full
Deploy to Production / deploy-production (push) Successful in 27s
Details
Deploy to Production / deploy-production (push) Successful in 27s
Details
This commit is contained in:
parent
8c6406269c
commit
0b5fb434db
|
|
@ -283,11 +283,15 @@ def run_mentor_ai_check_submission(self, submission_id, publish):
|
||||||
submission.save(update_fields=['graded_by_ai'])
|
submission.save(update_fields=['graded_by_ai'])
|
||||||
invalidate_dashboard_cache(submission.student.id, 'client')
|
invalidate_dashboard_cache(submission.student.id, 'client')
|
||||||
invalidate_dashboard_cache(mentor.id, 'mentor')
|
invalidate_dashboard_cache(mentor.id, 'mentor')
|
||||||
|
msg_student = f'Проверено ДЗ "{homework_title}". Оценка: {score}/5'
|
||||||
|
if feedback and str(feedback).strip():
|
||||||
|
comment = (feedback[:500] + '…') if len(feedback) > 500 else feedback
|
||||||
|
msg_student += f'\n\n💬 Комментарий:\n{comment}'
|
||||||
NotificationService.create_notification_with_telegram(
|
NotificationService.create_notification_with_telegram(
|
||||||
recipient=submission.student,
|
recipient=submission.student,
|
||||||
notification_type='homework_reviewed',
|
notification_type='homework_reviewed',
|
||||||
title='✅ ДЗ проверено',
|
title='✅ ДЗ проверено',
|
||||||
message=f'Проверено ДЗ "{homework_title}". Оценка: {score}/5',
|
message=msg_student,
|
||||||
priority='normal',
|
priority='normal',
|
||||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||||
content_object=submission
|
content_object=submission
|
||||||
|
|
@ -308,11 +312,24 @@ def run_mentor_ai_check_submission(self, submission_id, publish):
|
||||||
submission.ai_checked_at = timezone.now()
|
submission.ai_checked_at = timezone.now()
|
||||||
submission.save(update_fields=['ai_score', 'ai_feedback', 'ai_checked_at'])
|
submission.save(update_fields=['ai_score', 'ai_feedback', 'ai_checked_at'])
|
||||||
invalidate_dashboard_cache(mentor.id, 'mentor')
|
invalidate_dashboard_cache(mentor.id, 'mentor')
|
||||||
|
# Формируем сообщение с комментарием ИИ (HTML форматирование для Telegram)
|
||||||
|
feedback_preview = feedback[:400] + "..." if len(feedback) > 400 else feedback
|
||||||
|
# Экранируем HTML символы в комментарии
|
||||||
|
import html
|
||||||
|
feedback_escaped = html.escape(feedback_preview)
|
||||||
|
message_text = (
|
||||||
|
f'{student_name} — ДЗ «{homework_title}»\n\n'
|
||||||
|
f'🤖 <b>Предварительная проверка ИИ:</b>\n'
|
||||||
|
f'⭐ Оценка: {score}/5\n\n'
|
||||||
|
f'💬 <b>Комментарий ИИ:</b>\n<i>{feedback_escaped}</i>\n\n'
|
||||||
|
f'📝 Сохранено как черновик.\n\n'
|
||||||
|
f'В боте нажмите «Домашние задания» → выберите это задание — там кнопки «Редактировать ответ» и «Сохранить ответ».'
|
||||||
|
)
|
||||||
NotificationService.create_notification_with_telegram(
|
NotificationService.create_notification_with_telegram(
|
||||||
recipient=mentor,
|
recipient=mentor,
|
||||||
notification_type='homework_submitted',
|
notification_type='homework_submitted',
|
||||||
title='🤖 ИИ проверил ДЗ, статус: черновик',
|
title='🤖 ИИ проверил ДЗ, статус: черновик',
|
||||||
message=f'{student_name} — ДЗ «{homework_title}»: предварительная оценка {score}/5. ИИ сохранил как черновик — можете отредактировать и опубликовать.',
|
message=message_text,
|
||||||
priority='normal',
|
priority='normal',
|
||||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||||
content_object=submission
|
content_object=submission
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
API views для домашних заданий.
|
API views для домашних заданий.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import html
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
@ -732,11 +733,18 @@ class HomeworkSubmissionViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
# Отправляем уведомление студенту
|
# Отправляем уведомление студенту
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService
|
||||||
|
|
||||||
|
feedback_text = ""
|
||||||
|
if submission.feedback:
|
||||||
|
# Экранируем HTML теги в комментарии, чтобы не сломать разметку Telegram
|
||||||
|
escaped_feedback = html.escape(submission.feedback)
|
||||||
|
feedback_text = f"\n\n💬 <b>Комментарий:</b>\n{escaped_feedback}"
|
||||||
|
|
||||||
NotificationService.create_notification_with_telegram(
|
NotificationService.create_notification_with_telegram(
|
||||||
recipient=submission.student,
|
recipient=submission.student,
|
||||||
notification_type='homework_reviewed',
|
notification_type='homework_reviewed',
|
||||||
title='✅ ДЗ проверено',
|
title='✅ ДЗ проверено',
|
||||||
message=f'Проверено ДЗ "{submission.homework.title}". Оценка: {submission.score}/5',
|
message=f'Проверено ДЗ "{submission.homework.title}". Оценка: {submission.score}/5{feedback_text}',
|
||||||
priority='normal',
|
priority='normal',
|
||||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||||
content_object=submission
|
content_object=submission
|
||||||
|
|
@ -930,11 +938,15 @@ class HomeworkSubmissionViewSet(viewsets.ModelViewSet):
|
||||||
|
|
||||||
# Отправляем уведомление студенту
|
# Отправляем уведомление студенту
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService
|
||||||
|
msg = f'ДЗ "{submission.homework.title}" возвращено на доработку. Нужно отправить решение заново.'
|
||||||
|
if submission.feedback and str(submission.feedback).strip():
|
||||||
|
comment = (submission.feedback[:500] + '…') if len(submission.feedback) > 500 else submission.feedback
|
||||||
|
msg += f'\n\n💬 Комментарий:\n{comment}'
|
||||||
NotificationService.create_notification_with_telegram(
|
NotificationService.create_notification_with_telegram(
|
||||||
recipient=submission.student,
|
recipient=submission.student,
|
||||||
notification_type='homework_returned',
|
notification_type='homework_returned',
|
||||||
title='🔄 ДЗ возвращено на доработку',
|
title='🔄 ДЗ возвращено на доработку',
|
||||||
message=f'ДЗ "{submission.homework.title}" возвращено на доработку',
|
message=msg,
|
||||||
priority='normal',
|
priority='normal',
|
||||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||||
content_object=submission
|
content_object=submission
|
||||||
|
|
|
||||||
|
|
@ -721,7 +721,7 @@ class NotificationService:
|
||||||
return 'минут'
|
return 'минут'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_homework_notification(homework, notification_type='homework_assigned', student=None):
|
def send_homework_notification(homework, notification_type='homework_assigned', student=None, submission=None):
|
||||||
"""
|
"""
|
||||||
Отправка уведомления о домашнем задании.
|
Отправка уведомления о домашнем задании.
|
||||||
|
|
||||||
|
|
@ -729,6 +729,7 @@ class NotificationService:
|
||||||
homework: Объект домашнего задания
|
homework: Объект домашнего задания
|
||||||
notification_type: Тип уведомления
|
notification_type: Тип уведомления
|
||||||
student: Студент (для homework_submitted и homework_reviewed)
|
student: Студент (для homework_submitted и homework_reviewed)
|
||||||
|
submission: Объект решения (для homework_reviewed)
|
||||||
"""
|
"""
|
||||||
if notification_type == 'homework_assigned':
|
if notification_type == 'homework_assigned':
|
||||||
# Уведомление всем назначенным ученикам о новом ДЗ
|
# Уведомление всем назначенным ученикам о новом ДЗ
|
||||||
|
|
@ -794,6 +795,20 @@ class NotificationService:
|
||||||
lesson_title = homework.lesson.title if homework.lesson else homework.title
|
lesson_title = homework.lesson.title if homework.lesson else homework.title
|
||||||
message = f'Ваше домашнее задание по занятию "{lesson_title}" проверено'
|
message = f'Ваше домашнее задание по занятию "{lesson_title}" проверено'
|
||||||
|
|
||||||
|
if submission:
|
||||||
|
if hasattr(submission, 'score') and submission.score is not None:
|
||||||
|
max_score = getattr(homework, 'max_score', None)
|
||||||
|
if max_score:
|
||||||
|
message += f'. Оценка: {submission.score}/{max_score}'
|
||||||
|
else:
|
||||||
|
message += f'. Оценка: {submission.score}'
|
||||||
|
|
||||||
|
feedback = getattr(submission, 'feedback', None)
|
||||||
|
if feedback:
|
||||||
|
import html
|
||||||
|
escaped_feedback = html.escape(feedback)
|
||||||
|
message += f'\n\n💬 <b>Комментарий:</b>\n{escaped_feedback}'
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"""
|
"""
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
from django.utils.html import strip_tags
|
||||||
from .services import NotificationService, create_notification_preferences
|
from .services import NotificationService, create_notification_preferences
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -148,8 +149,10 @@ def duplicate_notification_to_chat(sender, instance, created, **kwargs):
|
||||||
ChatParticipant.objects.create(chat=chat, user=mentor, role='admin')
|
ChatParticipant.objects.create(chat=chat, user=mentor, role='admin')
|
||||||
ChatParticipant.objects.create(chat=chat, user=recipient, role='member')
|
ChatParticipant.objects.create(chat=chat, user=recipient, role='member')
|
||||||
|
|
||||||
# Создаем системное сообщение в чате
|
# Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги)
|
||||||
message_content = f"🔔 {instance.title}\n{instance.message}"
|
title_plain = strip_tags(instance.title or '')
|
||||||
|
message_plain = strip_tags(instance.message or '')
|
||||||
|
message_content = f"🔔 {title_plain}\n{message_plain}"
|
||||||
Message.objects.create(
|
Message.objects.create(
|
||||||
chat=chat,
|
chat=chat,
|
||||||
sender=None, # Системное сообщение
|
sender=None, # Системное сообщение
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,10 @@ def send_email_notification(notification):
|
||||||
|
|
||||||
def send_telegram_notification(notification):
|
def send_telegram_notification(notification):
|
||||||
"""Отправка Telegram уведомления."""
|
"""Отправка Telegram уведомления."""
|
||||||
|
import re
|
||||||
|
import asyncio
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
try:
|
try:
|
||||||
telegram_id = notification.recipient.telegram_id
|
telegram_id = notification.recipient.telegram_id
|
||||||
|
|
||||||
|
|
@ -413,21 +417,47 @@ def send_telegram_notification(notification):
|
||||||
# Формируем сообщение в HTML формате
|
# Формируем сообщение в HTML формате
|
||||||
message_text = f"<b>{notification.title}</b>\n\n{notification.message}"
|
message_text = f"<b>{notification.title}</b>\n\n{notification.message}"
|
||||||
|
|
||||||
|
full_url = None
|
||||||
if notification.action_url:
|
if notification.action_url:
|
||||||
full_url = f"{settings.FRONTEND_URL}{notification.action_url}"
|
full_url = f"{settings.FRONTEND_URL}{notification.action_url}"
|
||||||
message_text += f'\n\n<a href="{full_url}">Перейти на платформу</a>'
|
frontend_domain = urlparse(settings.FRONTEND_URL).netloc or settings.FRONTEND_URL
|
||||||
|
message_text += f'\n\n<a href="{full_url}">{frontend_domain}</a>'
|
||||||
|
|
||||||
# Отправляем через Telegram Bot API
|
# Уведомления от ИИ для ментора — с кнопками управления
|
||||||
from .telegram_bot import send_telegram_message
|
reply_markup = None
|
||||||
import asyncio
|
is_mentor_ai_homework = (
|
||||||
|
getattr(notification.recipient, 'role', None) == 'mentor'
|
||||||
|
and notification.notification_type in ('homework_submitted', 'homework_reviewed')
|
||||||
|
and notification.action_url
|
||||||
|
and ('ИИ' in (notification.title or '') or 'черновик' in (notification.title or '').lower())
|
||||||
|
)
|
||||||
|
if is_mentor_ai_homework:
|
||||||
|
match = re.match(r'/homework/(\d+)/submissions/(\d+)/?', notification.action_url.strip())
|
||||||
|
if match:
|
||||||
|
submission_id = match.group(2)
|
||||||
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("📝 Открыть решение", callback_data=f"mentor_submission_{submission_id}")],
|
||||||
|
]
|
||||||
|
if full_url:
|
||||||
|
keyboard.append([InlineKeyboardButton("🌐 Открыть на сайте", url=full_url)])
|
||||||
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Запускаем асинхронную отправку
|
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
success = loop.run_until_complete(
|
if reply_markup:
|
||||||
send_telegram_message(telegram_id, message_text, parse_mode='HTML')
|
from .telegram_bot import send_telegram_message_with_buttons
|
||||||
)
|
success = loop.run_until_complete(
|
||||||
|
send_telegram_message_with_buttons(
|
||||||
|
telegram_id, message_text, reply_markup, parse_mode='HTML'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
from .telegram_bot import send_telegram_message
|
||||||
|
success = loop.run_until_complete(
|
||||||
|
send_telegram_message(telegram_id, message_text, parse_mode='HTML')
|
||||||
|
)
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -151,6 +151,22 @@ class ReferralViewSet(viewsets.ViewSet):
|
||||||
except (UserReferralProfile.DoesNotExist, AttributeError):
|
except (UserReferralProfile.DoesNotExist, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Уведомление рефереру: по вашей ссылке зарегистрировался новый пользователь
|
||||||
|
referrer_user = referrer_profile.user
|
||||||
|
new_user_name = request.user.get_full_name() or request.user.email or 'Новый пользователь'
|
||||||
|
try:
|
||||||
|
from apps.notifications.services import NotificationService
|
||||||
|
NotificationService.create_notification_with_telegram(
|
||||||
|
recipient=referrer_user,
|
||||||
|
notification_type='system',
|
||||||
|
title='🎉 Новый реферал',
|
||||||
|
message=f'По вашей реферальной ссылке зарегистрировался {new_user_name}',
|
||||||
|
priority='normal',
|
||||||
|
action_url='/referrals',
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'success': True,
|
'success': True,
|
||||||
'message': f'Реферер установлен: {referrer_profile.user.email}'
|
'message': f'Реферер установлен: {referrer_profile.user.email}'
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ class User(AbstractUser):
|
||||||
unique=True,
|
unique=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
validators=[MinLengthValidator(8)],
|
validators=[], # Валидация длины выполняется в коде, чтобы не блокировать сохранение при null
|
||||||
verbose_name='Универсальный код',
|
verbose_name='Универсальный код',
|
||||||
help_text='8-символьный код (цифры и латинские буквы) для добавления ученика ментором',
|
help_text='8-символьный код (цифры и латинские буквы) для добавления ученика ментором',
|
||||||
)
|
)
|
||||||
|
|
@ -386,7 +386,12 @@ class Mentor(User):
|
||||||
self.username = f"{original_username}{counter}"
|
self.username = f"{original_username}{counter}"
|
||||||
counter += 1
|
counter += 1
|
||||||
if not self.universal_code:
|
if not self.universal_code:
|
||||||
self.universal_code = self._generate_universal_code()
|
try:
|
||||||
|
self.universal_code = self._generate_universal_code()
|
||||||
|
except Exception:
|
||||||
|
# Если не удалось сгенерировать, не прерываем сохранение
|
||||||
|
# Код будет сгенерирован при следующем запросе профиля или в RegisterView
|
||||||
|
pass
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from rest_framework.permissions import IsAuthenticated
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Sum, Q
|
from django.db.models import Sum, Q
|
||||||
|
|
||||||
from .models import MentorStudentConnection
|
from .models import MentorStudentConnection, Client
|
||||||
|
|
||||||
|
|
||||||
class NavBadgesView(APIView):
|
class NavBadgesView(APIView):
|
||||||
|
|
@ -21,7 +21,7 @@ class NavBadgesView(APIView):
|
||||||
user = request.user
|
user = request.user
|
||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
|
|
||||||
# Занятий осталось провести сегодня (ментор: запланированные или идущие на сегодня)
|
# Занятий сегодня: ментор — запланированные/идущие; студент — его занятия на сегодня
|
||||||
lessons_today = 0
|
lessons_today = 0
|
||||||
if user.role == 'mentor':
|
if user.role == 'mentor':
|
||||||
from apps.schedule.models import Lesson
|
from apps.schedule.models import Lesson
|
||||||
|
|
@ -30,6 +30,15 @@ class NavBadgesView(APIView):
|
||||||
start_time__date=today,
|
start_time__date=today,
|
||||||
status__in=['scheduled', 'in_progress']
|
status__in=['scheduled', 'in_progress']
|
||||||
).count()
|
).count()
|
||||||
|
elif user.role == 'client':
|
||||||
|
from apps.schedule.models import Lesson
|
||||||
|
client = Client.objects.filter(user=user).first()
|
||||||
|
if client:
|
||||||
|
lessons_today = Lesson.objects.filter(
|
||||||
|
client=client,
|
||||||
|
start_time__date=today,
|
||||||
|
status__in=['scheduled', 'in_progress']
|
||||||
|
).count()
|
||||||
|
|
||||||
# Непрочитанных сообщений в чатах
|
# Непрочитанных сообщений в чатах
|
||||||
chat_unread = 0
|
chat_unread = 0
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,15 @@ class RegisterSerializer(serializers.ModelSerializer):
|
||||||
**validated_data
|
**validated_data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Гарантированно задаём 8-символьный код при создании
|
||||||
|
if not user.universal_code or len(str(user.universal_code or '').strip()) != 8:
|
||||||
|
try:
|
||||||
|
user.universal_code = user._generate_universal_code()
|
||||||
|
user.save(update_fields=['universal_code'])
|
||||||
|
except Exception:
|
||||||
|
# Если не удалось, код будет сгенерирован в RegisterView или при запросе профиля
|
||||||
|
pass
|
||||||
|
|
||||||
# Создаем профиль в зависимости от роли
|
# Создаем профиль в зависимости от роли
|
||||||
if user.role == 'client':
|
if user.role == 'client':
|
||||||
Client.objects.create(user=user)
|
Client.objects.create(user=user)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
import secrets
|
import secrets
|
||||||
|
import string
|
||||||
|
import random
|
||||||
|
import logging
|
||||||
|
|
||||||
from .models import User, Client, Parent, Group
|
from .models import User, Client, Parent, Group
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
|
|
@ -123,6 +126,25 @@ class TelegramAuthView(generics.GenericAPIView):
|
||||||
user.set_unusable_password()
|
user.set_unusable_password()
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
# Гарантируем 8-символьный код для приглашений
|
||||||
|
if not user.universal_code or len(str(user.universal_code or '').strip()) != 8:
|
||||||
|
try:
|
||||||
|
user.universal_code = user._generate_universal_code()
|
||||||
|
user.save(update_fields=['universal_code'])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'Ошибка генерации universal_code для Telegram пользователя {user.id}: {e}')
|
||||||
|
# Пробуем ещё раз
|
||||||
|
try:
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
for _ in range(500):
|
||||||
|
code = ''.join(random.choices(alphabet, k=8))
|
||||||
|
if not User.objects.filter(universal_code=code).exclude(pk=user.pk).exists():
|
||||||
|
user.universal_code = code
|
||||||
|
user.save(update_fields=['universal_code'])
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass # Код будет сгенерирован при следующем запросе профиля
|
||||||
|
|
||||||
is_new_user = True
|
is_new_user = True
|
||||||
message = 'Регистрация через Telegram выполнена успешно'
|
message = 'Регистрация через Telegram выполнена успешно'
|
||||||
|
|
||||||
|
|
@ -160,20 +182,35 @@ class RegisterView(generics.CreateAPIView):
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
user = serializer.save()
|
user = serializer.save()
|
||||||
|
|
||||||
# 8-символьный код пользователя: генерируем при регистрации, если ещё нет
|
# Всегда задаём 8-символьный код при регистрации (для приглашений ментор/студент)
|
||||||
update_fields = []
|
logger = logging.getLogger(__name__)
|
||||||
if not user.universal_code or len(user.universal_code) != 8:
|
need_code = not user.universal_code or len(str(user.universal_code or '').strip()) != 8
|
||||||
|
if need_code:
|
||||||
try:
|
try:
|
||||||
user.universal_code = user._generate_universal_code()
|
user.universal_code = user._generate_universal_code()
|
||||||
update_fields.append('universal_code')
|
user.save(update_fields=['universal_code'])
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
# Если не удалось сгенерировать код, пробуем ещё раз с большим количеством попыток
|
||||||
|
logger.warning(f'Ошибка генерации universal_code для пользователя {user.id}: {e}, пробуем ещё раз')
|
||||||
|
try:
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
for _ in range(500):
|
||||||
|
code = ''.join(random.choices(alphabet, k=8))
|
||||||
|
if not User.objects.filter(universal_code=code).exclude(pk=user.pk).exists():
|
||||||
|
user.universal_code = code
|
||||||
|
user.save(update_fields=['universal_code'])
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Если всё равно не получилось, не прерываем регистрацию
|
||||||
|
logger.error(f'Не удалось сгенерировать unique universal_code для пользователя {user.id} после 500 попыток')
|
||||||
|
except Exception as e2:
|
||||||
|
logger.error(f'Критическая ошибка генерации universal_code для пользователя {user.id}: {e2}')
|
||||||
|
# Не прерываем регистрацию, код будет сгенерирован при следующем запросе профиля
|
||||||
|
|
||||||
# Токен для подтверждения email
|
# Токен для подтверждения email
|
||||||
verification_token = secrets.token_urlsafe(32)
|
verification_token = secrets.token_urlsafe(32)
|
||||||
user.email_verification_token = verification_token
|
user.email_verification_token = verification_token
|
||||||
update_fields.append('email_verification_token')
|
user.save(update_fields=['email_verification_token'])
|
||||||
user.save(update_fields=update_fields)
|
|
||||||
|
|
||||||
# Отправляем email подтверждения (асинхронно через Celery)
|
# Отправляем email подтверждения (асинхронно через Celery)
|
||||||
send_verification_email_task.delay(user.id, verification_token)
|
send_verification_email_task.delay(user.id, verification_token)
|
||||||
|
|
@ -181,7 +218,8 @@ class RegisterView(generics.CreateAPIView):
|
||||||
# Генерируем JWT токены
|
# Генерируем JWT токены
|
||||||
refresh = RefreshToken.for_user(user)
|
refresh = RefreshToken.for_user(user)
|
||||||
|
|
||||||
# Сериализуем пользователя с контекстом запроса для правильных URL
|
# Берём пользователя из БД, чтобы в ответе точно был universal_code
|
||||||
|
user.refresh_from_db()
|
||||||
user_serializer = UserDetailSerializer(user, context={'request': request})
|
user_serializer = UserDetailSerializer(user, context={'request': request})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
|
|
|
||||||
|
|
@ -190,14 +190,17 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- prod_network
|
- prod_network
|
||||||
|
|
||||||
# Видеоуроки: хост nginx (api.uchill.online) проксирует /livekit на 7880. Dev на том же хосте — 7890.
|
# Видеоуроки: 2K, высокий битрейт. Nginx проксирует /livekit на 7880.
|
||||||
# LIVEKIT_KEYS — строго один ключ в формате "key: secret" (пробел после двоеточия). В .env задайте одну строку: LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf
|
# Конфиг: docker/livekit/livekit-config.yaml (буферы под 2K, RTC).
|
||||||
livekit:
|
livekit:
|
||||||
image: livekit/livekit-server:latest
|
image: livekit/livekit-server:latest
|
||||||
container_name: platform_prod_livekit
|
container_name: platform_prod_livekit
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: ["/livekit-server", "--config", "/etc/livekit/livekit-config.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./docker/livekit/livekit-config.yaml:/etc/livekit/livekit-config.yaml:ro
|
||||||
environment:
|
environment:
|
||||||
# Одна строка "key: secret" (пробел после двоеточия). В кавычках, чтобы YAML не воспринял двоеточие как ключ.
|
# Переопределение ключей через env (опционально)
|
||||||
- "LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf"
|
- "LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf"
|
||||||
ports:
|
ports:
|
||||||
- "7880:7880"
|
- "7880:7880"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
# LiveKit Server — поддержка 2K и высокого битрейта
|
||||||
|
# Ключи можно переопределить через LIVEKIT_KEYS в docker-compose
|
||||||
|
|
||||||
|
port: 7880
|
||||||
|
keys:
|
||||||
|
APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf
|
||||||
|
rtc:
|
||||||
|
port_range_start: 50000
|
||||||
|
port_range_end: 60000
|
||||||
|
tcp_port: 7881
|
||||||
|
use_external_ip: false
|
||||||
|
# Буферы для видео (по умолчанию 500) — чуть выше для 2K/высокого битрейта
|
||||||
|
packet_buffer_size_video: 600
|
||||||
|
packet_buffer_size_audio: 200
|
||||||
|
congestion_control:
|
||||||
|
enabled: true
|
||||||
|
allow_pause: true
|
||||||
|
allow_tcp_fallback: true
|
||||||
|
|
||||||
|
room:
|
||||||
|
auto_create: true
|
||||||
|
empty_timeout: 300
|
||||||
|
max_participants: 50
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
sample: false
|
||||||
|
|
@ -92,27 +92,31 @@ server {
|
||||||
}
|
}
|
||||||
|
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# LIVEKIT - видеоконференции (официальный Go-сервер)
|
# LIVEKIT - видеоконференции (2K, высокий битрейт)
|
||||||
# Всё проходит через наш сервис
|
# Увеличенные буферы для WebSocket и видеопотока
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# SDK livekit-client при ошибке WS делает GET /rtc/v1/validate для понятной ошибки.
|
location = /livekit/rtc/v1/validate {
|
||||||
# Официальный сервер LiveKit этот HTTP endpoint не отдаёт (404). Отвечаем 200 сами.
|
add_header Content-Type application/json;
|
||||||
# location = /livekit/rtc/v1/validate {
|
return 200 '{}';
|
||||||
# add_header Content-Type application/json;
|
}
|
||||||
# return 200 '{}';
|
location /livekit {
|
||||||
# }
|
proxy_pass http://livekit/;
|
||||||
# location /livekit {
|
proxy_http_version 1.1;
|
||||||
# proxy_pass http://livekit/;
|
proxy_set_header Host $host;
|
||||||
# proxy_http_version 1.1;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
# proxy_set_header Host $host;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
# proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header Connection "upgrade";
|
||||||
# proxy_set_header Upgrade $http_upgrade;
|
proxy_read_timeout 86400s;
|
||||||
# proxy_set_header Connection "upgrade";
|
proxy_send_timeout 86400s;
|
||||||
# proxy_read_timeout 86400s;
|
proxy_connect_timeout 60s;
|
||||||
# proxy_send_timeout 86400s;
|
# Буферы для высокого битрейта (2K / 6 Mbps)
|
||||||
# }
|
proxy_buffer_size 128k;
|
||||||
|
proxy_buffers 4 256k;
|
||||||
|
proxy_busy_buffers_size 256k;
|
||||||
|
proxy_temp_file_write_size 256k;
|
||||||
|
}
|
||||||
|
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# HEALTH CHECK
|
# HEALTH CHECK
|
||||||
|
|
|
||||||
|
|
@ -97,9 +97,10 @@ http {
|
||||||
keepalive 32;
|
keepalive 32;
|
||||||
}
|
}
|
||||||
|
|
||||||
# upstream livekit {
|
upstream livekit {
|
||||||
# server localhost:7880 max_fails=3 fail_timeout=30s;
|
server livekit:7880 max_fails=3 fail_timeout=30s;
|
||||||
# }
|
keepalive 4;
|
||||||
|
}
|
||||||
|
|
||||||
# ==============================================
|
# ==============================================
|
||||||
# ВКЛЮЧЕНИЕ КОНФИГУРАЦИЙ САЙТОВ
|
# ВКЛЮЧЕНИЕ КОНФИГУРАЦИЙ САЙТОВ
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,24 @@ export async function getReferralStats(): Promise<ReferralStats | null> {
|
||||||
export async function setReferrer(referralCode: string): Promise<void> {
|
export async function setReferrer(referralCode: string): Promise<void> {
|
||||||
await apiClient.post('/referrals/set_referrer/', { referral_code: referralCode.trim() });
|
await apiClient.post('/referrals/set_referrer/', { referral_code: referralCode.trim() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ключ в localStorage для реферального кода (после перехода по ссылке /register?ref=CODE). */
|
||||||
|
export const REFERRAL_STORAGE_KEY = 'referral_code';
|
||||||
|
|
||||||
|
export interface MyReferralItem {
|
||||||
|
email: string;
|
||||||
|
level: string;
|
||||||
|
total_points: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MyReferralsResponse {
|
||||||
|
direct: MyReferralItem[];
|
||||||
|
indirect: MyReferralItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список приглашённых рефералов (прямые и непрямые). */
|
||||||
|
export async function getMyReferrals(): Promise<MyReferralsResponse> {
|
||||||
|
const response = await apiClient.get<MyReferralsResponse>('/referrals/my_referrals/');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { register } from '@/api/auth';
|
import { register } from '@/api/auth';
|
||||||
import { setReferrer } from '@/api/referrals';
|
import { REFERRAL_STORAGE_KEY } from '@/api/referrals';
|
||||||
import { searchCitiesFromCSV, type CityOption } from '@/api/profile';
|
import { searchCitiesFromCSV, type CityOption } from '@/api/profile';
|
||||||
|
|
||||||
const loadMaterialComponents = async () => {
|
const loadMaterialComponents = async () => {
|
||||||
|
|
@ -17,8 +17,6 @@ const loadMaterialComponents = async () => {
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const REFERRAL_STORAGE_KEY = 'referral_code';
|
|
||||||
|
|
||||||
const ROLE_OPTIONS: { value: 'mentor' | 'client' | 'parent'; label: string }[] = [
|
const ROLE_OPTIONS: { value: 'mentor' | 'client' | 'parent'; label: string }[] = [
|
||||||
{ value: 'mentor', label: 'Ментор' },
|
{ value: 'mentor', label: 'Ментор' },
|
||||||
{ value: 'client', label: 'Студент' },
|
{ value: 'client', label: 'Студент' },
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { NavBadgesProvider } from '@/contexts/NavBadgesContext';
|
||||||
import { SelectedChildProvider } from '@/contexts/SelectedChildContext';
|
import { SelectedChildProvider } from '@/contexts/SelectedChildContext';
|
||||||
import { getNavBadges } from '@/api/navBadges';
|
import { getNavBadges } from '@/api/navBadges';
|
||||||
import { getActiveSubscription } from '@/api/subscriptions';
|
import { getActiveSubscription } from '@/api/subscriptions';
|
||||||
|
import { setReferrer, REFERRAL_STORAGE_KEY } from '@/api/referrals';
|
||||||
import type { NavBadges } from '@/api/navBadges';
|
import type { NavBadges } from '@/api/navBadges';
|
||||||
|
|
||||||
export default function ProtectedLayout({
|
export default function ProtectedLayout({
|
||||||
|
|
@ -38,6 +39,18 @@ export default function ProtectedLayout({
|
||||||
refreshNavBadges();
|
refreshNavBadges();
|
||||||
}, [user, refreshNavBadges]);
|
}, [user, refreshNavBadges]);
|
||||||
|
|
||||||
|
// После входа: если в localStorage сохранён реферальный код (переход по ссылке /register?ref=...), привязываем реферера
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const code = typeof window !== 'undefined' ? localStorage.getItem(REFERRAL_STORAGE_KEY) : null;
|
||||||
|
if (!code || !code.trim()) return;
|
||||||
|
setReferrer(code.trim())
|
||||||
|
.then(() => {
|
||||||
|
localStorage.removeItem(REFERRAL_STORAGE_KEY);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// Для ментора: редирект на /payment, если нет активной подписки (кроме самой страницы /payment)
|
// Для ментора: редирект на /payment, если нет активной подписки (кроме самой страницы /payment)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user || user.role !== 'mentor' || pathname === '/payment') {
|
if (!user || user.role !== 'mentor' || pathname === '/payment') {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ const Chart = dynamic(() => import('react-apexcharts').then((mod) => mod.default
|
||||||
const CHART_COLORS = ['#6750A4', '#7D5260'];
|
const CHART_COLORS = ['#6750A4', '#7D5260'];
|
||||||
|
|
||||||
const defaultRange = {
|
const defaultRange = {
|
||||||
start_date: dayjs().subtract(3, 'month').format('YYYY-MM-DD'),
|
start_date: dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
|
||||||
end_date: dayjs().format('YYYY-MM-DD'),
|
end_date: dayjs().format('YYYY-MM-DD'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,12 @@ function stripLeadingEmojis(s: string): string {
|
||||||
return t.replace(SYSTEM_EMOJI_PREFIX, '').replace(/^[-–—•\s]+/, '').trim() || t;
|
return t.replace(SYSTEM_EMOJI_PREFIX, '').replace(/^[-–—•\s]+/, '').trim() || t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Убирает HTML-теги из строки (чтобы в чате не отображались теги в уведомлениях). */
|
||||||
|
function stripHtml(s: string): string {
|
||||||
|
if (typeof s !== 'string') return '';
|
||||||
|
return s.replace(/<[^>]*>/g, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
type SystemTheme = {
|
type SystemTheme = {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -534,7 +540,7 @@ export function ChatWindow({
|
||||||
(!senderId && (m as any).sender_name === 'System');
|
(!senderId && (m as any).sender_name === 'System');
|
||||||
const msgContent = (m as any).content || '';
|
const msgContent = (m as any).content || '';
|
||||||
const sysTheme = isSystem ? getSystemMessageTheme(msgContent) : null;
|
const sysTheme = isSystem ? getSystemMessageTheme(msgContent) : null;
|
||||||
const sysDisplayContent = isSystem ? stripLeadingEmojis(msgContent) : '';
|
const sysDisplayContent = isSystem ? stripHtml(stripLeadingEmojis(msgContent)) : '';
|
||||||
|
|
||||||
const msgUuid = (m as any).uuid ? String((m as any).uuid) : null;
|
const msgUuid = (m as any).uuid ? String((m as any).uuid) : null;
|
||||||
const msgKey = (m as any).uuid || m.id || `msg-${idx}`;
|
const msgKey = (m as any).uuid || m.id || `msg-${idx}`;
|
||||||
|
|
|
||||||
|
|
@ -793,6 +793,30 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
|
||||||
Оценка: {mySubmission.score} / 5
|
Оценка: {mySubmission.score} / 5
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{userRole === 'client' && mySubmission.status === 'returned' && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 16 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSubmitOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
borderRadius: 14,
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--md-sys-color-primary)',
|
||||||
|
color: 'var(--md-sys-color-on-primary)',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>edit_note</span>
|
||||||
|
Доработать ДЗ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{userRole === 'client' && SHOW_DELETE_SUBMISSION_FOR_STUDENT && (
|
{userRole === 'client' && SHOW_DELETE_SUBMISSION_FOR_STUDENT && (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 16 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 16 }}>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,13 @@ class LiveKitLayoutErrorBoundary extends React.Component<
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { LiveKitRoom, VideoConference, RoomAudioRenderer, ConnectionStateToast, useTracks, useRemoteParticipants, ParticipantTile, useRoomContext, useStartAudio } from '@livekit/components-react';
|
import { LiveKitRoom, VideoConference, RoomAudioRenderer, ConnectionStateToast, useTracks, useRemoteParticipants, ParticipantTile, useRoomContext, useStartAudio } from '@livekit/components-react';
|
||||||
import { ExitLessonModal } from '@/components/livekit/ExitLessonModal';
|
import { ExitLessonModal } from '@/components/livekit/ExitLessonModal';
|
||||||
import { Track, RoomEvent } from 'livekit-client';
|
import { Track, RoomEvent, VideoPresets } from 'livekit-client';
|
||||||
|
|
||||||
|
/** 2K (1440p) — разрешение и кодирование для высокого качества при хорошем канале */
|
||||||
|
const PRESET_2K = {
|
||||||
|
resolution: { width: 2560, height: 1440 },
|
||||||
|
encoding: { maxBitrate: 6_000_000, maxFramerate: 30 } as const,
|
||||||
|
};
|
||||||
import { isTrackReference } from '@livekit/components-core';
|
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';
|
||||||
|
|
@ -321,7 +327,7 @@ function PreJoinScreen({
|
||||||
if (!videoEnabled) return;
|
if (!videoEnabled) return;
|
||||||
let stream: MediaStream | null = null;
|
let stream: MediaStream | null = null;
|
||||||
navigator.mediaDevices
|
navigator.mediaDevices
|
||||||
.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false })
|
.getUserMedia({ video: { width: { ideal: 2560 }, height: { ideal: 1440 }, frameRate: { ideal: 30 } }, audio: false })
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
stream = s;
|
stream = s;
|
||||||
setPreview(s);
|
setPreview(s);
|
||||||
|
|
@ -1018,8 +1024,21 @@ export default function LiveKitRoomContent() {
|
||||||
options={{
|
options={{
|
||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
|
// Захват до 2K (1440p), при отсутствии поддержки браузер даст меньше
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
resolution: { width: 1280, height: 720, frameRate: 30 },
|
resolution: PRESET_2K.resolution,
|
||||||
|
frameRate: 30,
|
||||||
|
},
|
||||||
|
publishDefaults: {
|
||||||
|
simulcast: true,
|
||||||
|
// Камера: до 2K, 6 Mbps — вариативность через слои 1080p, 720p, 360p
|
||||||
|
videoEncoding: PRESET_2K.encoding,
|
||||||
|
// Два слоя поверх основного: 720p и 360p для вариативности при слабом канале
|
||||||
|
videoSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
|
||||||
|
// Демонстрация экрана: 2K, 6 Mbps, те же два слоя для адаптации
|
||||||
|
screenShareEncoding: { maxBitrate: 6_000_000, maxFramerate: 30 },
|
||||||
|
screenShareSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
|
||||||
|
degradationPreference: 'maintain-resolution',
|
||||||
},
|
},
|
||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
noiseSuppression: true,
|
noiseSuppression: true,
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ export function BottomNavigationBar({ userRole, user, navBadges, slideout, onClo
|
||||||
{ label: 'Материалы', path: '/materials', icon: 'folder' },
|
{ label: 'Материалы', path: '/materials', icon: 'folder' },
|
||||||
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
|
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
|
||||||
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
|
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
|
||||||
{ label: 'Подключить ментора', path: '/request-mentor', icon: 'person_add' },
|
{ label: 'Мои менторы', path: '/request-mentor', icon: 'person_add' },
|
||||||
];
|
];
|
||||||
} else if (userRole === 'parent') {
|
} else if (userRole === 'parent') {
|
||||||
// Родитель: те же страницы, что и студент, кроме материалов
|
// Родитель: те же страницы, что и студент, кроме материалов
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useNotifications } from '@/hooks/useNotifications';
|
||||||
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
|
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
|
||||||
import type { Notification } from '@/api/notifications';
|
import type { Notification } from '@/api/notifications';
|
||||||
|
|
||||||
const BELL_POSITION = { right: 24, bottom: 88 };
|
const BELL_POSITION = { right: 24, bottom: 25 };
|
||||||
const PANEL_WIDTH = 360;
|
const PANEL_WIDTH = 360;
|
||||||
const PANEL_MAX_HEIGHT = '70vh';
|
const PANEL_MAX_HEIGHT = '70vh';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -408,3 +408,22 @@
|
||||||
right: 12px !important;
|
right: 12px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Качество отображения видео в контейнере LiveKit */
|
||||||
|
.lk-participant-media-video {
|
||||||
|
background: #000 !important;
|
||||||
|
}
|
||||||
|
/* Демонстрация экрана: contain чтобы не обрезать, чёткое отображение */
|
||||||
|
.lk-participant-media-video[data-lk-source="screen_share"] {
|
||||||
|
object-fit: contain !important;
|
||||||
|
object-position: center !important;
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
/* Сетка: минимальная высота плиток для крупного видео */
|
||||||
|
.lk-grid-layout {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.lk-grid-layout .lk-participant-tile {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue