Compare commits
No commits in common. "a167683bd9c39a7db08d7ebd79bccc9ba2d77d63" and "d9121fe6ef01ecf6dcc017e4cf9617bdf343eec3" have entirely different histories.
a167683bd9
...
d9121fe6ef
File diff suppressed because it is too large
Load Diff
|
|
@ -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}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -479,120 +479,6 @@ 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):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1611,50 +1611,6 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
# Generated manually
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
dependencies = [
|
|
||||||
("schedule", "0010_lesson_livekit_access_token_lesson_livekit_room_name_and_more"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="lesson",
|
|
||||||
name="mentor_connected_at",
|
|
||||||
field=models.DateTimeField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Время подключения ментора к видеокомнате",
|
|
||||||
null=True,
|
|
||||||
verbose_name="Ментор подключился",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="lesson",
|
|
||||||
name="client_connected_at",
|
|
||||||
field=models.DateTimeField(
|
|
||||||
blank=True,
|
|
||||||
help_text="Время подключения студента к видеокомнате",
|
|
||||||
null=True,
|
|
||||||
verbose_name="Студент подключился",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
@ -364,20 +364,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ class LessonSerializer(serializers.ModelSerializer):
|
||||||
'livekit_room_name'
|
'livekit_room_name'
|
||||||
]
|
]
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'id', 'end_time', 'reminder_sent',
|
'id', 'end_time', 'status', 'reminder_sent',
|
||||||
'created_at', 'updated_at', 'livekit_room_name'
|
'created_at', 'updated_at', 'livekit_room_name'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -114,16 +114,6 @@ 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
|
||||||
|
|
@ -131,12 +121,10 @@ 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 минут в прошлом
|
# Проверка что занятие в будущем
|
||||||
now = timezone.now()
|
if start_time and start_time <= timezone.now():
|
||||||
tolerance = timedelta(minutes=30)
|
|
||||||
if start_time and start_time < now - tolerance:
|
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
|
'start_time': 'Занятие должно быть запланировано в будущем'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Проверка конфликтов (только при создании или изменении времени)
|
# Проверка конфликтов (только при создании или изменении времени)
|
||||||
|
|
@ -380,7 +368,8 @@ 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)
|
||||||
|
|
@ -388,10 +377,9 @@ 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()
|
||||||
tolerance = timedelta(minutes=30)
|
if start_time <= now:
|
||||||
if start_time < now - tolerance:
|
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
|
'start_time': f'Занятие должно быть запланировано в будущем. Текущее время: {now.isoformat()}, указанное время: {start_time.isoformat()}'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Рассчитываем время окончания
|
# Рассчитываем время окончания
|
||||||
|
|
@ -633,12 +621,12 @@ class LessonCalendarSerializer(serializers.Serializer):
|
||||||
'end_date': 'Дата окончания должна быть позже даты начала'
|
'end_date': 'Дата окончания должна быть позже даты начала'
|
||||||
})
|
})
|
||||||
|
|
||||||
# Ограничение диапазона (не более 6 месяцев — для календаря, смена месяцев)
|
# Ограничение диапазона (не более 3 месяцев)
|
||||||
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 > 180:
|
if delta.days > 90:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
'Диапазон не может превышать 180 дней'
|
'Диапазон не может превышать 90 дней'
|
||||||
)
|
)
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,93 @@
|
||||||
"""
|
"""
|
||||||
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, send_lesson_completion_confirmation_telegram
|
from apps.notifications.tasks import send_lesson_notification
|
||||||
|
|
||||||
|
|
||||||
@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'
|
||||||
)
|
)
|
||||||
# Если занятие создано сразу в статусе completed (задним числом) — отправляем подтверждение в Telegram
|
# Напоминания отправляются периодической задачей send_lesson_reminders
|
||||||
if instance.status == 'completed':
|
# (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent)
|
||||||
send_lesson_completion_confirmation_telegram.delay(instance.id)
|
else:
|
||||||
# Напоминания отправляются периодической задачей send_lesson_reminders
|
# Занятие изменено
|
||||||
# (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent)
|
# Проверяем, что именно изменилось
|
||||||
else:
|
if instance.tracker.has_changed('start_time') or instance.tracker.has_changed('end_time'):
|
||||||
# Занятие изменено
|
# Время изменилось
|
||||||
# Проверяем, что именно изменилось
|
send_lesson_notification.delay(
|
||||||
if instance.tracker.has_changed('start_time') or instance.tracker.has_changed('end_time'):
|
lesson_id=instance.id,
|
||||||
# Время изменилось
|
notification_type='lesson_rescheduled'
|
||||||
send_lesson_notification.delay(
|
)
|
||||||
lesson_id=instance.id,
|
|
||||||
notification_type='lesson_rescheduled'
|
if instance.tracker.has_changed('status'):
|
||||||
)
|
# Статус изменился
|
||||||
|
if instance.status == 'cancelled':
|
||||||
if instance.tracker.has_changed('status'):
|
send_lesson_notification.delay(
|
||||||
# При переводе в completed — всегда отправляем подтверждение в Telegram (с кнопками «состоялось»/«отменилось»)
|
lesson_id=instance.id,
|
||||||
# Работает для ручного завершения и для занятий, созданных/завершённых задним числом
|
notification_type='lesson_cancelled'
|
||||||
if instance.status == 'completed':
|
)
|
||||||
send_lesson_completion_confirmation_telegram.delay(instance.id)
|
elif instance.status == 'completed':
|
||||||
|
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
|
|
||||||
if instance.status == 'cancelled':
|
@receiver(pre_delete, sender=Lesson)
|
||||||
send_lesson_notification.delay(
|
def lesson_deleted(sender, instance, **kwargs):
|
||||||
lesson_id=instance.id,
|
"""
|
||||||
notification_type='lesson_cancelled'
|
Обработка удаления занятия.
|
||||||
)
|
Отправка уведомления об отмене.
|
||||||
elif instance.status == 'completed':
|
"""
|
||||||
send_lesson_notification.delay(
|
if instance.status != 'cancelled':
|
||||||
lesson_id=instance.id,
|
send_lesson_notification.delay(
|
||||||
notification_type='lesson_completed'
|
lesson_id=instance.id,
|
||||||
)
|
notification_type='lesson_cancelled'
|
||||||
|
)
|
||||||
|
|
||||||
@receiver(pre_delete, sender=Lesson)
|
|
||||||
def lesson_deleted(sender, instance, **kwargs):
|
@receiver(pre_save, sender=Lesson)
|
||||||
"""
|
def lesson_before_save(sender, instance, **kwargs):
|
||||||
Обработка удаления занятия.
|
"""
|
||||||
Отправка уведомления об отмене.
|
Действия перед сохранением занятия.
|
||||||
"""
|
Инициализация tracker для отслеживания изменений.
|
||||||
if instance.status != 'cancelled':
|
"""
|
||||||
send_lesson_notification.delay(
|
if not hasattr(instance, 'tracker'):
|
||||||
lesson_id=instance.id,
|
# Создаем простой tracker для отслеживания изменений
|
||||||
notification_type='lesson_cancelled'
|
if instance.pk:
|
||||||
)
|
try:
|
||||||
|
old_instance = Lesson.objects.get(pk=instance.pk)
|
||||||
|
instance.tracker = type('obj', (object,), {
|
||||||
@receiver(pre_save, sender=Lesson)
|
'has_changed': lambda field: getattr(old_instance, field) != getattr(instance, field)
|
||||||
def lesson_before_save(sender, instance, **kwargs):
|
})
|
||||||
"""
|
except Lesson.DoesNotExist:
|
||||||
Действия перед сохранением занятия.
|
instance.tracker = type('obj', (object,), {
|
||||||
Инициализация tracker для отслеживания изменений.
|
'has_changed': lambda field: False
|
||||||
"""
|
})
|
||||||
if not hasattr(instance, 'tracker'):
|
else:
|
||||||
# Создаем простой tracker для отслеживания изменений
|
instance.tracker = type('obj', (object,), {
|
||||||
if instance.pk:
|
'has_changed': lambda field: False
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -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, Q
|
from django.db.models import Count
|
||||||
from .models import Lesson, Subject, MentorSubject
|
from .models import Lesson, Subject, MentorSubject
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -380,8 +380,7 @@ 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 (время начала уже наступило)
|
||||||
|
|
@ -403,22 +402,17 @@ 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 - 15 минут (всегда ждём 15 мин после конца, даже если занятие создали задним числом)
|
# end_time < now - 5 минут (время окончания прошло более 5 минут назад - даём время на завершение)
|
||||||
fifteen_minutes_ago = now - timedelta(minutes=15)
|
# status in ['scheduled', 'in_progress'] (еще не завершены)
|
||||||
|
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=fifteen_minutes_ago
|
end_time__lt=five_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
|
||||||
|
|
@ -443,14 +437,6 @@ 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}')
|
||||||
|
|
|
||||||
|
|
@ -19,26 +19,7 @@
|
||||||
{ "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" }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,22 +36,6 @@ 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"},
|
||||||
|
|
@ -59,6 +43,7 @@ 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"},
|
||||||
|
# Другие крупные города СНГ можно добавлять по мере необходимости
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -275,84 +275,6 @@ def delete_livekit_room_by_lesson(request, lesson_id):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
|
||||||
@permission_classes([IsAuthenticated])
|
|
||||||
def participant_connected(request):
|
|
||||||
"""
|
|
||||||
Отметить подключение участника к видеокомнате (для метрик).
|
|
||||||
Вызывается фронтендом при успешном подключении к LiveKit комнате.
|
|
||||||
|
|
||||||
POST /api/video/livekit/participant-connected/
|
|
||||||
Body: { "room_name": "uuid", "lesson_id": 123 } — lesson_id опционален, резерв при 404 по room_name
|
|
||||||
"""
|
|
||||||
room_name = request.data.get('room_name')
|
|
||||||
lesson_id = request.data.get('lesson_id')
|
|
||||||
|
|
||||||
if not room_name and not lesson_id:
|
|
||||||
return Response(
|
|
||||||
{'error': 'Укажите room_name или lesson_id'},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
video_room = None
|
|
||||||
if room_name:
|
|
||||||
try:
|
|
||||||
import uuid as uuid_module
|
|
||||||
room_uuid = uuid_module.UUID(str(room_name).strip())
|
|
||||||
video_room = VideoRoom.objects.get(room_id=room_uuid)
|
|
||||||
except (ValueError, VideoRoom.DoesNotExist):
|
|
||||||
pass
|
|
||||||
if not video_room and lesson_id:
|
|
||||||
try:
|
|
||||||
video_room = VideoRoom.objects.get(lesson_id=lesson_id)
|
|
||||||
except VideoRoom.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not video_room:
|
|
||||||
logger.warning(
|
|
||||||
'participant_connected: VideoRoom not found (room_name=%s, lesson_id=%s, user=%s)',
|
|
||||||
room_name, lesson_id, getattr(request.user, 'pk', request.user)
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
{'error': 'Видеокомната не найдена'},
|
|
||||||
status=status.HTTP_404_NOT_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
user = request.user
|
|
||||||
client_user = video_room.client.user if hasattr(video_room.client, 'user') else video_room.client
|
|
||||||
is_mentor = user.pk == video_room.mentor_id
|
|
||||||
client_pk = getattr(client_user, 'pk', None) or getattr(client_user, 'id', None)
|
|
||||||
is_client = client_pk is not None and user.pk == client_pk
|
|
||||||
if not is_mentor and not is_client:
|
|
||||||
return Response(
|
|
||||||
{'error': 'Нет доступа к этой видеокомнате'},
|
|
||||||
status=status.HTTP_403_FORBIDDEN
|
|
||||||
)
|
|
||||||
|
|
||||||
from .models import VideoParticipant
|
|
||||||
participant, _ = VideoParticipant.objects.get_or_create(
|
|
||||||
room=video_room,
|
|
||||||
user=user,
|
|
||||||
defaults={
|
|
||||||
'is_connected': True,
|
|
||||||
'is_audio_enabled': True,
|
|
||||||
'is_video_enabled': True,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if not participant.is_connected:
|
|
||||||
participant.is_connected = True
|
|
||||||
participant.save(update_fields=['is_connected'])
|
|
||||||
|
|
||||||
video_room.mark_participant_joined(user)
|
|
||||||
logger.info(
|
|
||||||
'participant_connected: marked %s for lesson %s (room %s)',
|
|
||||||
'mentor' if is_mentor else 'client',
|
|
||||||
video_room.lesson_id,
|
|
||||||
video_room.room_id,
|
|
||||||
)
|
|
||||||
return Response({'success': True}, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def update_livekit_participant_media_state(request):
|
def update_livekit_participant_media_state(request):
|
||||||
|
|
@ -414,8 +336,6 @@ def update_livekit_participant_media_state(request):
|
||||||
'is_video_enabled': video_enabled if video_enabled is not None else True,
|
'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
|
|
@ -26,11 +26,10 @@ 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=client_user,
|
client=instance.client,
|
||||||
is_recording=True, # По умолчанию включаем запись
|
is_recording=True, # По умолчанию включаем запись
|
||||||
max_participants=2
|
max_participants=2
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,35 @@
|
||||||
"""
|
"""
|
||||||
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, participant_connected
|
from .livekit_views import create_livekit_room, get_livekit_config, delete_livekit_room_by_lesson, update_livekit_participant_media_state
|
||||||
|
|
||||||
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'),
|
]
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -81,12 +81,6 @@ 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': {
|
||||||
|
|
|
||||||
|
|
@ -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, livekit 7880/7881,
|
# front_material 3010, yjs 1236, excalidraw 3004, whiteboard 8083,
|
||||||
# celery/beat — без портов (внутренние)
|
# livekit 7880/7881, 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,6 +273,17 @@ 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 без бэкапа!
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,12 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"builder": {
|
|
||||||
"gc": {
|
|
||||||
"defaultKeepStorage": "10GB",
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"log-driver": "json-file",
|
|
||||||
"log-opts": {
|
|
||||||
"max-size": "10m",
|
|
||||||
"max-file": "3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,6 @@ node_modules
|
||||||
*.md
|
*.md
|
||||||
.env*.local
|
.env*.local
|
||||||
.env
|
.env
|
||||||
.env.*
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|
@ -16,10 +15,3 @@ coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
docs
|
|
||||||
.cursor
|
|
||||||
agent-transcripts
|
|
||||||
__pycache__
|
|
||||||
*.pyc
|
|
||||||
.pytest_cache
|
|
||||||
.mypy_cache
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
/** Максимальный балл (1–5 по умолчанию). По умолчанию 5. */
|
/** Максимальный балл (1–5 по умолчанию). По умолчанию 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Проверить решение через ИИ. Ментор: задание + решение → комментарий и оценка 1–5. */
|
/** Проверить решение через ИИ. Ментор: задание + решение → комментарий и оценка 1–5. */
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,52 @@
|
||||||
/**
|
/**
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -147,8 +147,6 @@ export interface UpdateLessonData {
|
||||||
start_time?: string;
|
start_time?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
price?: number;
|
price?: number;
|
||||||
/** Для завершённых занятий — можно изменить статус (cancelled и т.д.) */
|
|
||||||
status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
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([
|
||||||
|
|
@ -40,7 +39,7 @@ export default function ForgotPasswordPage() {
|
||||||
await requestPasswordReset({ email });
|
await requestPasswordReset({ email });
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(getErrorMessage(err, 'Ошибка при отправке запроса. Проверьте email.'));
|
setError(err.response?.data?.detail || 'Ошибка при отправке запроса. Проверьте email.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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([
|
||||||
|
|
@ -145,7 +144,14 @@ export default function RegisterPage() {
|
||||||
setRegistrationSuccess(true);
|
setRegistrationSuccess(true);
|
||||||
return;
|
return;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(getErrorMessage(err, 'Ошибка регистрации. Проверьте данные.'));
|
setError(
|
||||||
|
err.response?.data?.detail ||
|
||||||
|
(Array.isArray(err.response?.data?.email)
|
||||||
|
? err.response.data.email[0]
|
||||||
|
: err.response?.data?.email) ||
|
||||||
|
err.response?.data?.message ||
|
||||||
|
'Ошибка регистрации. Проверьте данные.'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
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([
|
||||||
|
|
@ -44,7 +43,11 @@ function ResetPasswordContent() {
|
||||||
await confirmPasswordReset(token, password, confirmPassword);
|
await confirmPasswordReset(token, password, confirmPassword);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(getErrorMessage(err, 'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.'));
|
setError(
|
||||||
|
err.response?.data?.error?.message ||
|
||||||
|
err.response?.data?.detail ||
|
||||||
|
'Не удалось сменить пароль. Ссылка могла устареть — запросите новую.'
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
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([
|
||||||
|
|
@ -48,7 +47,11 @@ function VerifyEmailContent() {
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setMessage(getErrorMessage(err, 'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.'));
|
const msg =
|
||||||
|
err.response?.data?.error?.message ||
|
||||||
|
err.response?.data?.detail ||
|
||||||
|
'Неверная или устаревшая ссылка. Запросите новое письмо с подтверждением.';
|
||||||
|
setMessage(msg);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ 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';
|
||||||
|
|
@ -62,8 +61,7 @@ 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);
|
||||||
|
|
@ -266,9 +264,7 @@ 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 || '';
|
||||||
|
|
@ -333,23 +329,20 @@ export default function SchedulePage() {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isCompleted = editingLessonStatus === 'completed';
|
if (!formData.client) {
|
||||||
if (!isCompleted) {
|
setFormError('Выберите ученика');
|
||||||
if (!formData.client) {
|
setFormLoading(false);
|
||||||
setFormError('Выберите ученика');
|
return;
|
||||||
setFormLoading(false);
|
}
|
||||||
return;
|
if (!selectedSubjectId && !selectedMentorSubjectId) {
|
||||||
}
|
setFormError('Выберите предмет');
|
||||||
if (!selectedSubjectId && !selectedMentorSubjectId) {
|
setFormLoading(false);
|
||||||
setFormError('Выберите предмет');
|
return;
|
||||||
setFormLoading(false);
|
}
|
||||||
return;
|
if (!formData.start_date || !formData.start_time) {
|
||||||
}
|
setFormError('Укажите дату и время');
|
||||||
if (!formData.start_date || !formData.start_time) {
|
setFormLoading(false);
|
||||||
setFormError('Укажите дату и время');
|
return;
|
||||||
setFormLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (formData.price == null || formData.price < 0) {
|
if (formData.price == null || formData.price < 0) {
|
||||||
setFormError('Укажите стоимость занятия');
|
setFormError('Укажите стоимость занятия');
|
||||||
|
|
@ -357,26 +350,22 @@ export default function SchedulePage() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startUtc = !isCompleted
|
// Конвертируем время из timezone пользователя в UTC
|
||||||
? createDateTimeInUserTimezone(formData.start_date, formData.start_time, user?.timezone)
|
const startUtc = createDateTimeInUserTimezone(
|
||||||
: '';
|
formData.start_date,
|
||||||
|
formData.start_time,
|
||||||
|
user?.timezone
|
||||||
|
);
|
||||||
const title = generateTitle();
|
const title = generateTitle();
|
||||||
|
|
||||||
if (isEditingMode && editingLessonId) {
|
if (isEditingMode && editingLessonId) {
|
||||||
if (editingLessonStatus === 'completed') {
|
await updateLesson(editingLessonId, {
|
||||||
await updateLesson(editingLessonId, {
|
title,
|
||||||
price: formData.price,
|
description: formData.description,
|
||||||
status: formData.status ?? 'completed',
|
start_time: startUtc,
|
||||||
});
|
duration: formData.duration,
|
||||||
} else {
|
price: formData.price,
|
||||||
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,
|
||||||
|
|
@ -395,10 +384,16 @@ export default function SchedulePage() {
|
||||||
|
|
||||||
setIsFormVisible(false);
|
setIsFormVisible(false);
|
||||||
setEditingLessonId(null);
|
setEditingLessonId(null);
|
||||||
setEditingLessonStatus(null);
|
|
||||||
loadLessons();
|
loadLessons();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setFormError(getErrorMessage(err, 'Не удалось сохранить занятие. Проверьте данные.'));
|
const msg = err?.response?.data
|
||||||
|
? typeof err.response.data === 'object'
|
||||||
|
? Object.entries(err.response.data)
|
||||||
|
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
|
||||||
|
.join('\n')
|
||||||
|
: String(err.response.data)
|
||||||
|
: err?.message || 'Ошибка сохранения занятия';
|
||||||
|
setFormError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -412,10 +407,9 @@ 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(getErrorMessage(err, 'Не удалось удалить занятие.'));
|
setFormError(err?.message || 'Ошибка удаления занятия');
|
||||||
} finally {
|
} finally {
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -426,7 +420,6 @@ export default function SchedulePage() {
|
||||||
setIsEditingMode(false);
|
setIsEditingMode(false);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditingLessonId(null);
|
setEditingLessonId(null);
|
||||||
setEditingLessonStatus(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -483,7 +476,6 @@ 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>
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,6 @@ 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;
|
||||||
|
|
@ -245,6 +244,7 @@ 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,14 +252,13 @@ 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]);
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ 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 {
|
||||||
|
|
@ -74,8 +72,6 @@ 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> = ({
|
||||||
|
|
@ -106,7 +102,6 @@ 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;
|
||||||
|
|
@ -147,11 +142,21 @@ 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 style={{ position: 'relative', width: '100%', height: '100%' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
transformStyle: 'preserve-3d',
|
||||||
|
transition: 'transform 0.6s ease-in-out',
|
||||||
|
transform: isFormVisible ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Лицевая сторона: Список занятий */}
|
{/* Лицевая сторона: Список занятий */}
|
||||||
<div
|
<div
|
||||||
className="ios-glass-panel"
|
className="ios-glass-panel"
|
||||||
|
|
@ -161,12 +166,13 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
|
||||||
left: 0,
|
left: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
opacity: isFormVisible ? 0 : 1,
|
backfaceVisibility: 'hidden',
|
||||||
visibility: isFormVisible ? 'hidden' : 'visible',
|
WebkitBackfaceVisibility: 'hidden',
|
||||||
transition: 'opacity 0.2s ease',
|
transform: 'rotateY(0deg)',
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
overflowY: 'hidden',
|
overflowY: 'hidden',
|
||||||
|
transformOrigin: 'center center',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
|
|
@ -363,12 +369,13 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
|
||||||
left: 0,
|
left: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
opacity: isFormVisible ? 1 : 0,
|
backfaceVisibility: 'hidden',
|
||||||
visibility: isFormVisible ? 'visible' : 'hidden',
|
WebkitBackfaceVisibility: 'hidden',
|
||||||
transition: 'opacity 0.2s ease',
|
transform: 'rotateY(180deg)',
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
|
transformOrigin: 'center center',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
|
|
@ -406,11 +413,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
|
||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isCompletedLesson
|
{isEditingMode ? 'Редактировать занятие' : 'Создать занятие'}
|
||||||
? 'Изменить завершённое занятие'
|
|
||||||
: isEditingMode
|
|
||||||
? 'Редактировать занятие'
|
|
||||||
: 'Создать занятие'}
|
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -456,7 +459,6 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isCompletedLesson && (
|
|
||||||
<div style={{ gridColumn: 1 }}>
|
<div style={{ gridColumn: 1 }}>
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -477,9 +479,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!isCompletedLesson && (
|
|
||||||
<div style={{ gridColumn: 2 }}>
|
<div style={{ gridColumn: 2 }}>
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -509,9 +509,7 @@ 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={{
|
||||||
|
|
@ -553,10 +551,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!isCompletedLesson && (
|
|
||||||
<>
|
|
||||||
<div style={{ gridColumn: 1 }}>
|
<div style={{ gridColumn: 1 }}>
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -692,51 +687,10 @@ 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 || isCompletedLesson}
|
disabled={formLoading}
|
||||||
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={{
|
||||||
|
|
@ -749,7 +703,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
{isEditingMode && !isCompletedLesson && onDelete && (
|
{isEditingMode && onDelete && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDeleteClick}
|
onClick={handleDeleteClick}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
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';
|
||||||
|
|
@ -301,7 +300,14 @@ export const CreateLessonDialog: React.FC<CreateLessonDialogProps> = ({
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[CreateLessonDialog] Ошибка создания занятия:', err);
|
console.error('[CreateLessonDialog] Ошибка создания занятия:', err);
|
||||||
setError(getErrorMessage(err, 'Не удалось создать занятие. Проверьте данные.'));
|
if (err.response?.data) {
|
||||||
|
const fieldErrors = Object.entries(err.response.data)
|
||||||
|
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : value}`)
|
||||||
|
.join('\n');
|
||||||
|
setError(fieldErrors);
|
||||||
|
} else {
|
||||||
|
setError(err.message || 'Ошибка создания занятия');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,40 +43,52 @@ 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-front"
|
className="flip-card-inner"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'relative',
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
opacity: isFlipped ? 0 : 1,
|
transition: 'transform 0.6s',
|
||||||
visibility: isFlipped ? 'hidden' : 'visible',
|
transformStyle: 'preserve-3d',
|
||||||
transition: 'opacity 0.2s ease',
|
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{front}
|
<div
|
||||||
</div>
|
className="flip-card-front"
|
||||||
<div
|
style={{
|
||||||
className="flip-card-back"
|
position: 'absolute',
|
||||||
style={{
|
top: 0,
|
||||||
position: 'absolute',
|
left: 0,
|
||||||
top: 0,
|
width: '100%',
|
||||||
left: 0,
|
height: '100%',
|
||||||
width: '100%',
|
backfaceVisibility: 'hidden',
|
||||||
height: '100%',
|
WebkitBackfaceVisibility: 'hidden',
|
||||||
opacity: isFlipped ? 1 : 0,
|
}}
|
||||||
visibility: isFlipped ? 'visible' : 'hidden',
|
>
|
||||||
transition: 'opacity 0.2s ease',
|
{front}
|
||||||
}}
|
</div>
|
||||||
>
|
<div
|
||||||
{back}
|
className="flip-card-back"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
|
WebkitBackfaceVisibility: 'hidden',
|
||||||
|
transform: 'rotateY(180deg)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{back}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -61,7 +61,6 @@ 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';
|
||||||
|
|
||||||
|
|
@ -447,20 +446,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -2132,7 +2132,7 @@ img {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flip-карточка (переключение без переворота) */
|
/* Flip-карточка эффект */
|
||||||
.flip-card {
|
.flip-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -2142,6 +2142,14 @@ 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;
|
||||||
|
|
@ -2149,6 +2157,12 @@ 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue