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'])
|
||||
invalidate_dashboard_cache(submission.student.id, 'client')
|
||||
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(
|
||||
recipient=submission.student,
|
||||
notification_type='homework_reviewed',
|
||||
title='✅ ДЗ проверено',
|
||||
message=f'Проверено ДЗ "{homework_title}". Оценка: {score}/5',
|
||||
message=msg_student,
|
||||
priority='normal',
|
||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||
content_object=submission
|
||||
|
|
@ -308,11 +312,24 @@ def run_mentor_ai_check_submission(self, submission_id, publish):
|
|||
submission.ai_checked_at = timezone.now()
|
||||
submission.save(update_fields=['ai_score', 'ai_feedback', 'ai_checked_at'])
|
||||
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(
|
||||
recipient=mentor,
|
||||
notification_type='homework_submitted',
|
||||
title='🤖 ИИ проверил ДЗ, статус: черновик',
|
||||
message=f'{student_name} — ДЗ «{homework_title}»: предварительная оценка {score}/5. ИИ сохранил как черновик — можете отредактировать и опубликовать.',
|
||||
message=message_text,
|
||||
priority='normal',
|
||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||
content_object=submission
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
API views для домашних заданий.
|
||||
"""
|
||||
import logging
|
||||
import html
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
|
@ -732,11 +733,18 @@ class HomeworkSubmissionViewSet(viewsets.ModelViewSet):
|
|||
|
||||
# Отправляем уведомление студенту
|
||||
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(
|
||||
recipient=submission.student,
|
||||
notification_type='homework_reviewed',
|
||||
title='✅ ДЗ проверено',
|
||||
message=f'Проверено ДЗ "{submission.homework.title}". Оценка: {submission.score}/5',
|
||||
message=f'Проверено ДЗ "{submission.homework.title}". Оценка: {submission.score}/5{feedback_text}',
|
||||
priority='normal',
|
||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||
content_object=submission
|
||||
|
|
@ -930,11 +938,15 @@ class HomeworkSubmissionViewSet(viewsets.ModelViewSet):
|
|||
|
||||
# Отправляем уведомление студенту
|
||||
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(
|
||||
recipient=submission.student,
|
||||
notification_type='homework_returned',
|
||||
title='🔄 ДЗ возвращено на доработку',
|
||||
message=f'ДЗ "{submission.homework.title}" возвращено на доработку',
|
||||
message=msg,
|
||||
priority='normal',
|
||||
action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
|
||||
content_object=submission
|
||||
|
|
|
|||
|
|
@ -721,7 +721,7 @@ class NotificationService:
|
|||
return 'минут'
|
||||
|
||||
@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: Объект домашнего задания
|
||||
notification_type: Тип уведомления
|
||||
student: Студент (для homework_submitted и homework_reviewed)
|
||||
submission: Объект решения (для homework_reviewed)
|
||||
"""
|
||||
if notification_type == 'homework_assigned':
|
||||
# Уведомление всем назначенным ученикам о новом ДЗ
|
||||
|
|
@ -794,6 +795,20 @@ class NotificationService:
|
|||
lesson_title = homework.lesson.title if homework.lesson else homework.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:
|
||||
return
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"""
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.html import strip_tags
|
||||
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=recipient, role='member')
|
||||
|
||||
# Создаем системное сообщение в чате
|
||||
message_content = f"🔔 {instance.title}\n{instance.message}"
|
||||
# Создаем системное сообщение в чате (без HTML-тегов, чтобы в чате не отображались теги)
|
||||
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(
|
||||
chat=chat,
|
||||
sender=None, # Системное сообщение
|
||||
|
|
|
|||
|
|
@ -403,6 +403,10 @@ def send_email_notification(notification):
|
|||
|
||||
def send_telegram_notification(notification):
|
||||
"""Отправка Telegram уведомления."""
|
||||
import re
|
||||
import asyncio
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
telegram_id = notification.recipient.telegram_id
|
||||
|
||||
|
|
@ -413,18 +417,44 @@ def send_telegram_notification(notification):
|
|||
# Формируем сообщение в HTML формате
|
||||
message_text = f"<b>{notification.title}</b>\n\n{notification.message}"
|
||||
|
||||
full_url = None
|
||||
if 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
|
||||
import asyncio
|
||||
# Уведомления от ИИ для ментора — с кнопками управления
|
||||
reply_markup = None
|
||||
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:
|
||||
# Запускаем асинхронную отправку
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
if reply_markup:
|
||||
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')
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -151,6 +151,22 @@ class ReferralViewSet(viewsets.ViewSet):
|
|||
except (UserReferralProfile.DoesNotExist, AttributeError):
|
||||
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({
|
||||
'success': True,
|
||||
'message': f'Реферер установлен: {referrer_profile.user.email}'
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ class User(AbstractUser):
|
|||
unique=True,
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinLengthValidator(8)],
|
||||
validators=[], # Валидация длины выполняется в коде, чтобы не блокировать сохранение при null
|
||||
verbose_name='Универсальный код',
|
||||
help_text='8-символьный код (цифры и латинские буквы) для добавления ученика ментором',
|
||||
)
|
||||
|
|
@ -386,7 +386,12 @@ class Mentor(User):
|
|||
self.username = f"{original_username}{counter}"
|
||||
counter += 1
|
||||
if not self.universal_code:
|
||||
try:
|
||||
self.universal_code = self._generate_universal_code()
|
||||
except Exception:
|
||||
# Если не удалось сгенерировать, не прерываем сохранение
|
||||
# Код будет сгенерирован при следующем запросе профиля или в RegisterView
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from rest_framework.permissions import IsAuthenticated
|
|||
from django.utils import timezone
|
||||
from django.db.models import Sum, Q
|
||||
|
||||
from .models import MentorStudentConnection
|
||||
from .models import MentorStudentConnection, Client
|
||||
|
||||
|
||||
class NavBadgesView(APIView):
|
||||
|
|
@ -21,7 +21,7 @@ class NavBadgesView(APIView):
|
|||
user = request.user
|
||||
today = timezone.now().date()
|
||||
|
||||
# Занятий осталось провести сегодня (ментор: запланированные или идущие на сегодня)
|
||||
# Занятий сегодня: ментор — запланированные/идущие; студент — его занятия на сегодня
|
||||
lessons_today = 0
|
||||
if user.role == 'mentor':
|
||||
from apps.schedule.models import Lesson
|
||||
|
|
@ -30,6 +30,15 @@ class NavBadgesView(APIView):
|
|||
start_time__date=today,
|
||||
status__in=['scheduled', 'in_progress']
|
||||
).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
|
||||
|
|
|
|||
|
|
@ -156,6 +156,15 @@ class RegisterSerializer(serializers.ModelSerializer):
|
|||
**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':
|
||||
Client.objects.create(user=user)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ from rest_framework_simplejwt.tokens import RefreshToken
|
|||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
import secrets
|
||||
import string
|
||||
import random
|
||||
import logging
|
||||
|
||||
from .models import User, Client, Parent, Group
|
||||
from .serializers import (
|
||||
|
|
@ -123,6 +126,25 @@ class TelegramAuthView(generics.GenericAPIView):
|
|||
user.set_unusable_password()
|
||||
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
|
||||
message = 'Регистрация через Telegram выполнена успешно'
|
||||
|
||||
|
|
@ -160,20 +182,35 @@ class RegisterView(generics.CreateAPIView):
|
|||
serializer.is_valid(raise_exception=True)
|
||||
user = serializer.save()
|
||||
|
||||
# 8-символьный код пользователя: генерируем при регистрации, если ещё нет
|
||||
update_fields = []
|
||||
if not user.universal_code or len(user.universal_code) != 8:
|
||||
# Всегда задаём 8-символьный код при регистрации (для приглашений ментор/студент)
|
||||
logger = logging.getLogger(__name__)
|
||||
need_code = not user.universal_code or len(str(user.universal_code or '').strip()) != 8
|
||||
if need_code:
|
||||
try:
|
||||
user.universal_code = user._generate_universal_code()
|
||||
update_fields.append('universal_code')
|
||||
except Exception:
|
||||
pass
|
||||
user.save(update_fields=['universal_code'])
|
||||
except Exception as e:
|
||||
# Если не удалось сгенерировать код, пробуем ещё раз с большим количеством попыток
|
||||
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
|
||||
verification_token = secrets.token_urlsafe(32)
|
||||
user.email_verification_token = verification_token
|
||||
update_fields.append('email_verification_token')
|
||||
user.save(update_fields=update_fields)
|
||||
user.save(update_fields=['email_verification_token'])
|
||||
|
||||
# Отправляем email подтверждения (асинхронно через Celery)
|
||||
send_verification_email_task.delay(user.id, verification_token)
|
||||
|
|
@ -181,7 +218,8 @@ class RegisterView(generics.CreateAPIView):
|
|||
# Генерируем JWT токены
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
# Сериализуем пользователя с контекстом запроса для правильных URL
|
||||
# Берём пользователя из БД, чтобы в ответе точно был universal_code
|
||||
user.refresh_from_db()
|
||||
user_serializer = UserDetailSerializer(user, context={'request': request})
|
||||
|
||||
return Response({
|
||||
|
|
|
|||
|
|
@ -190,14 +190,17 @@ services:
|
|||
networks:
|
||||
- prod_network
|
||||
|
||||
# Видеоуроки: хост nginx (api.uchill.online) проксирует /livekit на 7880. Dev на том же хосте — 7890.
|
||||
# LIVEKIT_KEYS — строго один ключ в формате "key: secret" (пробел после двоеточия). В .env задайте одну строку: LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf
|
||||
# Видеоуроки: 2K, высокий битрейт. Nginx проксирует /livekit на 7880.
|
||||
# Конфиг: docker/livekit/livekit-config.yaml (буферы под 2K, RTC).
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
container_name: platform_prod_livekit
|
||||
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:
|
||||
# Одна строка "key: secret" (пробел после двоеточия). В кавычках, чтобы YAML не воспринял двоеточие как ключ.
|
||||
# Переопределение ключей через env (опционально)
|
||||
- "LIVEKIT_KEYS=APIKeyPlatform2024Secret: ThisIsAVerySecureSecretKeyForPlatform2024VideoConf"
|
||||
ports:
|
||||
- "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 для понятной ошибки.
|
||||
# Официальный сервер LiveKit этот HTTP endpoint не отдаёт (404). Отвечаем 200 сами.
|
||||
# location = /livekit/rtc/v1/validate {
|
||||
# add_header Content-Type application/json;
|
||||
# return 200 '{}';
|
||||
# }
|
||||
# location /livekit {
|
||||
# proxy_pass http://livekit/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection "upgrade";
|
||||
# proxy_read_timeout 86400s;
|
||||
# proxy_send_timeout 86400s;
|
||||
# }
|
||||
location = /livekit/rtc/v1/validate {
|
||||
add_header Content-Type application/json;
|
||||
return 200 '{}';
|
||||
}
|
||||
location /livekit {
|
||||
proxy_pass http://livekit/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_connect_timeout 60s;
|
||||
# Буферы для высокого битрейта (2K / 6 Mbps)
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
proxy_temp_file_write_size 256k;
|
||||
}
|
||||
|
||||
# ==============================================
|
||||
# HEALTH CHECK
|
||||
|
|
|
|||
|
|
@ -97,9 +97,10 @@ http {
|
|||
keepalive 32;
|
||||
}
|
||||
|
||||
# upstream livekit {
|
||||
# server localhost:7880 max_fails=3 fail_timeout=30s;
|
||||
# }
|
||||
upstream livekit {
|
||||
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> {
|
||||
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 { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { register } from '@/api/auth';
|
||||
import { setReferrer } from '@/api/referrals';
|
||||
import { REFERRAL_STORAGE_KEY } from '@/api/referrals';
|
||||
import { searchCitiesFromCSV, type CityOption } from '@/api/profile';
|
||||
|
||||
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 }[] = [
|
||||
{ value: 'mentor', label: 'Ментор' },
|
||||
{ value: 'client', label: 'Студент' },
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { NavBadgesProvider } from '@/contexts/NavBadgesContext';
|
|||
import { SelectedChildProvider } from '@/contexts/SelectedChildContext';
|
||||
import { getNavBadges } from '@/api/navBadges';
|
||||
import { getActiveSubscription } from '@/api/subscriptions';
|
||||
import { setReferrer, REFERRAL_STORAGE_KEY } from '@/api/referrals';
|
||||
import type { NavBadges } from '@/api/navBadges';
|
||||
|
||||
export default function ProtectedLayout({
|
||||
|
|
@ -38,6 +39,18 @@ export default function ProtectedLayout({
|
|||
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)
|
||||
useEffect(() => {
|
||||
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 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'),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,12 @@ function stripLeadingEmojis(s: string): string {
|
|||
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 = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
|
|
@ -534,7 +540,7 @@ export function ChatWindow({
|
|||
(!senderId && (m as any).sender_name === 'System');
|
||||
const msgContent = (m as any).content || '';
|
||||
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 msgKey = (m as any).uuid || m.id || `msg-${idx}`;
|
||||
|
|
|
|||
|
|
@ -793,6 +793,30 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
|
|||
Оценка: {mySubmission.score} / 5
|
||||
</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 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 16 }}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -50,7 +50,13 @@ class LiveKitLayoutErrorBoundary extends React.Component<
|
|||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { LiveKitRoom, VideoConference, RoomAudioRenderer, ConnectionStateToast, useTracks, useRemoteParticipants, ParticipantTile, useRoomContext, useStartAudio } from '@livekit/components-react';
|
||||
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 '@/styles/livekit-components.css';
|
||||
import '@/styles/livekit-theme.css';
|
||||
|
|
@ -321,7 +327,7 @@ function PreJoinScreen({
|
|||
if (!videoEnabled) return;
|
||||
let stream: MediaStream | null = null;
|
||||
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) => {
|
||||
stream = s;
|
||||
setPreview(s);
|
||||
|
|
@ -1018,8 +1024,21 @@ export default function LiveKitRoomContent() {
|
|||
options={{
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
// Захват до 2K (1440p), при отсутствии поддержки браузер даст меньше
|
||||
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: {
|
||||
noiseSuppression: true,
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ export function BottomNavigationBar({ userRole, user, navBadges, slideout, onClo
|
|||
{ label: 'Материалы', path: '/materials', icon: 'folder' },
|
||||
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
|
||||
{ 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') {
|
||||
// Родитель: те же страницы, что и студент, кроме материалов
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useNotifications } from '@/hooks/useNotifications';
|
|||
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
|
||||
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_MAX_HEIGHT = '70vh';
|
||||
|
||||
|
|
|
|||
|
|
@ -408,3 +408,22 @@
|
|||
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