Compare commits

..

2 Commits

Author SHA1 Message Date
root a167683bd9 bug fix
Deploy to Production / deploy-production (push) Successful in 49s Details
2026-02-23 21:44:27 +03:00
root 9382eab7b2 fix bugs 2026-02-23 15:44:27 +03:00
37 changed files with 4682 additions and 4257 deletions

View File

@ -479,6 +479,120 @@ def send_telegram_notification(notification):
raise raise
def _format_lesson_datetime_ru(dt, user_timezone='UTC'):
"""Форматирует дату/время для русского языка: «23 февраля 2026, 14:30»."""
if dt is None:
return ''
from apps.users.utils import convert_to_user_timezone
local_dt = convert_to_user_timezone(dt, user_timezone)
months_ru = (
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
)
day = local_dt.day
month = months_ru[local_dt.month - 1]
year = local_dt.year
time_str = local_dt.strftime('%H:%M')
return f'{day} {month} {year}, {time_str}'
@shared_task
def send_lesson_completion_confirmation_telegram(lesson_id, only_if_someone_not_connected=False):
"""
Отправить ментору в Telegram сообщение о завершённом занятии
с кнопками «Занятие состоялось» / «Занятие отменилось».
only_if_someone_not_connected: при True отправить только если ментор или ученик не подключались
(при авто-завершении Celery). При False всегда отправить (ручное завершение, сигнал).
"""
import asyncio
from apps.schedule.models import Lesson
from apps.video.models import VideoRoom
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from .telegram_bot import send_telegram_message_with_buttons
logger.info(f'send_lesson_completion_confirmation_telegram: lesson_id={lesson_id}')
try:
lesson = Lesson.objects.select_related('mentor', 'client', 'client__user').get(id=lesson_id)
except Lesson.DoesNotExist:
logger.warning(f'Lesson {lesson_id} not found for completion confirmation')
return
mentor = lesson.mentor
if not mentor:
logger.warning(f'Lesson {lesson_id}: mentor is None, skipping completion confirmation')
return
if not mentor.telegram_id:
logger.warning(
f'Lesson {lesson_id}: mentor {mentor.id} has no telegram_id linked, skipping completion confirmation'
)
return
tz = mentor.timezone or 'UTC'
student_name = ''
if lesson.client and lesson.client.user:
student_name = lesson.client.user.get_full_name() or lesson.client.user.email or 'Ученик'
else:
student_name = 'Ученик'
start_str = _format_lesson_datetime_ru(lesson.start_time, tz)
end_str = _format_lesson_datetime_ru(lesson.end_time, tz)
# Подключения: из Lesson или VideoRoom
mentor_connected = lesson.mentor_connected_at is not None
client_connected = lesson.client_connected_at is not None
if not mentor_connected and not client_connected:
try:
vr = VideoRoom.objects.filter(lesson=lesson).first()
if vr:
mentor_connected = vr.mentor_joined_at is not None
client_connected = vr.client_joined_at is not None
except Exception:
pass
mentor_status = '✅ Подключился' if mentor_connected else 'Не подключался'
client_status = '✅ Подключился' if client_connected else 'Не подключался'
someone_not_connected = not mentor_connected or not client_connected
if only_if_someone_not_connected and not someone_not_connected:
logger.info(f'Lesson {lesson_id}: both participants connected, skipping completion confirmation')
return
message = (
f"⏱ <b>Занятие завершилось по времени</b>\n\n"
f"📚 <b>{lesson.title}</b>\n"
f"👤 {student_name}\n\n"
f"🕐 <b>Время:</b> {start_str}{end_str}\n\n"
f"📡 <b>Подключения:</b>\n"
f" • Ментор: {mentor_status}\n"
f" • Ученик: {client_status}\n\n"
f"Подтвердите, пожалуйста:"
)
keyboard = [
[
InlineKeyboardButton("✅ Занятие состоялось", callback_data=f"lesson_confirm_{lesson_id}"),
InlineKeyboardButton("❌ Занятие отменилось", callback_data=f"lesson_cancel_{lesson_id}"),
]
]
reply_markup = InlineKeyboardMarkup(keyboard)
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
success = loop.run_until_complete(
send_telegram_message_with_buttons(
mentor.telegram_id, message, reply_markup, parse_mode='HTML'
)
)
loop.close()
if success:
logger.info(f'Lesson {lesson_id} completion confirmation sent to mentor {mentor.id}')
except Exception as e:
logger.error(f'Error sending lesson completion confirmation to mentor: {e}', exc_info=True)
@shared_task @shared_task
def send_bulk_notifications(notification_ids): def send_bulk_notifications(notification_ids):
""" """

View File

@ -1611,6 +1611,50 @@ class TelegramBot:
await query.answer("❌ Ошибка", show_alert=True) await query.answer("❌ Ошибка", show_alert=True)
return return
# Обработка подтверждения занятия (Занятие состоялось / Занятие отменилось)
if query.data.startswith('lesson_confirm_') or query.data.startswith('lesson_cancel_'):
try:
lesson_id = int(query.data.split('_')[-1])
is_confirmed = query.data.startswith('lesson_confirm_')
from apps.schedule.models import Lesson
from django.utils import timezone
lesson = await sync_to_async(
Lesson.objects.select_related('client', 'client__user', 'mentor').get
)(id=lesson_id)
user = update.effective_user
telegram_id = user.id
# Только ментор может подтверждать
if not lesson.mentor or lesson.mentor.telegram_id != telegram_id:
await query.answer("❌ Только ментор занятия может подтвердить.", show_alert=True)
return
if is_confirmed:
# Занятие состоялось — оставляем completed
await query.edit_message_text(
f"✅ <b>Подтверждено</b>\n\n"
f"Занятие «{lesson.title}» состоялось."
)
else:
# Занятие отменилось — меняем статус
lesson.status = 'cancelled'
lesson.cancelled_at = timezone.now()
await sync_to_async(lesson.save)(update_fields=['status', 'cancelled_at'])
await query.edit_message_text(
f"❌ <b>Отменено</b>\n\n"
f"Занятие «{lesson.title}» отмечено как отменённое."
)
await query.answer()
except Lesson.DoesNotExist:
await query.answer("❌ Занятие не найдено", show_alert=True)
except Exception as e:
logger.error(f"Error handling lesson confirmation: {e}", exc_info=True)
await query.answer("❌ Ошибка обработки", show_alert=True)
return
# Обработка подтверждения присутствия # Обработка подтверждения присутствия
if query.data.startswith('attendance_yes_') or query.data.startswith('attendance_no_'): if query.data.startswith('attendance_yes_') or query.data.startswith('attendance_no_'):
try: try:

View File

@ -0,0 +1,32 @@
# Generated manually
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("schedule", "0010_lesson_livekit_access_token_lesson_livekit_room_name_and_more"),
]
operations = [
migrations.AddField(
model_name="lesson",
name="mentor_connected_at",
field=models.DateTimeField(
blank=True,
help_text="Время подключения ментора к видеокомнате",
null=True,
verbose_name="Ментор подключился",
),
),
migrations.AddField(
model_name="lesson",
name="client_connected_at",
field=models.DateTimeField(
blank=True,
help_text="Время подключения студента к видеокомнате",
null=True,
verbose_name="Студент подключился",
),
),
]

View File

@ -364,6 +364,20 @@ class Lesson(models.Model):
# verbose_name='Время отправки напоминания' # verbose_name='Время отправки напоминания'
# ) # )
# Метрики подключения к видеокомнате (заполняются при подключении ментора/студента)
mentor_connected_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Ментор подключился',
help_text='Время подключения ментора к видеокомнате'
)
client_connected_at = models.DateTimeField(
null=True,
blank=True,
verbose_name='Студент подключился',
help_text='Время подключения студента к видеокомнате'
)
# Фактическое время завершения (если занятие завершено досрочно) # Фактическое время завершения (если занятие завершено досрочно)
completed_at = models.DateTimeField( completed_at = models.DateTimeField(
null=True, null=True,

View File

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

View File

@ -9,7 +9,7 @@ from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from .models import Lesson from .models import Lesson
from apps.notifications.tasks import send_lesson_notification from apps.notifications.tasks import send_lesson_notification, send_lesson_completion_confirmation_telegram
@receiver(post_save, sender=Lesson) @receiver(post_save, sender=Lesson)
@ -30,6 +30,9 @@ def lesson_saved(sender, instance, created, **kwargs):
lesson_id=instance.id, lesson_id=instance.id,
notification_type='lesson_created' notification_type='lesson_created'
) )
# Если занятие создано сразу в статусе completed (задним числом) — отправляем подтверждение в Telegram
if instance.status == 'completed':
send_lesson_completion_confirmation_telegram.delay(instance.id)
# Напоминания отправляются периодической задачей send_lesson_reminders # Напоминания отправляются периодической задачей send_lesson_reminders
# (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent) # (проверяет флаги reminder_24h_sent, reminder_1h_sent, reminder_15m_sent)
else: else:
@ -43,7 +46,16 @@ def lesson_saved(sender, instance, created, **kwargs):
) )
if instance.tracker.has_changed('status'): if instance.tracker.has_changed('status'):
# Статус изменился # При переводе в completed — всегда отправляем подтверждение в Telegram (с кнопками «состоялось»/«отменилось»)
# Работает для ручного завершения и для занятий, созданных/завершённых задним числом
if instance.status == 'completed':
send_lesson_completion_confirmation_telegram.delay(instance.id)
# Общие уведомления — не отправляем, если занятие уже в прошлом
# (коррекция статуса/стоимости после факта, например отмена задним числом)
ref_time = instance.end_time or instance.start_time
if ref_time and ref_time < timezone.now():
return # Занятие уже прошло — пропускаем lesson_cancelled / lesson_completed
if instance.status == 'cancelled': if instance.status == 'cancelled':
send_lesson_notification.delay( send_lesson_notification.delay(
lesson_id=instance.id, lesson_id=instance.id,

View File

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

View File

@ -19,7 +19,26 @@
{ "country_code": "RU", "country_name": "Россия", "city": "Тюмень", "timezone": "Asia/Yekaterinburg" }, { "country_code": "RU", "country_name": "Россия", "city": "Тюмень", "timezone": "Asia/Yekaterinburg" },
{ "country_code": "RU", "country_name": "Россия", "city": "Иркутск", "timezone": "Asia/Irkutsk" }, { "country_code": "RU", "country_name": "Россия", "city": "Иркутск", "timezone": "Asia/Irkutsk" },
{ "country_code": "RU", "country_name": "Россия", "city": "Владивосток", "timezone": "Asia/Vladivostok" }, { "country_code": "RU", "country_name": "Россия", "city": "Владивосток", "timezone": "Asia/Vladivostok" },
{ "country_code": "RU", "country_name": "Россия", "city": "Ульяновск", "timezone": "Europe/Samara" } { "country_code": "RU", "country_name": "Россия", "city": "Ульяновск", "timezone": "Europe/Samara" },
{ "country_code": "RU", "country_name": "Россия", "city": "Улан-Удэ", "timezone": "Asia/Irkutsk" },
{ "country_code": "RU", "country_name": "Россия", "city": "Чита", "timezone": "Asia/Chita" },
{ "country_code": "RU", "country_name": "Россия", "city": "Хабаровск", "timezone": "Asia/Vladivostok" },
{ "country_code": "RU", "country_name": "Россия", "city": "Барнаул", "timezone": "Asia/Barnaul" },
{ "country_code": "RU", "country_name": "Россия", "city": "Томск", "timezone": "Asia/Tomsk" },
{ "country_code": "RU", "country_name": "Россия", "city": "Кемерово", "timezone": "Asia/Novokuznetsk" },
{ "country_code": "RU", "country_name": "Россия", "city": "Новокузнецк", "timezone": "Asia/Novokuznetsk" },
{ "country_code": "RU", "country_name": "Россия", "city": "Якутск", "timezone": "Asia/Yakutsk" },
{ "country_code": "RU", "country_name": "Россия", "city": "Магадан", "timezone": "Asia/Magadan" },
{ "country_code": "RU", "country_name": "Россия", "city": "Петропавловск-Камчатский", "timezone": "Asia/Kamchatka" },
{ "country_code": "RU", "country_name": "Россия", "city": "Южно-Сахалинск", "timezone": "Asia/Sakhalin" },
{ "country_code": "RU", "country_name": "Россия", "city": "Ижевск", "timezone": "Europe/Samara" },
{ "country_code": "RU", "country_name": "Россия", "city": "Оренбург", "timezone": "Asia/Yekaterinburg" },
{ "country_code": "RU", "country_name": "Россия", "city": "Рязань", "timezone": "Europe/Moscow" },
{ "country_code": "RU", "country_name": "Россия", "city": "Пенза", "timezone": "Europe/Moscow" },
{ "country_code": "RU", "country_name": "Россия", "city": "Липецк", "timezone": "Europe/Moscow" },
{ "country_code": "RU", "country_name": "Россия", "city": "Астрахань", "timezone": "Europe/Astrakhan" },
{ "country_code": "RU", "country_name": "Россия", "city": "Сочи", "timezone": "Europe/Moscow" },
{ "country_code": "RU", "country_name": "Россия", "city": "Калининград", "timezone": "Europe/Kaliningrad" }
] ]

View File

@ -36,6 +36,22 @@ POPULAR_CITIES = [
{"country_code": "RU", "country_name": "Россия", "city": "Самара", "timezone": "Europe/Samara"}, {"country_code": "RU", "country_name": "Россия", "city": "Самара", "timezone": "Europe/Samara"},
{"country_code": "RU", "country_name": "Россия", "city": "Красноярск", "timezone": "Asia/Krasnoyarsk"}, {"country_code": "RU", "country_name": "Россия", "city": "Красноярск", "timezone": "Asia/Krasnoyarsk"},
{"country_code": "RU", "country_name": "Россия", "city": "Владивосток", "timezone": "Asia/Vladivostok"}, {"country_code": "RU", "country_name": "Россия", "city": "Владивосток", "timezone": "Asia/Vladivostok"},
{"country_code": "RU", "country_name": "Россия", "city": "Улан-Удэ", "timezone": "Asia/Irkutsk"},
{"country_code": "RU", "country_name": "Россия", "city": "Иркутск", "timezone": "Asia/Irkutsk"},
{"country_code": "RU", "country_name": "Россия", "city": "Чита", "timezone": "Asia/Chita"},
{"country_code": "RU", "country_name": "Россия", "city": "Хабаровск", "timezone": "Asia/Vladivostok"},
{"country_code": "RU", "country_name": "Россия", "city": "Омск", "timezone": "Asia/Omsk"},
{"country_code": "RU", "country_name": "Россия", "city": "Челябинск", "timezone": "Asia/Yekaterinburg"},
{"country_code": "RU", "country_name": "Россия", "city": "Уфа", "timezone": "Asia/Yekaterinburg"},
{"country_code": "RU", "country_name": "Россия", "city": "Ростов-на-Дону", "timezone": "Europe/Moscow"},
{"country_code": "RU", "country_name": "Россия", "city": "Пермь", "timezone": "Asia/Yekaterinburg"},
{"country_code": "RU", "country_name": "Россия", "city": "Воронеж", "timezone": "Europe/Moscow"},
{"country_code": "RU", "country_name": "Россия", "city": "Волгоград", "timezone": "Europe/Moscow"},
{"country_code": "RU", "country_name": "Россия", "city": "Краснодар", "timezone": "Europe/Moscow"},
{"country_code": "RU", "country_name": "Россия", "city": "Барнаул", "timezone": "Asia/Barnaul"},
{"country_code": "RU", "country_name": "Россия", "city": "Томск", "timezone": "Asia/Tomsk"},
{"country_code": "RU", "country_name": "Россия", "city": "Якутск", "timezone": "Asia/Yakutsk"},
{"country_code": "RU", "country_name": "Россия", "city": "Калининград", "timezone": "Europe/Kaliningrad"},
# Казахстан # Казахстан
{"country_code": "KZ", "country_name": "Казахстан", "city": "Алматы", "timezone": "Asia/Almaty"}, {"country_code": "KZ", "country_name": "Казахстан", "city": "Алматы", "timezone": "Asia/Almaty"},
{"country_code": "KZ", "country_name": "Казахстан", "city": "Астана", "timezone": "Asia/Almaty"}, {"country_code": "KZ", "country_name": "Казахстан", "city": "Астана", "timezone": "Asia/Almaty"},
@ -43,7 +59,6 @@ POPULAR_CITIES = [
{"country_code": "BY", "country_name": "Беларусь", "city": "Минск", "timezone": "Europe/Minsk"}, {"country_code": "BY", "country_name": "Беларусь", "city": "Минск", "timezone": "Europe/Minsk"},
# Украина # Украина
{"country_code": "UA", "country_name": "Украина", "city": "Киев", "timezone": "Europe/Kyiv"}, {"country_code": "UA", "country_name": "Украина", "city": "Киев", "timezone": "Europe/Kyiv"},
# Другие крупные города СНГ можно добавлять по мере необходимости
] ]

View File

@ -275,6 +275,84 @@ def delete_livekit_room_by_lesson(request, lesson_id):
) )
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def participant_connected(request):
"""
Отметить подключение участника к видеокомнате (для метрик).
Вызывается фронтендом при успешном подключении к LiveKit комнате.
POST /api/video/livekit/participant-connected/
Body: { "room_name": "uuid", "lesson_id": 123 } lesson_id опционален, резерв при 404 по room_name
"""
room_name = request.data.get('room_name')
lesson_id = request.data.get('lesson_id')
if not room_name and not lesson_id:
return Response(
{'error': 'Укажите room_name или lesson_id'},
status=status.HTTP_400_BAD_REQUEST
)
video_room = None
if room_name:
try:
import uuid as uuid_module
room_uuid = uuid_module.UUID(str(room_name).strip())
video_room = VideoRoom.objects.get(room_id=room_uuid)
except (ValueError, VideoRoom.DoesNotExist):
pass
if not video_room and lesson_id:
try:
video_room = VideoRoom.objects.get(lesson_id=lesson_id)
except VideoRoom.DoesNotExist:
pass
if not video_room:
logger.warning(
'participant_connected: VideoRoom not found (room_name=%s, lesson_id=%s, user=%s)',
room_name, lesson_id, getattr(request.user, 'pk', request.user)
)
return Response(
{'error': 'Видеокомната не найдена'},
status=status.HTTP_404_NOT_FOUND
)
user = request.user
client_user = video_room.client.user if hasattr(video_room.client, 'user') else video_room.client
is_mentor = user.pk == video_room.mentor_id
client_pk = getattr(client_user, 'pk', None) or getattr(client_user, 'id', None)
is_client = client_pk is not None and user.pk == client_pk
if not is_mentor and not is_client:
return Response(
{'error': 'Нет доступа к этой видеокомнате'},
status=status.HTTP_403_FORBIDDEN
)
from .models import VideoParticipant
participant, _ = VideoParticipant.objects.get_or_create(
room=video_room,
user=user,
defaults={
'is_connected': True,
'is_audio_enabled': True,
'is_video_enabled': True,
}
)
if not participant.is_connected:
participant.is_connected = True
participant.save(update_fields=['is_connected'])
video_room.mark_participant_joined(user)
logger.info(
'participant_connected: marked %s for lesson %s (room %s)',
'mentor' if is_mentor else 'client',
video_room.lesson_id,
video_room.room_id,
)
return Response({'success': True}, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def update_livekit_participant_media_state(request): def update_livekit_participant_media_state(request):
@ -336,6 +414,8 @@ def update_livekit_participant_media_state(request):
'is_video_enabled': video_enabled if video_enabled is not None else True, 'is_video_enabled': video_enabled if video_enabled is not None else True,
} }
) )
# Фиксируем подключение ментора/студента для метрик
video_room.mark_participant_joined(user)
if not created: if not created:
# Обновляем существующего участника # Обновляем существующего участника

View File

@ -1,4 +1,4 @@
""" """
Модели для видеоконференций. Модели для видеоконференций.
""" """
from django.db import models from django.db import models
@ -219,13 +219,27 @@ class VideoRoom(models.Model):
self.save() self.save()
def mark_participant_joined(self, user): def mark_participant_joined(self, user):
"""Отметить что участник подключился.""" """Отметить что участник подключился (также обновляет Lesson для метрик)."""
if user == self.mentor: now = timezone.now()
self.mentor_joined_at = timezone.now() update_fields = []
elif user == self.client: user_pk = getattr(user, 'pk', None) or getattr(user, 'id', None)
self.client_joined_at = timezone.now() if user_pk is not None and user_pk == self.mentor_id:
self.mentor_joined_at = now
update_fields.append('mentor_joined_at')
elif user_pk is not None and user_pk == self.client_id:
self.client_joined_at = now
update_fields.append('client_joined_at')
self.save(update_fields=['mentor_joined_at', 'client_joined_at']) if update_fields:
self.save(update_fields=update_fields)
# Синхронизируем метрики на занятие для аналитики
lesson = self.lesson
if user_pk == self.mentor_id and not lesson.mentor_connected_at:
lesson.mentor_connected_at = now
lesson.save(update_fields=['mentor_connected_at'])
elif user_pk == self.client_id and not lesson.client_connected_at:
lesson.client_connected_at = now
lesson.save(update_fields=['client_connected_at'])
@property @property
def is_active(self): def is_active(self):

View File

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

View File

@ -1,4 +1,4 @@
""" """
URL routing для видео API. URL routing для видео API.
""" """
from django.urls import path, include from django.urls import path, include
@ -11,7 +11,7 @@ from .views import (
) )
from .janus_views import JanusVideoRoomViewSet from .janus_views import JanusVideoRoomViewSet
from .token_views import VideoRoomTokenViewSet from .token_views import VideoRoomTokenViewSet
from .livekit_views import create_livekit_room, get_livekit_config, delete_livekit_room_by_lesson, update_livekit_participant_media_state from .livekit_views import create_livekit_room, get_livekit_config, delete_livekit_room_by_lesson, update_livekit_participant_media_state, participant_connected
router = DefaultRouter() router = DefaultRouter()
router.register(r'rooms', VideoRoomViewSet, basename='videoroom') router.register(r'rooms', VideoRoomViewSet, basename='videoroom')
@ -32,4 +32,5 @@ urlpatterns = [
path('livekit/config/', get_livekit_config, name='livekit-config'), path('livekit/config/', get_livekit_config, name='livekit-config'),
path('livekit/rooms/lesson/<int:lesson_id>/', delete_livekit_room_by_lesson, name='livekit-delete-room-by-lesson'), path('livekit/rooms/lesson/<int:lesson_id>/', delete_livekit_room_by_lesson, name='livekit-delete-room-by-lesson'),
path('livekit/update-media-state/', update_livekit_participant_media_state, name='livekit-update-media-state'), path('livekit/update-media-state/', update_livekit_participant_media_state, name='livekit-update-media-state'),
path('livekit/participant-connected/', participant_connected, name='livekit-participant-connected'),
] ]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -50,3 +50,17 @@ export async function getLiveKitConfig(): Promise<LiveKitConfig> {
const res = await apiClient.get<LiveKitConfig>('/video/livekit/config/'); const res = await apiClient.get<LiveKitConfig>('/video/livekit/config/');
return res.data; return res.data;
} }
/**
* Отметить подключение участника к видеокомнате (для метрик).
* lessonId резерв при 404 по room_name (например, если room.name отличается от БД).
*/
export async function participantConnected(params: {
roomName: string;
lessonId?: number | null;
}): Promise<void> {
const { roomName, lessonId } = params;
const body: { room_name: string; lesson_id?: number } = { room_name: roomName };
if (lessonId != null) body.lesson_id = lessonId;
await apiClient.post('/video/livekit/participant-connected/', body);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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