full
Deploy to Production / deploy-production (push) Successful in 27s Details

This commit is contained in:
root 2026-02-14 02:45:50 +03:00
parent 8c6406269c
commit 0b5fb434db
26 changed files with 4559 additions and 3429 deletions

View File

@ -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

View File

@ -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

View File

@ -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':
# Уведомление всем назначенным ученикам о новом ДЗ # Уведомление всем назначенным ученикам о новом ДЗ
@ -793,6 +794,20 @@ class NotificationService:
title = '✅ Домашнее задание проверено' title = '✅ Домашнее задание проверено'
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

View File

@ -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, # Системное сообщение

View File

@ -403,33 +403,63 @@ 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
if not telegram_id: if not telegram_id:
notification.mark_as_sent(error='Telegram ID not linked') notification.mark_as_sent(error='Telegram ID not linked')
return 'Telegram ID not linked' return 'Telegram ID not linked'
# Формируем сообщение в 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 # Уведомления от ИИ для ментора — с кнопками управления
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: 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:
notification.mark_as_sent() notification.mark_as_sent()
logger.info(f'Telegram notification sent to {telegram_id}') logger.info(f'Telegram notification sent to {telegram_id}')
@ -437,12 +467,12 @@ def send_telegram_notification(notification):
else: else:
notification.mark_as_sent(error='Failed to send message') notification.mark_as_sent(error='Failed to send message')
return 'Failed to send Telegram message' return 'Failed to send Telegram message'
except Exception as e: except Exception as e:
logger.error(f'Error sending telegram message: {str(e)}') logger.error(f'Error sending telegram message: {str(e)}')
notification.mark_as_sent(error=str(e)) notification.mark_as_sent(error=str(e))
return f'Error: {str(e)}' return f'Error: {str(e)}'
except Exception as e: except Exception as e:
logger.error(f'Error sending telegram notification: {str(e)}') logger.error(f'Error sending telegram notification: {str(e)}')
notification.mark_as_sent(error=str(e)) notification.mark_as_sent(error=str(e))

File diff suppressed because it is too large Load Diff

View File

@ -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}'

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -1,64 +1,64 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{% block title %}Uchill{% endblock %}</title> <title>{% block title %}Uchill{% endblock %}</title>
<!--[if mso]> <!--[if mso]>
<style type="text/css"> <style type="text/css">
body, table, td {font-family: Arial, sans-serif !important;} body, table, td {font-family: Arial, sans-serif !important;}
</style> </style>
<![endif]--> <![endif]-->
</head> </head>
<body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;"> <body style="margin: 0; padding: 0; background-color: #FAFAFA; font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;">
<!-- Wrapper table --> <!-- Wrapper table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #FAFAFA;">
<tr> <tr>
<td align="center" style="padding: 40px 20px;"> <td align="center" style="padding: 40px 20px;">
<!-- Main content table --> <!-- Main content table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="600" style="max-width: 600px; background-color: #FFFFFF; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header with logo --> <!-- Header with logo -->
<tr> <tr>
<td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;"> <td style="padding: 40px 40px 30px 40px; text-align: center; background-color: #FFFFFF; border-radius: 8px 8px 0 0;">
<!-- Стилизованный текстовый логотип uchill --> <!-- Стилизованный текстовый логотип uchill -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
<tr> <tr>
<td> <td>
<span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;"> <span style="display: inline-block; color: #7444FD; letter-spacing: 1px; font-size: 48px; font-weight: 600; line-height: 1.2;">
<span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span> <span style="background-color: #7444FD; color: #ffffff; border-radius: 2px; margin-right: 2px; font-weight: 600; font-size: 48px; display: inline-block; vertical-align: baseline; line-height: 1; padding: 2px 8px; letter-spacing: 0;">u</span><span style="vertical-align: baseline;">chill</span>
</span> </span>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>
<!-- Content block --> <!-- Content block -->
<tr> <tr>
<td style="padding: 0 40px 40px 40px;"> <td style="padding: 0 40px 40px 40px;">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</td> </td>
</tr> </tr>
<!-- Footer --> <!-- Footer -->
<tr> <tr>
<td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;"> <td style="padding: 30px 40px; background-color: #F5F5F5; border-radius: 0 0 8px 8px; border-top: 1px solid #E0E0E0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr> <tr>
<td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;"> <td style="text-align: center; font-size: 14px; color: #757575; line-height: 1.6;">
<p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p> <p style="margin: 0 0 10px 0;">С уважением,<br><strong style="color: #7444FD;">Команда Uchill</strong></p>
<p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;"> <p style="margin: 10px 0 0 0; font-size: 12px; color: #9E9E9E;">
© {% now "Y" %} Uchill. Все права защищены. © {% now "Y" %} Uchill. Все права защищены.
</p> </p>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>
</table> </table>
</body> </body>
</html> </html>

View File

@ -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({

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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;
}
# ============================================== # ==============================================
# ВКЛЮЧЕНИЕ КОНФИГУРАЦИЙ САЙТОВ # ВКЛЮЧЕНИЕ КОНФИГУРАЦИЙ САЙТОВ

View File

@ -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;
}

View File

@ -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: 'Студент' },

View File

@ -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') {

View File

@ -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'),
}; };

View File

@ -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}`;

View File

@ -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

View File

@ -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,

View File

@ -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') {
// Родитель: те же страницы, что и студент, кроме материалов // Родитель: те же страницы, что и студент, кроме материалов

View File

@ -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';

View File

@ -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;
}