From 0b5fb434db0a007c9705f8bdd6d30fa1c3a0d786 Mon Sep 17 00:00:00 2001
From: root
Date: Sat, 14 Feb 2026 02:45:50 +0300
Subject: [PATCH] full
---
backend/apps/homework/tasks.py | 21 +-
backend/apps/homework/views.py | 16 +-
backend/apps/notifications/services.py | 17 +-
backend/apps/notifications/signals.py | 7 +-
backend/apps/notifications/tasks.py | 62 +-
backend/apps/notifications/telegram_bot.py | 7427 +++++++++--------
backend/apps/referrals/views.py | 16 +
backend/apps/users/models.py | 9 +-
backend/apps/users/nav_badges_views.py | 13 +-
backend/apps/users/serializers.py | 9 +
backend/apps/users/templates/emails/base.html | 128 +-
backend/apps/users/views.py | 56 +-
docker-compose.yml | 9 +-
docker/livekit/livekit-config.yaml | 27 +
docker/nginx/conf.d/default.conf | 44 +-
docker/nginx/nginx.conf | 7 +-
front_material/api/referrals.ts | 21 +
front_material/app/(auth)/register/page.tsx | 4 +-
front_material/app/(protected)/layout.tsx | 13 +
.../app/(protected)/my-progress/page.tsx | 2 +-
front_material/components/chat/ChatWindow.tsx | 8 +-
.../homework/HomeworkDetailsModal.tsx | 24 +
.../components/livekit/LiveKitRoomContent.tsx | 25 +-
.../navigation/BottomNavigationBar.tsx | 2 +-
.../notifications/NotificationBell.tsx | 2 +-
front_material/styles/livekit-theme.css | 19 +
26 files changed, 4559 insertions(+), 3429 deletions(-)
create mode 100644 docker/livekit/livekit-config.yaml
diff --git a/backend/apps/homework/tasks.py b/backend/apps/homework/tasks.py
index e14c008..4052cb6 100644
--- a/backend/apps/homework/tasks.py
+++ b/backend/apps/homework/tasks.py
@@ -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'🤖 Предварительная проверка ИИ:\n'
+ f'⭐ Оценка: {score}/5\n\n'
+ f'💬 Комментарий ИИ:\n{feedback_escaped}\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
diff --git a/backend/apps/homework/views.py b/backend/apps/homework/views.py
index 1711b3c..205bd31 100644
--- a/backend/apps/homework/views.py
+++ b/backend/apps/homework/views.py
@@ -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💬 Комментарий:\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
diff --git a/backend/apps/notifications/services.py b/backend/apps/notifications/services.py
index c6b97a3..7ee364c 100644
--- a/backend/apps/notifications/services.py
+++ b/backend/apps/notifications/services.py
@@ -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':
# Уведомление всем назначенным ученикам о новом ДЗ
@@ -793,6 +794,20 @@ class NotificationService:
title = '✅ Домашнее задание проверено'
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💬 Комментарий:\n{escaped_feedback}'
else:
return
diff --git a/backend/apps/notifications/signals.py b/backend/apps/notifications/signals.py
index 54ea454..19c8531 100644
--- a/backend/apps/notifications/signals.py
+++ b/backend/apps/notifications/signals.py
@@ -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, # Системное сообщение
diff --git a/backend/apps/notifications/tasks.py b/backend/apps/notifications/tasks.py
index a7563dc..11bd8e8 100644
--- a/backend/apps/notifications/tasks.py
+++ b/backend/apps/notifications/tasks.py
@@ -403,33 +403,63 @@ 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
-
+
if not telegram_id:
notification.mark_as_sent(error='Telegram ID not linked')
return 'Telegram ID not linked'
-
+
# Формируем сообщение в HTML формате
message_text = f"{notification.title}\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Перейти на платформу'
-
- # Отправляем через Telegram Bot API
- from .telegram_bot import send_telegram_message
- import asyncio
-
+ frontend_domain = urlparse(settings.FRONTEND_URL).netloc or settings.FRONTEND_URL
+ message_text += f'\n\n{frontend_domain}'
+
+ # Уведомления от ИИ для ментора — с кнопками управления
+ 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)
- success = loop.run_until_complete(
- send_telegram_message(telegram_id, message_text, parse_mode='HTML')
- )
+ 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')
+ )
loop.close()
-
+
if success:
notification.mark_as_sent()
logger.info(f'Telegram notification sent to {telegram_id}')
@@ -437,12 +467,12 @@ def send_telegram_notification(notification):
else:
notification.mark_as_sent(error='Failed to send message')
return 'Failed to send Telegram message'
-
+
except Exception as e:
logger.error(f'Error sending telegram message: {str(e)}')
notification.mark_as_sent(error=str(e))
return f'Error: {str(e)}'
-
+
except Exception as e:
logger.error(f'Error sending telegram notification: {str(e)}')
notification.mark_as_sent(error=str(e))
diff --git a/backend/apps/notifications/telegram_bot.py b/backend/apps/notifications/telegram_bot.py
index 49fdc1b..5251fae 100644
--- a/backend/apps/notifications/telegram_bot.py
+++ b/backend/apps/notifications/telegram_bot.py
@@ -1,3293 +1,4134 @@
-"""
-Telegram бот для уведомлений и интеграции.
-"""
-import logging
-from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, KeyboardButton
-from telegram.ext import (
- Application,
- CommandHandler,
- CallbackQueryHandler,
- MessageHandler,
- filters,
- ContextTypes,
-)
-from django.conf import settings
-from django.utils import timezone
-from asgiref.sync import sync_to_async
-
-logger = logging.getLogger(__name__)
-
-
-def get_main_keyboard(role=None):
- """
- Получить основную клавиатуру в зависимости от роли.
-
- Args:
- role: Роль пользователя ('mentor', 'client', 'parent', None)
-
- Returns:
- ReplyKeyboardMarkup: Клавиатура с кнопками
- """
- if role == 'mentor':
- keyboard = [
- [KeyboardButton("📅 Расписание"), KeyboardButton("📚 Следующее занятие")],
- [KeyboardButton("📝 Домашние задания"), KeyboardButton("👥 Клиенты")],
- [KeyboardButton("📊 Статистика"), KeyboardButton("⚙️ Настройки")],
- [KeyboardButton("ℹ️ Статус"), KeyboardButton("❓ Помощь")]
- ]
- elif role == 'client':
- keyboard = [
- [KeyboardButton("📅 Моё расписание"), KeyboardButton("📚 Следующее занятие")],
- [KeyboardButton("📝 Мои задания"), KeyboardButton("📊 Мой прогресс")],
- [KeyboardButton("⚙️ Настройки"), KeyboardButton("❓ Помощь")]
- ]
- elif role == 'parent':
- keyboard = [
- [KeyboardButton("📅 Расписание детей"), KeyboardButton("📚 Следующее занятие")],
- [KeyboardButton("📝 Задания детей"), KeyboardButton("⚙️ Настройки")],
- [KeyboardButton("ℹ️ Статус"), KeyboardButton("❓ Помощь")]
- ]
- else:
- # Для не связанных пользователей
- keyboard = [
- [KeyboardButton("🔗 Связать аккаунт"), KeyboardButton("❓ Помощь")]
- ]
-
- return ReplyKeyboardMarkup(keyboard, resize_keyboard=True, one_time_keyboard=False)
-
-
-async def get_user_keyboard(telegram_id):
- """
- Получить клавиатуру для пользователя по его telegram_id.
-
- Args:
- telegram_id: ID пользователя в Telegram
-
- Returns:
- ReplyKeyboardMarkup: Клавиатура с кнопками
- """
- from apps.users.models import User
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
- role = db_user.role if db_user else None
- return get_main_keyboard(role)
-
-
-class TelegramBot:
- """Класс для управления Telegram ботом."""
-
- def __init__(self):
- """Инициализация бота."""
- self.token = settings.TELEGRAM_BOT_TOKEN
- self.application = None
- self.use_webhook = getattr(settings, 'TELEGRAM_USE_WEBHOOK', False)
- self.webhook_url = getattr(settings, 'TELEGRAM_WEBHOOK_URL', None)
- self.webhook_secret_token = getattr(settings, 'TELEGRAM_WEBHOOK_SECRET_TOKEN', None)
-
- async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """
- Обработчик команды /start.
- Приветствие и инструкции по связыванию аккаунта.
- """
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
-
- # Проверяем связан ли аккаунт
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- # Если аккаунт не связан, показываем общее приветствие
- welcome_message = f"""
-👋 Привет, {user.first_name}!
-
-Я бот образовательной платформы. Я буду присылать вам уведомления о:
-• Новых занятиях
-• Домашних заданиях
-• Сообщениях от ментора/ученика
-• Напоминаниях о занятиях
-
-📱 Чтобы связать ваш аккаунт:
-1. Войдите на платформу
-2. Перейдите в Профиль → Настройки → Telegram
-3. Нажмите "Связать Telegram"
-4. Введите код связывания
-
-Или используйте команду:
-/link <ваш_код_связывания>
-
-После связывания вы увидите команды, доступные для вашей роли.
-"""
- else:
- # Если аккаунт связан, показываем приветствие в зависимости от роли
- role = db_user.role
- user_name = db_user.get_full_name() or db_user.email
-
- if role == 'mentor':
- welcome_message = f"""
-👋 Привет, {user_name}!
-
-Я бот образовательной платформы для менторов.
-
-Я буду присылать вам уведомления о:
-• Новых запросах на занятия
-• Отменах занятий
-• Сданных домашних заданиях
-• Сообщениях от учеников
-• Напоминаниях о занятиях
-
-📚 Доступные команды:
-/help - Полный список команд
-/schedule - Расписание занятий
-/nextlesson - Следующее занятие
-/homework - Домашние задания на проверку
-/clients - Список клиентов
-/stats - Статистика
-/settings - Настройки уведомлений
-/status - Статус аккаунта
-"""
- keyboard = get_main_keyboard('mentor')
- await update.message.reply_text(
- welcome_message,
- reply_markup=keyboard
- )
- return
- elif role == 'client':
- welcome_message = f"""
-👋 Привет, {user_name}!
-
-Я бот образовательной платформы для учеников.
-
-Я буду присылать вам уведомления о:
-• Новых занятиях
-• Отменах занятий
-• Новых домашних заданиях
-• Проверенных домашних заданиях
-• Сообщениях от ментора
-• Напоминаниях о занятиях
-
-📚 Доступные команды:
-/help - Полный список команд
-/schedule - Моё расписание
-/nextlesson - Следующее занятие
-/homework - Мои домашние задания
-/progress - Мой прогресс обучения
-/settings - Настройки уведомлений
-/status - Статус аккаунта
-"""
- keyboard = get_main_keyboard('client')
- await update.message.reply_text(
- welcome_message,
- reply_markup=keyboard
- )
- return
- elif role == 'parent':
- welcome_message = f"""
-👋 Привет, {user_name}!
-
-Я бот образовательной платформы для родителей.
-
-Я буду присылать вам уведомления о:
-• Занятиях ваших детей
-• Домашних заданиях детей
-• Прогрессе обучения
-• Отчётах от менторов
-
-📚 Доступные команды:
-/help - Полный список команд
-/schedule - Расписание детей
-/nextlesson - Следующее занятие ребёнка
-/homework - Домашние задания детей
-/settings - Настройки уведомлений
-/status - Статус аккаунта
-"""
- keyboard = get_main_keyboard('parent')
- await update.message.reply_text(
- welcome_message,
- reply_markup=keyboard
- )
- return
- else:
- # Для других ролей (admin и т.д.)
- welcome_message = f"""
-👋 Привет, {user_name}!
-
-Я бот образовательной платформы.
-
-Я буду присылать вам уведомления о событиях на платформе.
-
-📚 Доступные команды:
-/help - Полный список команд
-/settings - Настройки уведомлений
-/status - Статус аккаунта
-"""
- keyboard = get_main_keyboard(None)
- await update.message.reply_text(
- welcome_message,
- reply_markup=keyboard
- )
- return
-
- # Если дошли сюда, отправляем без клавиатуры
- await update.message.reply_text(welcome_message)
-
- async def help_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик команды /help."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
-
- # Проверяем связан ли аккаунт
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- # Если аккаунт не связан, показываем общую справку
- help_text = """
-🔔 Доступные команды:
-
-/start - Начать работу с ботом
-/link <код> - Связать аккаунт с платформой
-/help - Эта справка
-
-💡 Как связать аккаунт:
-1. Получите код связывания на платформе (Профиль → Telegram)
-2. Отправьте команду: /link ВАШ_КОД
-
-После связывания вы увидите команды, доступные для вашей роли.
-"""
- else:
- # Если аккаунт связан, показываем справку в зависимости от роли
- role = db_user.role
-
- if role == 'mentor':
- help_text = """
-👨🏫 Справка для менторов:
-
-🔔 Основные команды:
-/start - Главное меню
-/help - Эта справка
-/status - Статус аккаунта
-/settings - Настройки уведомлений
-
-📚 Расписание:
-/schedule - Ближайшие занятия (до 5 занятий)
-/nextlesson - Следующее занятие
-
-📝 Домашние задания:
-/homework - Задания, требующие проверки
-
-👥 Клиенты:
-/clients - Список ваших клиентов со статистикой
-/stats - Ваша статистика (занятия, ДЗ, клиенты)
-
-🔗 Управление аккаунтом:
-/unlink - Отвязать Telegram аккаунт
-
-💡 Вы будете получать уведомления о:
-• Новых запросах на занятия
-• Отменах занятий
-• Сданных домашних заданиях
-• Сообщениях от учеников
-"""
- elif role == 'client':
- help_text = """
-👨🎓 Справка для учеников:
-
-🔔 Основные команды:
-/start - Главное меню
-/help - Эта справка
-/status - Статус аккаунта
-/settings - Настройки уведомлений
-
-📚 Расписание:
-/schedule - Моё расписание (до 5 ближайших занятий)
-/nextlesson - Следующее занятие
-
-📝 Домашние задания:
-/homework - Мои активные домашние задания
-
-📊 Прогресс:
-/progress - Мой прогресс обучения
-
-🔗 Управление аккаунтом:
-/unlink - Отвязать Telegram аккаунт
-
-💡 Вы будете получать уведомления о:
-• Новых занятиях
-• Отменах занятий
-• Новых домашних заданиях
-• Проверенных домашних заданиях
-• Сообщениях от ментора
-"""
- elif role == 'parent':
- help_text = """
-👨👩👧 Справка для родителей:
-
-🔔 Основные команды:
-/start - Главное меню
-/help - Эта справка
-/status - Статус аккаунта
-/settings - Настройки уведомлений
-
-📚 Расписание детей:
-/schedule - Расписание всех детей (до 5 ближайших занятий)
-/nextlesson - Следующее занятие ребёнка
-
-📝 Домашние задания:
-/homework - Домашние задания детей
-
-🔗 Управление аккаунтом:
-/unlink - Отвязать Telegram аккаунт
-
-💡 Вы будете получать уведомления о:
-• Занятиях ваших детей
-• Домашних заданиях детей
-• Прогрессе обучения
-• Отчётах от менторов
-"""
- else:
- # Для других ролей (admin и т.д.)
- help_text = """
-🔔 Доступные команды:
-
-/start - Начать работу с ботом
-/help - Эта справка
-/settings - Настройки уведомлений
-/status - Статус связывания
-/unlink - Отвязать аккаунт
-
-💡 Вы будете получать уведомления о событиях на платформе.
-"""
-
- # Добавляем клавиатуру если аккаунт связан
- if db_user:
- keyboard = get_main_keyboard(db_user.role)
- await update.message.reply_text(help_text, reply_markup=keyboard)
- else:
- await update.message.reply_text(help_text)
-
- async def link_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """
- Обработчик команды /link.
- Связывание Telegram аккаунта с аккаунтом на платформе.
- """
- user = update.effective_user
- telegram_id = user.id
- telegram_username = user.username or ''
-
- # Проверяем наличие кода
- if not context.args:
- await update.message.reply_text(
- "❌ Укажите код связывания.\n\n"
- "Использование: /link <код>\n\n"
- "Получите код на платформе: Профиль → Настройки → Telegram"
- )
- return
-
- link_code = context.args[0]
-
- # Проверяем код и связываем аккаунт
- from .services import TelegramLinkService
-
- try:
- result = await sync_to_async(TelegramLinkService.link_account)(
- link_code=link_code,
- telegram_id=telegram_id,
- telegram_username=telegram_username
- )
-
- if result['success']:
- user_name = result.get('user_name', 'Пользователь')
- # Получаем роль пользователя для клавиатуры
- from apps.users.models import User
- linked_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
- keyboard = get_main_keyboard(linked_user.role if linked_user else None)
- await update.message.reply_text(
- f"✅ Аккаунт успешно связан!\n\n"
- f"👤 {user_name}\n\n"
- f"Теперь вы будете получать уведомления в Telegram.",
- reply_markup=keyboard
- )
- else:
- keyboard = get_main_keyboard(None)
- await update.message.reply_text(
- f"❌ Ошибка связывания: {result.get('error', 'Неизвестная ошибка')}",
- reply_markup=keyboard
- )
-
- except Exception as e:
- logger.error(f"Error linking Telegram account: {e}")
- await update.message.reply_text(
- "❌ Произошла ошибка при связывании аккаунта.\n"
- "Попробуйте позже или обратитесь в поддержку."
- )
-
- async def unlink_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """
- Обработчик команды /unlink.
- Отвязка Telegram аккаунта.
- """
- user = update.effective_user
- telegram_id = user.id
-
- from .services import TelegramLinkService
-
- try:
- result = await sync_to_async(TelegramLinkService.unlink_account)(telegram_id)
-
- if result['success']:
- await update.message.reply_text(
- "✅ Аккаунт успешно отвязан.\n\n"
- "Вы больше не будете получать уведомления в Telegram.\n\n"
- "Чтобы снова связать аккаунт, используйте /link"
- )
- else:
- await update.message.reply_text(
- f"❌ {result.get('error', 'Аккаунт не найден')}"
- )
-
- except Exception as e:
- logger.error(f"Error unlinking Telegram account: {e}")
- await update.message.reply_text(
- "❌ Произошла ошибка при отвязке аккаунта."
- )
-
- async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик команды /status."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
-
- try:
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if db_user:
- role_display = db_user.get_role_display()
- role_emoji = {
- 'mentor': '👨🏫',
- 'client': '👨🎓',
- 'parent': '👨👩👧',
- 'admin': '👤'
- }.get(db_user.role, '👤')
-
- status_message = (
- f"✅ Аккаунт связан\n\n"
- f"{role_emoji} {db_user.get_full_name() or db_user.email}\n"
- f"📧 {db_user.email}\n"
- f"🎭 Роль: {role_display}\n\n"
- )
-
- # Дополнительная информация в зависимости от роли
- if db_user.role == 'parent':
- from apps.users.models import Parent
- try:
- parent_profile = await sync_to_async(lambda: db_user.parent_profile)()
- children = await sync_to_async(list)(parent_profile.children.all())
- children_count = len(children)
- status_message += f"👶 Привязано детей: {children_count}\n\n"
- except:
- pass
-
- # Проверяем настройки уведомлений
- try:
- preferences = await sync_to_async(lambda: db_user.notification_preferences)()
- notifications_status = "✅ Включены" if preferences.telegram_enabled else "❌ Выключены"
- status_message += f"🔔 Уведомления: {notifications_status}"
- except:
- status_message += "🔔 Уведомления: ⚙️ Настройте на платформе"
-
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(status_message, parse_mode='HTML', reply_markup=keyboard)
- else:
- keyboard = get_main_keyboard(None)
- await update.message.reply_text(
- "❌ Аккаунт не связан\n\n"
- "Используйте /link <код> для связывания аккаунта.",
- reply_markup=keyboard
- )
-
- except Exception as e:
- logger.error(f"Error checking status: {e}")
- await update.message.reply_text(
- "❌ Ошибка проверки статуса."
- )
-
- async def settings_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик команды /settings."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
-
- try:
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- keyboard = get_main_keyboard(None)
- await update.message.reply_text(
- "❌ Аккаунт не связан.\n\n"
- "Используйте /link <код> для связывания.",
- reply_markup=keyboard
- )
- return
-
- # Получаем настройки уведомлений
- try:
- preferences = await sync_to_async(lambda: db_user.notification_preferences)()
- except:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "⚙️ Настройки уведомлений не найдены.\n\n"
- "Настройте уведомления на платформе: Профиль → Настройки",
- reply_markup=keyboard
- )
- return
-
- # Формируем расширенное меню настроек
- message_text = "⚙️ Настройки\n\n"
-
- # Статус уведомлений
- all_status = "✅ Включены" if preferences.enabled else "❌ Выключены"
- telegram_status = "✅ Вкл" if preferences.telegram_enabled else "❌ Выкл"
- email_status = "✅ Вкл" if preferences.email_enabled else "❌ Выкл"
- in_app_status = "✅ Вкл" if preferences.in_app_enabled else "❌ Выкл"
-
- message_text += f"🔔 Все уведомления: {all_status}\n"
- message_text += f"📱 Telegram: {telegram_status}\n"
- message_text += f"📧 Email: {email_status}\n"
- message_text += f"💬 В приложении: {in_app_status}\n\n"
-
- # Режим тишины
- if preferences.quiet_hours_enabled and preferences.quiet_hours_start and preferences.quiet_hours_end:
- quiet_start = preferences.quiet_hours_start.strftime('%H:%M')
- quiet_end = preferences.quiet_hours_end.strftime('%H:%M')
- message_text += f"🔇 Режим тишины: {quiet_start} - {quiet_end}\n\n"
- else:
- message_text += "🔇 Режим тишины: ❌ Выключен\n\n"
-
- # Часовой пояс
- user_tz = db_user.timezone or 'Europe/Moscow'
- message_text += f"🕐 Часовой пояс: {user_tz}\n"
-
- # Язык
- user_lang = db_user.language or 'ru'
- lang_display = 'Русский' if user_lang == 'ru' else 'English'
- message_text += f"🌐 Язык: {lang_display}\n"
-
- # Создаем inline клавиатуру
- keyboard = []
-
- # Основные настройки уведомлений
- keyboard.append([
- InlineKeyboardButton(
- "🔔 " + ("Выключить все" if preferences.enabled else "Включить все"),
- callback_data="settings_toggle_all"
- )
- ])
-
- keyboard.append([
- InlineKeyboardButton(
- "📱 Telegram: " + ("Выкл" if preferences.telegram_enabled else "Вкл"),
- callback_data="settings_toggle_telegram"
- ),
- InlineKeyboardButton(
- "📧 Email: " + ("Выкл" if preferences.email_enabled else "Вкл"),
- callback_data="settings_toggle_email"
- )
- ])
-
- keyboard.append([
- InlineKeyboardButton(
- "🔇 Режим тишины",
- callback_data="settings_quiet_hours"
- ),
- InlineKeyboardButton(
- "📋 Типы уведомлений",
- callback_data="settings_notification_types"
- )
- ])
-
- keyboard.append([
- InlineKeyboardButton(
- "🕐 Часовой пояс",
- callback_data="settings_timezone"
- ),
- InlineKeyboardButton(
- "🌐 Язык",
- callback_data="settings_language"
- )
- ])
-
- # Добавляем кнопку с URL только если это не localhost
- frontend_url = settings.FRONTEND_URL
- if frontend_url and 'localhost' not in frontend_url and '127.0.0.1' not in frontend_url:
- keyboard.append([
- InlineKeyboardButton("🌐 Открыть на сайте", url=f"{frontend_url}/profile")
- ])
-
- reply_markup = InlineKeyboardMarkup(keyboard)
-
- # Добавляем основную клавиатуру вместе с inline кнопками
- main_keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- message_text,
- parse_mode='HTML',
- reply_markup=reply_markup
- )
- # Отправляем отдельное сообщение с основной клавиатурой
- await update.message.reply_text(
- "Используйте кнопки ниже для навигации:",
- reply_markup=main_keyboard
- )
-
- except Exception as e:
- logger.error(f"Error getting settings: {e}")
- await update.message.reply_text(
- "❌ Ошибка получения настроек."
- )
-
- async def button_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик нажатий на кнопки."""
- query = update.callback_query
- await query.answer()
-
- # Сначала обрабатываем настройки (они имеют приоритет)
- if query.data.startswith('settings_') or query.data.startswith('toggle_type_') or query.data.startswith('set_timezone_') or query.data.startswith('set_language_'):
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
-
- try:
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- await query.edit_message_text("❌ Аккаунт не связан.")
- return
-
- preferences = await sync_to_async(lambda: db_user.notification_preferences)()
-
- # Переключение всех уведомлений
- if query.data == "settings_toggle_all":
- preferences.enabled = not preferences.enabled
- await sync_to_async(preferences.save)()
- await self._refresh_settings_message(query, db_user, preferences)
- return
-
- # Переключение Telegram уведомлений
- elif query.data == "settings_toggle_telegram":
- preferences.telegram_enabled = not preferences.telegram_enabled
- await sync_to_async(preferences.save)()
- await self._refresh_settings_message(query, db_user, preferences)
- return
-
- # Переключение Email уведомлений
- elif query.data == "settings_toggle_email":
- preferences.email_enabled = not preferences.email_enabled
- await sync_to_async(preferences.save)()
- await self._refresh_settings_message(query, db_user, preferences)
- return
-
- # Режим тишины
- elif query.data == "settings_quiet_hours":
- await self._handle_quiet_hours(query, db_user, preferences)
- return
-
- # Обработка режима тишины
- elif query.data.startswith("quiet_hours_"):
- from datetime import time
-
- if query.data == "quiet_hours_disable":
- preferences.quiet_hours_enabled = False
- await sync_to_async(preferences.save)()
- await query.answer("Режим тишины выключен")
- await self._handle_quiet_hours(query, db_user, preferences)
- return
- elif query.data == "quiet_hours_enable_22_8":
- preferences.quiet_hours_enabled = True
- preferences.quiet_hours_start = time(22, 0)
- preferences.quiet_hours_end = time(8, 0)
- await sync_to_async(preferences.save)()
- await query.answer("Режим тишины: 22:00 - 08:00")
- await self._refresh_settings_message(query, db_user, preferences)
- return
- elif query.data == "quiet_hours_enable_23_7":
- preferences.quiet_hours_enabled = True
- preferences.quiet_hours_start = time(23, 0)
- preferences.quiet_hours_end = time(7, 0)
- await sync_to_async(preferences.save)()
- await query.answer("Режим тишины: 23:00 - 07:00")
- await self._refresh_settings_message(query, db_user, preferences)
- return
- elif query.data == "quiet_hours_custom":
- await query.answer("Настройка времени через сайт", show_alert=True)
- await self._handle_quiet_hours(query, db_user, preferences)
- return
-
- # Типы уведомлений
- elif query.data == "settings_notification_types":
- await self._handle_notification_types(query, db_user, preferences)
- return
-
- # Часовой пояс
- elif query.data == "settings_timezone":
- await self._handle_timezone(query, db_user)
- return
-
- # Язык
- elif query.data == "settings_language":
- await self._handle_language(query, db_user)
- return
-
- # Обработка типов уведомлений
- elif query.data.startswith("toggle_type_"):
- ntype = query.data.replace("toggle_type_", "")
- type_prefs = preferences.type_preferences.get(ntype, {})
- if not isinstance(type_prefs, dict):
- type_prefs = {}
-
- current = type_prefs.get('telegram', True)
- type_prefs['telegram'] = not current
- preferences.type_preferences[ntype] = type_prefs
- await sync_to_async(preferences.save)()
-
- # Получаем название типа для ответа
- from .models import Notification
- type_display = dict(Notification.TYPE_CHOICES).get(ntype, ntype)
- status = "включены" if not current else "выключены"
- await query.answer(f"{type_display} {status}")
- await self._handle_notification_types(query, db_user, preferences)
- return
-
- # Игнорируем заголовки (noop)
- elif query.data == "noop":
- await query.answer()
- return
-
- # Установка часового пояса
- elif query.data.startswith("set_timezone_"):
- timezone = query.data.replace("set_timezone_", "")
- db_user.timezone = timezone
- await sync_to_async(db_user.save)(update_fields=['timezone'])
- await query.answer(f"Часовой пояс установлен: {timezone}")
- await self._handle_timezone(query, db_user)
- return
-
- # Установка языка
- elif query.data.startswith("set_language_"):
- language = query.data.replace("set_language_", "")
- db_user.language = language
- await sync_to_async(db_user.save)(update_fields=['language'])
- await query.answer(f"Язык установлен: {language}")
- await self._handle_language(query, db_user)
- return
-
- # Возврат к настройкам
- elif query.data == "settings_back":
- preferences = await sync_to_async(lambda: db_user.notification_preferences)()
- await self._refresh_settings_message(query, db_user, preferences)
- return
-
- except Exception as e:
- logger.error(f"Error handling settings callback: {e}", exc_info=True)
- await query.edit_message_text("❌ Ошибка обновления настроек.")
- return
-
- # Обработка домашних заданий
- if query.data.startswith('homework_'):
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- await query.answer("❌ Аккаунт не связан", show_alert=True)
- return
-
- try:
- if query.data.startswith('homework_upload_'):
- # Загрузка решения ДЗ
- homework_id = int(query.data.replace('homework_upload_', ''))
-
- from apps.homework.models import Homework
- homework = await sync_to_async(Homework.objects.get)(id=homework_id)
-
- # Проверяем доступ
- if db_user.role != 'client' or db_user not in await sync_to_async(list)(homework.assigned_to.all()):
- await query.answer("❌ У вас нет доступа к этому заданию", show_alert=True)
- return
-
- # Сохраняем ID задания в контексте
- context.user_data['waiting_for_homework_file'] = homework_id
-
- await query.answer("📎 Отправьте файл или фото для решения")
- await query.edit_message_text(
- f"📎 Загрузка решения\n\n"
- f"📝 Задание: {homework.title}\n\n"
- f"Отправьте:\n"
- f"• Текст решения\n"
- f"• Файл (документ)\n"
- f"• Фото с решением\n\n"
- f"Или отправьте 'отмена' для отмены.",
- parse_mode='HTML'
- )
- return
-
- elif query.data.startswith('homework_detail_'):
- # Детали задания
- homework_id = int(query.data.replace('homework_detail_', ''))
-
- from apps.homework.models import Homework, HomeworkSubmission
- homework = await sync_to_async(Homework.objects.get)(id=homework_id)
-
- deadline_str = homework.deadline.strftime('%d.%m.%Y в %H:%M') if homework.deadline else 'Без дедлайна'
-
- submission = await sync_to_async(
- HomeworkSubmission.objects.filter(
- homework=homework,
- student=db_user
- ).first
- )()
-
- message = f"📝 {homework.title}\n\n"
- message += f"📅 Дедлайн: {deadline_str}\n"
- if homework.description:
- desc = homework.description[:200] + "..." if len(homework.description) > 200 else homework.description
- message += f"\n📄 {desc}\n"
-
- if submission:
- message += f"\n✅ Статус: Сдано\n"
- if submission.status == 'graded' and submission.score is not None:
- message += f"🎯 Оценка: {submission.score}/{homework.max_score}\n"
- elif submission.status == 'returned':
- message += f"🔄 Возвращено на доработку\n"
- else:
- message += f"⏳ Ожидает проверки\n"
- else:
- message += f"\n⏳ Статус: Не сдано\n"
-
- inline_keyboard = []
- if not submission or submission.status == 'returned':
- inline_keyboard.append([
- InlineKeyboardButton(
- "📎 Загрузить решение",
- callback_data=f"homework_upload_{homework.id}"
- )
- ])
-
- if submission:
- inline_keyboard.append([
- InlineKeyboardButton(
- "👁️ Просмотреть решение",
- callback_data=f"homework_view_{submission.id}"
- )
- ])
-
- inline_keyboard.append([
- InlineKeyboardButton(
- "◀️ Назад к списку",
- callback_data="homework_back"
- )
- ])
-
- reply_markup = InlineKeyboardMarkup(inline_keyboard)
- await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
- return
-
- elif query.data.startswith('homework_view_'):
- # Просмотр решения
- submission_id = int(query.data.replace('homework_view_', ''))
-
- from apps.homework.models import HomeworkSubmission
- submission = await sync_to_async(HomeworkSubmission.objects.select_related('homework').get)(id=submission_id)
-
- if submission.student != db_user and db_user.role != 'mentor':
- await query.answer("❌ У вас нет доступа", show_alert=True)
- return
-
- message = f"👁️ Решение ДЗ\n\n"
- message += f"📝 Задание: {submission.homework.title}\n"
- message += f"👤 Студент: {submission.student.get_full_name()}\n"
- message += f"📅 Сдано: {submission.submitted_at.strftime('%d.%m.%Y в %H:%M') if submission.submitted_at else 'Неизвестно'}\n"
- message += f"📊 Статус: {submission.get_status_display()}\n"
-
- if submission.score is not None:
- message += f"🎯 Оценка: {submission.score}/{submission.homework.max_score}\n"
-
- if submission.feedback:
- feedback = submission.feedback[:200] + "..." if len(submission.feedback) > 200 else submission.feedback
- message += f"\n💬 Отзыв: {feedback}\n"
-
- if submission.attachment:
- message += f"\n📎 Файл прикреплен"
-
- inline_keyboard = [[
- InlineKeyboardButton(
- "🌐 Открыть на сайте",
- url=f"{settings.FRONTEND_URL}/homework"
- )
- ]]
-
- reply_markup = InlineKeyboardMarkup(inline_keyboard)
- await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
- return
-
- elif query.data == 'homework_back':
- # Возврат к списку заданий
- # Отправляем новое сообщение со списком
- from apps.homework.models import Homework, HomeworkSubmission
- from django.utils import timezone
-
- homeworks = await sync_to_async(list)(
- Homework.objects.filter(
- assigned_to=db_user,
- deadline__gte=timezone.now()
- ).order_by('deadline')[:5]
- )
-
- if not homeworks:
- await query.edit_message_text("📝 У вас нет активных домашних заданий.")
- return
-
- message = "📝 Активные домашние задания:\n\n"
- inline_keyboard = []
-
- for hw in homeworks:
- deadline_str = hw.deadline.strftime('%d.%m.%Y') if hw.deadline else 'Без дедлайна'
- submission = await sync_to_async(
- HomeworkSubmission.objects.filter(
- homework=hw,
- student=db_user
- ).first
- )()
- status = "✅ Сдано" if submission else "⏳ Не сдано"
- message += f"{hw.title}\n"
- message += f"📅 Дедлайн: {deadline_str}\n"
- message += f"{status}\n\n"
- inline_keyboard.append([
- InlineKeyboardButton(
- f"📝 {hw.title[:30]}",
- callback_data=f"homework_detail_{hw.id}"
- )
- ])
-
- reply_markup = InlineKeyboardMarkup(inline_keyboard)
- await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
- return
-
- except Exception as e:
- logger.error(f"Error handling homework callback: {e}", exc_info=True)
- await query.answer("❌ Ошибка обработки запроса", show_alert=True)
- return
-
- # Обработка деталей занятия
- if query.data.startswith('lesson_detail_'):
- try:
- lesson_id = int(query.data.replace('lesson_detail_', ''))
-
- from apps.schedule.models import Lesson
- from django.utils import timezone
- import pytz
-
- lesson = await sync_to_async(
- Lesson.objects.select_related('mentor', 'client', 'client__user', 'subject').get
- )(id=lesson_id)
-
- user = update.effective_user
- telegram_id = user.id
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- await query.answer("❌ Аккаунт не связан", show_alert=True)
- return
-
- # Проверяем доступ
- has_access = False
- if db_user.role == 'mentor' and lesson.mentor == db_user:
- has_access = True
- elif db_user.role == 'client' and lesson.client and lesson.client.user == db_user:
- has_access = True
- elif db_user.role == 'parent' and lesson.client and lesson.client.user.parent_profile and lesson.client.user.parent_profile.user == db_user:
- has_access = True
-
- if not has_access:
- await query.answer("❌ У вас нет доступа к этому занятию", show_alert=True)
- return
-
- # Формируем сообщение
- from apps.users.utils import get_user_timezone
- user_tz = get_user_timezone(db_user.timezone or 'UTC')
- if timezone.is_aware(lesson.start_time):
- local_start = lesson.start_time.astimezone(user_tz)
- local_end = lesson.end_time.astimezone(user_tz) if lesson.end_time else None
- else:
- utc_start = timezone.make_aware(lesson.start_time, pytz.UTC)
- local_start = utc_start.astimezone(user_tz)
- local_end = lesson.end_time.astimezone(user_tz) if lesson.end_time and timezone.is_aware(lesson.end_time) else None
-
- message = f"📚 {lesson.title}\n\n"
-
- if lesson.subject:
- message += f"📖 Предмет: {lesson.subject.name}\n"
-
- message += f"🕐 Начало: {local_start.strftime('%d.%m.%Y в %H:%M')}\n"
- if local_end:
- message += f"🕐 Окончание: {local_end.strftime('%d.%m.%Y в %H:%M')}\n"
- message += f"⏱ Длительность: {lesson.duration} минут\n"
- message += f"📊 Статус: {lesson.get_status_display()}\n\n"
-
- if db_user.role == 'mentor':
- if lesson.client:
- message += f"👤 Студент: {lesson.client.user.get_full_name()}\n"
- else:
- message += f"👨🏫 Ментор: {lesson.mentor.get_full_name()}\n"
-
- if lesson.description:
- desc = lesson.description[:200] + "..." if len(lesson.description) > 200 else lesson.description
- message += f"\n📄 {desc}\n"
-
- if lesson.meeting_url:
- message += f"\n🔗 Ссылка на видеоконференцию\n"
-
- if lesson.mentor_grade is not None:
- message += f"\n🎯 Оценка: {lesson.mentor_grade}/100\n"
-
- inline_keyboard = [[
- InlineKeyboardButton(
- "🌐 Открыть на сайте",
- url=f"{settings.FRONTEND_URL}/schedule"
- )
- ]]
-
- reply_markup = InlineKeyboardMarkup(inline_keyboard)
- await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
-
- except Lesson.DoesNotExist:
- await query.answer("❌ Занятие не найдено", show_alert=True)
- except Exception as e:
- logger.error(f"Error handling lesson detail: {e}", exc_info=True)
- await query.answer("❌ Ошибка обработки запроса", show_alert=True)
- return
-
- # Обработка деталей клиента (для менторов)
- if query.data.startswith('client_detail_'):
- try:
- client_id = int(query.data.replace('client_detail_', ''))
-
- from apps.users.models import Client, User
- from apps.schedule.models import Lesson
- from apps.homework.models import HomeworkSubmission
- from django.utils import timezone
- from datetime import timedelta
-
- client = await sync_to_async(
- Client.objects.select_related('user').get
- )(id=client_id)
-
- user = update.effective_user
- telegram_id = user.id
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user or db_user.role != 'mentor' or db_user not in await sync_to_async(list)(client.mentors.all()):
- await query.answer("❌ У вас нет доступа", show_alert=True)
- return
-
- client_name = client.user.get_full_name() or client.user.email
- message = f"👤 {client_name}\n\n"
-
- # Статистика занятий
- all_lessons = await sync_to_async(list)(
- Lesson.objects.filter(mentor=db_user, client=client)
- )
- total_lessons = len(all_lessons)
- completed_lessons = len([l for l in all_lessons if l.status == 'completed'])
- upcoming_lessons = len([
- l for l in all_lessons
- if l.start_time >= timezone.now() and l.status == 'scheduled'
- ])
-
- # Занятия за месяц
- month_ago = timezone.now() - timedelta(days=30)
- lessons_this_month = len([
- l for l in all_lessons
- if l.start_time >= month_ago
- ])
-
- message += f"📚 Занятия:\n"
- message += f"• Всего: {total_lessons}\n"
- message += f"• Завершено: {completed_lessons}\n"
- message += f"• Предстоящих: {upcoming_lessons}\n"
- message += f"• За месяц: {lessons_this_month}\n\n"
-
- # Статистика ДЗ
- all_submissions = await sync_to_async(list)(
- HomeworkSubmission.objects.filter(
- homework__mentor=db_user,
- student=client.user
- )
- )
- total_homeworks = len(all_submissions)
- pending_homeworks = len([s for s in all_submissions if s.status == 'pending'])
- graded_homeworks = len([s for s in all_submissions if s.status == 'graded'])
-
- message += f"📝 Домашние задания:\n"
- message += f"• Всего решений: {total_homeworks}\n"
- message += f"• На проверке: {pending_homeworks}\n"
- message += f"• Проверено: {graded_homeworks}\n"
-
- if graded_homeworks > 0:
- scores = [s.score for s in all_submissions if s.score is not None]
- avg_score = sum(scores) / len(scores) if scores else 0
- message += f"• Средний балл: {avg_score:.1f}\n"
-
- # Доходы от клиента
- lessons_with_price = [l for l in all_lessons if l.price and l.status == 'completed']
- if lessons_with_price:
- total_revenue = sum([float(l.price) for l in lessons_with_price])
- message += f"\n💰 Доходы:\n"
- message += f"• Всего: {total_revenue:.2f} ₽\n"
-
- inline_keyboard = [[
- InlineKeyboardButton(
- "🌐 Открыть на сайте",
- url=f"{settings.FRONTEND_URL}/students"
- ),
- InlineKeyboardButton(
- "◀️ Назад к списку",
- callback_data="clients_back"
- )
- ]]
-
- reply_markup = InlineKeyboardMarkup(inline_keyboard)
- await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
-
- except Client.DoesNotExist:
- await query.answer("❌ Клиент не найден", show_alert=True)
- except Exception as e:
- logger.error(f"Error handling client detail: {e}", exc_info=True)
- await query.answer("❌ Ошибка обработки запроса", show_alert=True)
- return
-
- # Обработка возврата к списку клиентов
- if query.data == 'clients_back':
- try:
- user = update.effective_user
- telegram_id = user.id
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user or db_user.role != 'mentor':
- await query.answer("❌ Ошибка", show_alert=True)
- return
-
- from apps.users.models import Client
- clients = await sync_to_async(list)(
- Client.objects.filter(mentors=db_user).select_related('user')[:10]
- )
-
- if not clients:
- await query.edit_message_text("👥 У вас пока нет клиентов.")
- return
-
- message = "👥 Ваши клиенты:\n\n"
- inline_keyboard = []
-
- for client in clients:
- client_name = client.user.get_full_name() or client.user.email
- message += f"👤 {client_name}\n\n"
- inline_keyboard.append([
- InlineKeyboardButton(
- f"👤 {client_name[:30]}",
- callback_data=f"client_detail_{client.id}"
- )
- ])
-
- reply_markup = InlineKeyboardMarkup(inline_keyboard)
- await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
-
- except Exception as e:
- logger.error(f"Error handling clients back: {e}", exc_info=True)
- await query.answer("❌ Ошибка", show_alert=True)
- return
-
- # Обработка подтверждения присутствия
- if query.data.startswith('attendance_yes_') or query.data.startswith('attendance_no_'):
- try:
- lesson_id = int(query.data.split('_')[-1])
- response_bool = query.data.startswith('attendance_yes_')
-
- from apps.schedule.models import Lesson
- from django.utils import timezone
-
- lesson = await sync_to_async(Lesson.objects.select_related('client', 'client__user', 'mentor').get)(id=lesson_id)
- user = update.effective_user
- telegram_id = user.id
-
- # Проверяем, что пользователь - студент этого занятия
- if not lesson.client or lesson.client.user.telegram_id != telegram_id:
- await query.edit_message_text(
- "❌ Ошибка: вы не являетесь студентом этого занятия"
- )
- return
-
- # Сохраняем ответ
- lesson.attendance_confirmed = response_bool
- lesson.attendance_response_at = timezone.now()
- await sync_to_async(lesson.save)(update_fields=['attendance_confirmed', 'attendance_response_at'])
-
- # Отправляем уведомление ментору
- from apps.notifications.services import NotificationService
- await sync_to_async(NotificationService.send_attendance_response_to_mentor)(lesson, response_bool)
-
- response_text = "будете присутствовать" if response_bool else "не сможете присутствовать"
-
- await query.edit_message_text(
- f"✅ Ответ сохранен\n\n"
- f"Вы подтвердили, что {response_text} на занятии:\n"
- f"{lesson.title}\n\n"
- f"Преподаватель получил уведомление."
- )
-
- except Lesson.DoesNotExist:
- await query.edit_message_text("❌ Занятие не найдено")
- except Exception as e:
- logger.error(f"Error processing attendance confirmation: {e}")
- await query.edit_message_text("❌ Ошибка обработки ответа")
- return
-
- # Обработка настроек уведомлений
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
-
- try:
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- await query.edit_message_text("❌ Аккаунт не связан.")
- return
-
- preferences = await sync_to_async(lambda: db_user.notification_preferences)()
-
- # Переключение всех уведомлений
- if query.data == "settings_toggle_all":
- preferences.enabled = not preferences.enabled
- await sync_to_async(preferences.save)()
- await self._refresh_settings_message(query, db_user, preferences)
- return
-
- # Переключение Telegram уведомлений
- elif query.data == "settings_toggle_telegram":
- preferences.telegram_enabled = not preferences.telegram_enabled
- await sync_to_async(preferences.save)()
- await self._refresh_settings_message(query, db_user, preferences)
- return
-
- # Переключение Email уведомлений
- elif query.data == "settings_toggle_email":
- preferences.email_enabled = not preferences.email_enabled
- await sync_to_async(preferences.save)()
- await self._refresh_settings_message(query, db_user, preferences)
- return
-
- # Режим тишины
- elif query.data == "settings_quiet_hours":
- await self._handle_quiet_hours(query, db_user, preferences)
- return
-
- # Типы уведомлений
- elif query.data == "settings_notification_types":
- await self._handle_notification_types(query, db_user, preferences)
- return
-
- # Часовой пояс
- elif query.data == "settings_timezone":
- await self._handle_timezone(query, db_user)
- return
-
- # Язык
- elif query.data == "settings_language":
- await self._handle_language(query, db_user)
- return
-
- # Обработка типов уведомлений
- elif query.data.startswith("toggle_type_"):
- ntype = query.data.replace("toggle_type_", "")
- type_prefs = preferences.type_preferences.get(ntype, {})
- if not isinstance(type_prefs, dict):
- type_prefs = {}
-
- current = type_prefs.get('telegram', True)
- type_prefs['telegram'] = not current
- preferences.type_preferences[ntype] = type_prefs
- await sync_to_async(preferences.save)()
-
- await query.answer("Настройка обновлена")
- await self._handle_notification_types(query, db_user, preferences)
- return
-
- # Установка часового пояса
- elif query.data.startswith("set_timezone_"):
- timezone = query.data.replace("set_timezone_", "")
- db_user.timezone = timezone
- await sync_to_async(db_user.save)(update_fields=['timezone'])
- await query.answer(f"Часовой пояс установлен: {timezone}")
- await self._handle_timezone(query, db_user)
- return
-
- # Установка языка
- elif query.data.startswith("set_language_"):
- language = query.data.replace("set_language_", "")
- db_user.language = language
- await sync_to_async(db_user.save)(update_fields=['language'])
- await query.answer(f"Язык установлен: {language}")
- await self._handle_language(query, db_user)
- return
-
- # Возврат к настройкам
- elif query.data == "settings_back":
- preferences = await sync_to_async(lambda: db_user.notification_preferences)()
- await self._refresh_settings_message(query, db_user, preferences)
- return
-
- # Старый обработчик для обратной совместимости
- elif query.data == "toggle_notifications":
- preferences.telegram_enabled = not preferences.telegram_enabled
- await sync_to_async(preferences.save)()
- await self._refresh_settings_message(query, db_user, preferences)
- return
-
- except Exception as e:
- logger.error(f"Error handling settings callback: {e}", exc_info=True)
- await query.edit_message_text(
- "❌ Ошибка обновления настроек."
- )
-
- async def schedule_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик команды /schedule - показать расписание."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
-
- try:
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- await update.message.reply_text(
- "❌ Аккаунт не связан.\n\n"
- "Используйте /link <код> для связывания аккаунта."
- )
- return
-
- # Получаем ближайшие занятия
- from apps.schedule.models import Lesson
- from django.utils import timezone
-
- now = timezone.now()
-
- # Для ментора - все занятия
- if db_user.role == 'mentor':
- lessons = await sync_to_async(list)(
- Lesson.objects.filter(
- mentor=db_user,
- start_time__gte=now
- ).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
- )
- # Для клиента - только его занятия
- elif db_user.role == 'client':
- # Проверяем наличие Client профиля
- try:
- from apps.users.models import Client
- client_profile = await sync_to_async(
- Client.objects.filter(user=db_user).first
- )()
-
- if not client_profile:
- await update.message.reply_text(
- "❌ Профиль клиента не найден.\n\n"
- "Обратитесь к администратору для настройки профиля."
- )
- return
- except Exception as e:
- logger.error(f"Error getting client profile: {e}")
- await update.message.reply_text(
- "❌ Ошибка получения профиля клиента."
- )
- return
-
- lessons = await sync_to_async(list)(
- Lesson.objects.filter(
- client=client_profile,
- start_time__gte=now
- ).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
- )
- # Для родителя - занятия всех детей
- elif db_user.role == 'parent':
- from apps.users.models import Parent
- try:
- parent_profile = await sync_to_async(lambda: db_user.parent_profile)()
- children = await sync_to_async(list)(parent_profile.children.all())
-
- if not children:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ У вас нет привязанных детей.\n\n"
- "Обратитесь к администратору для привязки детей к вашему аккаунту.",
- reply_markup=keyboard
- )
- return
-
- # Получаем занятия всех детей
- child_clients = list(children) # children уже список Client объектов
- lessons = await sync_to_async(list)(
- Lesson.objects.filter(
- client__in=child_clients,
- start_time__gte=now
- ).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
- )
- except:
- await update.message.reply_text(
- "❌ Ошибка получения данных родителя."
- )
- return
- else:
- await update.message.reply_text(
- "❌ Эта команда доступна только для менторов, клиентов и родителей."
- )
- return
-
- if not lessons:
- await update.message.reply_text(
- "📅 У вас нет предстоящих занятий."
- )
- return
-
- message = "📅 Ближайшие занятия:\n\n"
-
- for lesson in lessons:
- import pytz
- from apps.users.utils import get_user_timezone
- user_tz = get_user_timezone(db_user.timezone or 'UTC')
- if timezone.is_aware(lesson.start_time):
- local_time = lesson.start_time.astimezone(user_tz)
- else:
- utc_time = timezone.make_aware(lesson.start_time, pytz.UTC)
- local_time = utc_time.astimezone(user_tz)
-
- time_str = local_time.strftime('%d.%m.%Y в %H:%M')
-
- if db_user.role == 'mentor':
- student_name = lesson.client.user.get_full_name() if lesson.client and lesson.client.user else 'Студент'
- message += f"📚 {lesson.title}\n"
- message += f"👤 {student_name}\n"
- elif db_user.role == 'parent':
- child_name = lesson.client.user.get_full_name() if lesson.client and lesson.client.user else 'Ребёнок'
- message += f"📚 {lesson.title}\n"
- message += f"👨🏫 {lesson.mentor.get_full_name()}\n"
- message += f"👶 {child_name}\n"
- else:
- message += f"📚 {lesson.title}\n"
- message += f"👨🏫 {lesson.mentor.get_full_name()}\n"
-
- message += f"🕐 {time_str}\n\n"
-
- await update.message.reply_text(message, parse_mode='HTML')
-
- except Exception as e:
- logger.error(f"Error in schedule_command: {e}", exc_info=True)
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Ошибка получения расписания.\n\n"
- "Попробуйте позже или обратитесь в поддержку.",
- reply_markup=keyboard
- )
-
- async def nextlesson_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик команды /nextlesson - показать следующее занятие."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
-
- try:
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- keyboard = get_main_keyboard(None)
- await update.message.reply_text(
- "❌ Аккаунт не связан.\n\n"
- "Используйте /link <код> для связывания аккаунта.",
- reply_markup=keyboard
- )
- return
-
- from apps.schedule.models import Lesson
- from django.utils import timezone
-
- now = timezone.now()
-
- # Находим ближайшее занятие
- if db_user.role == 'mentor':
- lesson = await sync_to_async(
- Lesson.objects.filter(
- mentor=db_user,
- start_time__gte=now
- ).select_related('mentor', 'client', 'client__user').order_by('start_time').first
- )()
- elif db_user.role == 'client':
- # Проверяем наличие Client профиля
- try:
- from apps.users.models import Client
- client_profile = await sync_to_async(
- Client.objects.filter(user=db_user).first
- )()
-
- if not client_profile:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Профиль клиента не найден.\n\n"
- "Обратитесь к администратору для настройки профиля.",
- reply_markup=keyboard
- )
- return
- except Exception as e:
- logger.error(f"Error getting client profile: {e}")
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Ошибка получения профиля клиента.",
- reply_markup=keyboard
- )
- return
-
- lesson = await sync_to_async(
- Lesson.objects.filter(
- client=client_profile,
- start_time__gte=now
- ).select_related('mentor', 'client', 'client__user').order_by('start_time').first
- )()
- elif db_user.role == 'parent':
- from apps.users.models import Parent
- try:
- parent_profile = await sync_to_async(lambda: db_user.parent_profile)()
- children = await sync_to_async(list)(parent_profile.children.all())
-
- if not children:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ У вас нет привязанных детей.",
- reply_markup=keyboard
- )
- return
-
- child_clients = list(children) # children уже список Client объектов
- lesson = await sync_to_async(
- Lesson.objects.filter(
- client__in=child_clients,
- start_time__gte=now
- ).select_related('mentor', 'client', 'client__user').order_by('start_time').first
- )()
- except:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Ошибка получения данных родителя.",
- reply_markup=keyboard
- )
- return
- else:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Эта команда доступна только для менторов, клиентов и родителей.",
- reply_markup=keyboard
- )
- return
-
- if not lesson:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "📅 У вас нет предстоящих занятий.",
- reply_markup=keyboard
- )
- return
-
- import pytz
- from apps.users.utils import get_user_timezone
- user_tz = get_user_timezone(db_user.timezone or 'UTC')
- if timezone.is_aware(lesson.start_time):
- local_time = lesson.start_time.astimezone(user_tz)
- else:
- utc_time = timezone.make_aware(lesson.start_time, pytz.UTC)
- local_time = utc_time.astimezone(user_tz)
-
- time_str = local_time.strftime('%d.%m.%Y в %H:%M')
-
- message = "📚 Следующее занятие:\n\n"
- message += f"{lesson.title}\n"
-
- if db_user.role == 'mentor':
- student_name = lesson.client.user.get_full_name() if lesson.client and lesson.client.user else 'Студент'
- message += f"👤 {student_name}\n"
- elif db_user.role == 'parent':
- child_name = lesson.client.user.get_full_name() if lesson.client and lesson.client.user else 'Ребёнок'
- message += f"👨🏫 {lesson.mentor.get_full_name()}\n"
- message += f"👶 {child_name}\n"
- else:
- message += f"👨🏫 {lesson.mentor.get_full_name()}\n"
-
- message += f"🕐 {time_str}\n"
-
- if lesson.description:
- message += f"\n📝 {lesson.description[:200]}"
-
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(message, parse_mode='HTML', reply_markup=keyboard)
-
- except Exception as e:
- logger.error(f"Error in nextlesson_command: {e}", exc_info=True)
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Ошибка получения следующего занятия.\n\n"
- "Попробуйте позже или обратитесь в поддержку.",
- reply_markup=keyboard
- )
-
- async def homework_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик команды /homework - показать домашние задания."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
-
- try:
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- await update.message.reply_text(
- "❌ Аккаунт не связан.\n\n"
- "Используйте /link <код> для связывания аккаунта."
- )
- return
-
- from apps.homework.models import Homework, HomeworkSubmission
-
- # Для клиента - показать его задания
- if db_user.role == 'client':
- homeworks = await sync_to_async(list)(
- Homework.objects.filter(
- assigned_to=db_user,
- deadline__gte=timezone.now()
- ).order_by('deadline')[:5]
- )
-
- if not homeworks:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "📝 У вас нет активных домашних заданий.",
- reply_markup=keyboard
- )
- return
-
- # Если только одно задание, показываем детали с кнопками
- if len(homeworks) == 1:
- hw = homeworks[0]
- deadline_str = hw.deadline.strftime('%d.%m.%Y в %H:%M') if hw.deadline else 'Без дедлайна'
-
- # Проверяем сдано ли
- submission = await sync_to_async(
- HomeworkSubmission.objects.filter(
- homework=hw,
- student=db_user
- ).first
- )()
-
- message = f"📝 {hw.title}\n\n"
- message += f"📅 Дедлайн: {deadline_str}\n"
- if hw.description:
- desc = hw.description[:200] + "..." if len(hw.description) > 200 else hw.description
- message += f"\n📄 {desc}\n"
-
- if submission:
- message += f"\n✅ Статус: Сдано\n"
- if submission.status == 'graded' and submission.score is not None:
- message += f"🎯 Оценка: {submission.score}/{hw.max_score}\n"
- elif submission.status == 'returned':
- message += f"🔄 Возвращено на доработку\n"
- else:
- message += f"⏳ Ожидает проверки\n"
- else:
- message += f"\n⏳ Статус: Не сдано\n"
-
- # Создаем inline клавиатуру
- inline_keyboard = []
- if not submission or submission.status == 'returned':
- inline_keyboard.append([
- InlineKeyboardButton(
- "📎 Загрузить решение",
- callback_data=f"homework_upload_{hw.id}"
- )
- ])
-
- if submission:
- inline_keyboard.append([
- InlineKeyboardButton(
- "👁️ Просмотреть решение",
- callback_data=f"homework_view_{submission.id}"
- )
- ])
-
- inline_keyboard.append([
- InlineKeyboardButton(
- "🌐 Открыть на сайте",
- url=f"{settings.FRONTEND_URL}/homework"
- )
- ])
-
- reply_markup = InlineKeyboardMarkup(inline_keyboard)
- keyboard = await get_user_keyboard(telegram_id)
-
- await update.message.reply_text(message, parse_mode='HTML', reply_markup=reply_markup)
- await update.message.reply_text("Используйте кнопки для навигации:", reply_markup=keyboard)
- else:
- # Если несколько заданий, показываем список с кнопками
- message = "📝 Активные домашние задания:\n\n"
-
- inline_keyboard = []
- for hw in homeworks:
- deadline_str = hw.deadline.strftime('%d.%m.%Y') if hw.deadline else 'Без дедлайна'
-
- # Проверяем сдано ли
- submission = await sync_to_async(
- HomeworkSubmission.objects.filter(
- homework=hw,
- student=db_user
- ).first
- )()
-
- status = "✅ Сдано" if submission else "⏳ Не сдано"
-
- message += f"{hw.title}\n"
- message += f"📅 Дедлайн: {deadline_str}\n"
- message += f"{status}\n\n"
-
- # Кнопка для просмотра деталей
- inline_keyboard.append([
- InlineKeyboardButton(
- f"📝 {hw.title[:30]}",
- callback_data=f"homework_detail_{hw.id}"
- )
- ])
-
- reply_markup = InlineKeyboardMarkup(inline_keyboard)
- keyboard = await get_user_keyboard(telegram_id)
-
- await update.message.reply_text(message, parse_mode='HTML', reply_markup=reply_markup)
- await update.message.reply_text("Выберите задание для просмотра:", reply_markup=keyboard)
-
- # Для родителя - показать задания всех детей
- elif db_user.role == 'parent':
- from apps.users.models import Parent
- try:
- parent_profile = await sync_to_async(lambda: db_user.parent_profile)()
- children = await sync_to_async(list)(parent_profile.children.all())
-
- if not children:
- await update.message.reply_text(
- "❌ У вас нет привязанных детей."
- )
- return
-
- child_users = [child.user for child in children]
- homeworks = await sync_to_async(list)(
- Homework.objects.filter(
- assigned_to__in=child_users,
- deadline__gte=timezone.now()
- ).order_by('deadline')[:5]
- )
-
- if not homeworks:
- await update.message.reply_text(
- "📝 У ваших детей нет активных домашних заданий."
- )
- return
-
- message = "📝 Домашние задания детей:\n\n"
-
- for hw in homeworks:
- deadline_str = hw.deadline.strftime('%d.%m.%Y') if hw.deadline else 'Без дедлайна'
-
- # Находим для какого ребёнка это задание
- child_students = [student for student in hw.assigned_to.all() if student in child_users]
- child_names = ', '.join([child.get_full_name() for child in child_students[:2]])
- if len(child_students) > 2:
- child_names += f" и ещё {len(child_students) - 2}"
-
- message += f"{hw.title}\n"
- message += f"👶 {child_names}\n"
- message += f"📅 Дедлайн: {deadline_str}\n\n"
-
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(message, parse_mode='HTML', reply_markup=keyboard)
- except Exception as e:
- logger.error(f"Error in homework_command for parent: {e}")
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Ошибка получения домашних заданий.",
- reply_markup=keyboard
- )
-
- # Для ментора - показать задания требующие проверки
- elif db_user.role == 'mentor':
- submissions = await sync_to_async(list)(
- HomeworkSubmission.objects.filter(
- homework__mentor=db_user,
- status='pending'
- ).order_by('-submitted_at')[:5]
- )
-
- if not submissions:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "📝 Нет домашних заданий, требующих проверки.",
- reply_markup=keyboard
- )
- return
-
- message = "📝 Домашние задания на проверку:\n\n"
-
- for submission in submissions:
- student_name = submission.student.get_full_name() if submission.student else 'Студент'
- hw_title = submission.homework.title
- submitted_at = submission.submitted_at.strftime('%d.%m.%Y') if submission.submitted_at else 'Неизвестно'
-
- message += f"{hw_title}\n"
- message += f"👤 {student_name}\n"
- message += f"📅 Сдано: {submitted_at}\n\n"
-
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(message, parse_mode='HTML', reply_markup=keyboard)
- else:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Эта команда доступна только для менторов, клиентов и родителей.",
- reply_markup=keyboard
- )
-
- except Exception as e:
- logger.error(f"Error in homework_command: {e}")
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Ошибка получения домашних заданий.",
- reply_markup=keyboard
- )
-
- async def _handle_text_homework_submission(self, update: Update, context: ContextTypes.DEFAULT_TYPE, db_user, homework_id: int, text_content: str):
- """
- Обработка текстового решения домашнего задания.
-
- Args:
- update: Update объект от Telegram
- context: Context объект
- db_user: Пользователь из базы данных
- homework_id: ID домашнего задания
- text_content: Текстовое содержание решения
- """
- try:
- from apps.homework.models import Homework, HomeworkSubmission
- from django.utils import timezone as tz
-
- homework = await sync_to_async(Homework.objects.get)(id=homework_id)
-
- # Проверяем, что пользователь имеет право сдавать это ДЗ
- if db_user.role != 'client' or db_user not in await sync_to_async(list)(homework.assigned_to.all()):
- await update.message.reply_text(
- "❌ У вас нет доступа к этому заданию."
- )
- context.user_data.pop('waiting_for_homework_file', None)
- return
-
- # Проверяем, есть ли уже решение
- existing_submission = await sync_to_async(
- HomeworkSubmission.objects.filter(
- homework=homework,
- student=db_user
- ).order_by('-attempt_number').first
- )()
-
- if existing_submission and existing_submission.status != 'returned':
- # Обновляем существующее решение
- existing_submission.content = text_content
- existing_submission.status = 'pending'
- await sync_to_async(existing_submission.save)()
-
- await update.message.reply_text(
- f"✅ Решение обновлено!\n\n"
- f"📝 Задание: {homework.title}\n\n"
- f"Решение отправлено на проверку.",
- parse_mode='HTML'
- )
- else:
- # Определяем номер попытки
- attempt_number = 1
- if existing_submission:
- attempt_number = existing_submission.attempt_number + 1
-
- # Создаем новое решение
- submission = HomeworkSubmission(
- homework=homework,
- student=db_user,
- content=text_content,
- status='pending',
- attempt_number=attempt_number
- )
- await sync_to_async(submission.save)()
-
- # Проверяем опоздание
- await sync_to_async(submission.check_if_late)()
-
- # Обновляем статистику задания
- await sync_to_async(homework.update_statistics)()
-
- await update.message.reply_text(
- f"✅ Решение загружено!\n\n"
- f"📝 Задание: {homework.title}\n\n"
- f"Решение отправлено на проверку.",
- parse_mode='HTML'
- )
-
- # Отправляем уведомление ментору
- from apps.notifications.services import NotificationService
- await sync_to_async(NotificationService.create_notification_with_telegram)(
- recipient=homework.mentor,
- notification_type='homework_submitted',
- title='📝 ДЗ сдано',
- message=f'{db_user.get_full_name()} сдал ДЗ "{homework.title}"',
- priority='normal',
- action_url=f'/homework/{homework.id}/',
- content_object=homework
- )
-
- # Очищаем состояние ожидания
- context.user_data.pop('waiting_for_homework_file', None)
-
- keyboard = await get_user_keyboard(update.effective_user.id)
- await update.message.reply_text(
- "Используйте кнопки для навигации:",
- reply_markup=keyboard
- )
-
- except Homework.DoesNotExist:
- await update.message.reply_text(
- "❌ Домашнее задание не найдено."
- )
- context.user_data.pop('waiting_for_homework_file', None)
- except Exception as e:
- logger.error(f"Error handling text homework submission: {e}", exc_info=True)
- await update.message.reply_text(
- "❌ Ошибка загрузки решения.\n\n"
- "Попробуйте позже или обратитесь в поддержку."
- )
- context.user_data.pop('waiting_for_homework_file', None)
-
- async def _refresh_settings_message(self, query, db_user, preferences):
- """Обновить сообщение с настройками."""
- message_text = "⚙️ Настройки\n\n"
-
- all_status = "✅ Включены" if preferences.enabled else "❌ Выключены"
- telegram_status = "✅ Вкл" if preferences.telegram_enabled else "❌ Выкл"
- email_status = "✅ Вкл" if preferences.email_enabled else "❌ Выкл"
- in_app_status = "✅ Вкл" if preferences.in_app_enabled else "❌ Выкл"
-
- message_text += f"🔔 Все уведомления: {all_status}\n"
- message_text += f"📱 Telegram: {telegram_status}\n"
- message_text += f"📧 Email: {email_status}\n"
- message_text += f"💬 В приложении: {in_app_status}\n\n"
-
- if preferences.quiet_hours_enabled and preferences.quiet_hours_start and preferences.quiet_hours_end:
- quiet_start = preferences.quiet_hours_start.strftime('%H:%M')
- quiet_end = preferences.quiet_hours_end.strftime('%H:%M')
- message_text += f"🔇 Режим тишины: {quiet_start} - {quiet_end}\n\n"
- else:
- message_text += "🔇 Режим тишины: ❌ Выключен\n\n"
-
- user_tz = db_user.timezone or 'Europe/Moscow'
- message_text += f"🕐 Часовой пояс: {user_tz}\n"
-
- user_lang = db_user.language or 'ru'
- lang_display = 'Русский' if user_lang == 'ru' else 'English'
- message_text += f"🌐 Язык: {lang_display}\n"
-
- keyboard = []
- keyboard.append([
- InlineKeyboardButton(
- "🔔 " + ("Выключить все" if preferences.enabled else "Включить все"),
- callback_data="settings_toggle_all"
- )
- ])
- keyboard.append([
- InlineKeyboardButton(
- "📱 Telegram: " + ("Выкл" if preferences.telegram_enabled else "Вкл"),
- callback_data="settings_toggle_telegram"
- ),
- InlineKeyboardButton(
- "📧 Email: " + ("Выкл" if preferences.email_enabled else "Вкл"),
- callback_data="settings_toggle_email"
- )
- ])
- keyboard.append([
- InlineKeyboardButton("🔇 Режим тишины", callback_data="settings_quiet_hours"),
- InlineKeyboardButton("📋 Типы уведомлений", callback_data="settings_notification_types")
- ])
- keyboard.append([
- InlineKeyboardButton("🕐 Часовой пояс", callback_data="settings_timezone"),
- InlineKeyboardButton("🌐 Язык", callback_data="settings_language")
- ])
-
- frontend_url = settings.FRONTEND_URL
- if frontend_url and 'localhost' not in frontend_url and '127.0.0.1' not in frontend_url:
- keyboard.append([
- InlineKeyboardButton("🌐 Открыть на сайте", url=f"{frontend_url}/profile")
- ])
-
- reply_markup = InlineKeyboardMarkup(keyboard)
- await query.edit_message_text(message_text, parse_mode='HTML', reply_markup=reply_markup)
-
- async def _handle_quiet_hours(self, query, db_user, preferences):
- """Обработка настроек режима тишины."""
- from datetime import time
-
- message_text = "🔇 Режим тишины\n\n"
-
- if preferences.quiet_hours_enabled and preferences.quiet_hours_start and preferences.quiet_hours_end:
- quiet_start = preferences.quiet_hours_start.strftime('%H:%M')
- quiet_end = preferences.quiet_hours_end.strftime('%H:%M')
- message_text += f"Текущий: {quiet_start} - {quiet_end}\n\n"
- else:
- message_text += "Текущий: ❌ Выключен\n\n"
-
- message_text += "Выберите действие:"
-
- keyboard = []
-
- # Предустановленные варианты
- if not preferences.quiet_hours_enabled:
- keyboard.append([
- InlineKeyboardButton("✅ Включить (22:00 - 08:00)", callback_data="quiet_hours_enable_22_8")
- ])
- keyboard.append([
- InlineKeyboardButton("✅ Включить (23:00 - 07:00)", callback_data="quiet_hours_enable_23_7")
- ])
- else:
- keyboard.append([
- InlineKeyboardButton("❌ Выключить", callback_data="quiet_hours_disable")
- ])
- keyboard.append([
- InlineKeyboardButton("🕐 Изменить время", callback_data="quiet_hours_custom")
- ])
-
- keyboard.append([
- InlineKeyboardButton("◀️ Назад к настройкам", callback_data="settings_back")
- ])
-
- reply_markup = InlineKeyboardMarkup(keyboard)
- await query.edit_message_text(message_text, parse_mode='HTML', reply_markup=reply_markup)
-
- async def _handle_notification_types(self, query, db_user, preferences):
- """Обработка настроек типов уведомлений."""
- from .models import Notification
-
- # Показываем меню выбора типов уведомлений
- message_text = "📋 Типы уведомлений\n\n"
- message_text += "Выберите типы уведомлений для Telegram:\n\n"
-
- # Группируем типы по категориям
- lesson_types = [t for t in Notification.TYPE_CHOICES if 'lesson' in t[0]]
- homework_types = [t for t in Notification.TYPE_CHOICES if 'homework' in t[0]]
- other_types = [t for t in Notification.TYPE_CHOICES if 'lesson' not in t[0] and 'homework' not in t[0]]
-
- keyboard = []
-
- # Кнопки для занятий
- if lesson_types:
- keyboard.append([InlineKeyboardButton("📅 Занятия", callback_data="noop")])
- for ntype, display in lesson_types:
- type_prefs = preferences.type_preferences.get(ntype, {})
- if not isinstance(type_prefs, dict):
- type_prefs = {}
- enabled = type_prefs.get('telegram', True)
- status = "✅" if enabled else "❌"
- # Сокращаем длинные названия
- short_display = display.replace('Занятие ', '').replace(' занятие', '')
- keyboard.append([
- InlineKeyboardButton(
- f"{status} {short_display}",
- callback_data=f"toggle_type_{ntype}"
- )
- ])
-
- # Кнопки для домашних заданий
- if homework_types:
- keyboard.append([InlineKeyboardButton("📝 Домашние задания", callback_data="noop")])
- for ntype, display in homework_types:
- type_prefs = preferences.type_preferences.get(ntype, {})
- if not isinstance(type_prefs, dict):
- type_prefs = {}
- enabled = type_prefs.get('telegram', True)
- status = "✅" if enabled else "❌"
- short_display = display.replace('Домашнее задание', 'ДЗ')
- keyboard.append([
- InlineKeyboardButton(
- f"{status} {short_display}",
- callback_data=f"toggle_type_{ntype}"
- )
- ])
-
- # Кнопки для других типов (первые 3)
- if other_types:
- keyboard.append([InlineKeyboardButton("📢 Другие", callback_data="noop")])
- for ntype, display in other_types[:3]:
- type_prefs = preferences.type_preferences.get(ntype, {})
- if not isinstance(type_prefs, dict):
- type_prefs = {}
- enabled = type_prefs.get('telegram', True)
- status = "✅" if enabled else "❌"
- keyboard.append([
- InlineKeyboardButton(
- f"{status} {display}",
- callback_data=f"toggle_type_{ntype}"
- )
- ])
-
- keyboard.append([
- InlineKeyboardButton("◀️ Назад к настройкам", callback_data="settings_back")
- ])
-
- reply_markup = InlineKeyboardMarkup(keyboard)
- await query.edit_message_text(message_text, parse_mode='HTML', reply_markup=reply_markup)
-
- async def _handle_timezone(self, query, db_user):
- """Обработка настройки часового пояса."""
- # Популярные часовые пояса
- popular_timezones = [
- ('Europe/Moscow', 'Москва (UTC+3)'),
- ('Europe/Kiev', 'Киев (UTC+2)'),
- ('Asia/Almaty', 'Алматы (UTC+6)'),
- ('Europe/Minsk', 'Минск (UTC+3)'),
- ('Asia/Tashkent', 'Ташкент (UTC+5)'),
- ('Asia/Yekaterinburg', 'Екатеринбург (UTC+5)'),
- ('Asia/Novosibirsk', 'Новосибирск (UTC+7)'),
- ('Europe/Kaliningrad', 'Калининград (UTC+2)'),
- ]
-
- message_text = "🕐 Часовой пояс\n\n"
- message_text += "Текущий: " + (db_user.timezone or 'Europe/Moscow') + "\n\n"
- message_text += "Выберите часовой пояс:"
-
- keyboard = []
- for tz, name in popular_timezones:
- keyboard.append([
- InlineKeyboardButton(
- name + (" ✅" if db_user.timezone == tz else ""),
- callback_data=f"set_timezone_{tz}"
- )
- ])
-
- keyboard.append([
- InlineKeyboardButton("◀️ Назад к настройкам", callback_data="settings_back")
- ])
-
- reply_markup = InlineKeyboardMarkup(keyboard)
- await query.edit_message_text(message_text, parse_mode='HTML', reply_markup=reply_markup)
-
- async def _handle_language(self, query, db_user):
- """Обработка настройки языка."""
- message_text = "🌐 Язык интерфейса\n\n"
- message_text += "Текущий: " + ('Русский' if db_user.language == 'ru' else 'English') + "\n\n"
- message_text += "Выберите язык:"
-
- keyboard = [
- [
- InlineKeyboardButton(
- "Русский" + (" ✅" if db_user.language == 'ru' else ""),
- callback_data="set_language_ru"
- ),
- InlineKeyboardButton(
- "English" + (" ✅" if db_user.language == 'en' else ""),
- callback_data="set_language_en"
- )
- ],
- [
- InlineKeyboardButton("◀️ Назад к настройкам", callback_data="settings_back")
- ]
- ]
-
- reply_markup = InlineKeyboardMarkup(keyboard)
- await query.edit_message_text(message_text, parse_mode='HTML', reply_markup=reply_markup)
-
- async def clients_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик команды /clients - список клиентов ментора."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User, Client
-
- try:
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- keyboard = get_main_keyboard(None)
- await update.message.reply_text(
- "❌ Аккаунт не связан.\n\n"
- "Используйте /link <код> для связывания аккаунта.",
- reply_markup=keyboard
- )
- return
-
- if db_user.role != 'mentor':
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Эта команда доступна только для менторов.",
- reply_markup=keyboard
- )
- return
-
- # Получаем клиентов ментора
- clients = await sync_to_async(list)(
- Client.objects.filter(mentors=db_user).select_related('user')[:10]
- )
-
- if not clients:
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "👥 У вас пока нет клиентов.\n\n"
- "Клиенты появятся здесь после того, как они запишутся на ваши занятия.",
- reply_markup=keyboard
- )
- return
-
- message = "👥 Ваши клиенты:\n\n"
-
- from apps.schedule.models import Lesson
- from apps.homework.models import HomeworkSubmission
- from django.utils import timezone
-
- inline_keyboard = []
- for client in clients:
- client_name = client.user.get_full_name() or client.user.email
- message += f"👤 {client_name}\n"
-
- # Статистика занятий
- lessons = await sync_to_async(list)(
- Lesson.objects.filter(
- mentor=db_user,
- client=client
- )
- )
- total_lessons = len(lessons)
- completed_lessons = len([l for l in lessons if l.status == 'completed'])
- upcoming_lessons = len([
- l for l in lessons
- if l.start_time >= timezone.now() and l.status == 'scheduled'
- ])
-
- message += f"📚 Занятий: {total_lessons} (завершено: {completed_lessons}, предстоящих: {upcoming_lessons})\n"
-
- # Статистика ДЗ
- submissions = await sync_to_async(list)(
- HomeworkSubmission.objects.filter(
- homework__mentor=db_user,
- student=client.user
- )
- )
- total_homeworks = len(submissions)
- graded_homeworks = len([s for s in submissions if s.status == 'graded'])
- if graded_homeworks > 0:
- scores = [s.score for s in submissions if s.score is not None]
- avg_score = sum(scores) / len(scores) if scores else 0
- message += f"📝 ДЗ: {total_homeworks} (проверено: {graded_homeworks}, средний балл: {avg_score:.1f})\n"
- else:
- message += f"📝 ДЗ: {total_homeworks}\n"
-
- message += "\n"
-
- # Добавляем кнопку для просмотра деталей клиента
- inline_keyboard.append([
- InlineKeyboardButton(
- f"👤 {client_name[:30]}",
- callback_data=f"client_detail_{client.id}"
- )
- ])
-
- reply_markup = InlineKeyboardMarkup(inline_keyboard) if inline_keyboard else None
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(message, parse_mode='HTML', reply_markup=reply_markup)
-
- except Exception as e:
- logger.error(f"Error in clients_command: {e}", exc_info=True)
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Ошибка получения списка клиентов.\n\n"
- "Попробуйте позже или обратитесь в поддержку.",
- reply_markup=keyboard
- )
-
- async def stats_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик команды /stats - статистика для ментора."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User, Client
-
- try:
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- keyboard = get_main_keyboard(None)
- await update.message.reply_text(
- "❌ Аккаунт не связан.\n\n"
- "Используйте /link <код> для связывания аккаунта.",
- reply_markup=keyboard
- )
- return
-
- if db_user.role != 'mentor':
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Эта команда доступна только для менторов.",
- reply_markup=keyboard
- )
- return
-
- from apps.schedule.models import Lesson
- from apps.homework.models import Homework, HomeworkSubmission
- from django.utils import timezone
- from datetime import timedelta
-
- now = timezone.now()
- month_ago = now - timedelta(days=30)
-
- # Общая статистика
- total_clients = await sync_to_async(Client.objects.filter(mentors=db_user).count)()
-
- # Занятия
- all_lessons = await sync_to_async(list)(
- Lesson.objects.filter(mentor=db_user)
- )
- total_lessons = len(all_lessons)
- completed_lessons = len([l for l in all_lessons if l.status == 'completed'])
- upcoming_lessons = len([
- l for l in all_lessons
- if l.start_time >= now and l.status == 'scheduled'
- ])
- lessons_this_month = len([
- l for l in all_lessons
- if l.start_time >= month_ago
- ])
-
- # Домашние задания
- all_submissions = await sync_to_async(list)(
- HomeworkSubmission.objects.filter(homework__mentor=db_user)
- )
- total_homeworks = len(all_submissions)
- pending_homeworks = len([s for s in all_submissions if s.status == 'pending'])
- graded_homeworks = len([s for s in all_submissions if s.status == 'graded'])
-
- if graded_homeworks > 0:
- scores = [s.score for s in all_submissions if s.score is not None]
- avg_score = sum(scores) / len(scores) if scores else 0
- else:
- avg_score = 0
-
- message = "📊 Ваша статистика:\n\n"
- message += f"👥 Клиентов: {total_clients}\n\n"
- message += f"📚 Занятия:\n"
- message += f"• Всего: {total_lessons}\n"
- message += f"• Завершено: {completed_lessons}\n"
- message += f"• Предстоящих: {upcoming_lessons}\n"
- message += f"• За месяц: {lessons_this_month}\n\n"
- message += f"📝 Домашние задания:\n"
- message += f"• Всего решений: {total_homeworks}\n"
- message += f"• На проверке: {pending_homeworks}\n"
- message += f"• Проверено: {graded_homeworks}\n"
- if avg_score > 0:
- message += f"• Средний балл: {avg_score:.1f}\n"
-
- # Доходы (если есть занятия с ценой)
- lessons_with_price = [l for l in all_lessons if l.price and l.status == 'completed']
- if lessons_with_price:
- total_revenue = sum([float(l.price) for l in lessons_with_price])
- avg_price = total_revenue / len(lessons_with_price) if lessons_with_price else 0
- revenue_this_month = sum([
- float(l.price) for l in lessons_with_price
- if l.start_time >= month_ago
- ])
-
- message += f"\n💰 Доходы:\n"
- message += f"• Всего: {total_revenue:.2f} ₽\n"
- message += f"• Средняя цена: {avg_price:.2f} ₽\n"
- message += f"• За месяц: {revenue_this_month:.2f} ₽\n"
-
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(message, parse_mode='HTML', reply_markup=keyboard)
-
- except Exception as e:
- logger.error(f"Error in stats_command: {e}", exc_info=True)
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Ошибка получения статистики.\n\n"
- "Попробуйте позже или обратитесь в поддержку.",
- reply_markup=keyboard
- )
-
- async def progress_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик команды /progress - прогресс обучения для клиента."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User, Client
-
- try:
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- keyboard = get_main_keyboard(None)
- await update.message.reply_text(
- "❌ Аккаунт не связан.\n\n"
- "Используйте /link <код> для связывания аккаунта.",
- reply_markup=keyboard
- )
- return
-
- if db_user.role != 'client':
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Эта команда доступна только для клиентов.",
- reply_markup=keyboard
- )
- return
-
- from apps.schedule.models import Lesson
- from apps.homework.models import HomeworkSubmission
- from django.utils import timezone
- from datetime import timedelta
-
- now = timezone.now()
- month_ago = now - timedelta(days=30)
-
- # Занятия за последний месяц
- lessons = await sync_to_async(list)(
- Lesson.objects.filter(
- client__user=db_user,
- start_time__gte=month_ago
- )
- )
-
- total_lessons = len(lessons)
- completed_lessons = len([l for l in lessons if l.status == 'completed'])
- completion_rate = (completed_lessons / total_lessons * 100) if total_lessons > 0 else 0
-
- # Домашние задания за последний месяц
- submissions = await sync_to_async(list)(
- HomeworkSubmission.objects.filter(
- student=db_user,
- submitted_at__gte=month_ago
- )
- )
-
- graded_submissions = [s for s in submissions if s.status == 'graded']
- passed_submissions = len([s for s in graded_submissions if s.passed])
- total_graded = len(graded_submissions)
- pass_rate = (passed_submissions / total_graded * 100) if total_graded > 0 else 0
-
- # Средний балл
- if graded_submissions:
- scores = [s.score for s in graded_submissions if s.score is not None]
- avg_score = sum(scores) / len(scores) if scores else 0
- else:
- avg_score = 0
-
- # Всего занятий (все время)
- all_lessons = await sync_to_async(list)(
- Lesson.objects.filter(client__user=db_user)
- )
- total_all_lessons = len(all_lessons)
- completed_all_lessons = len([l for l in all_lessons if l.status == 'completed'])
-
- # Всего ДЗ (все время)
- all_submissions = await sync_to_async(list)(
- HomeworkSubmission.objects.filter(student=db_user)
- )
- total_all_homeworks = len(all_submissions)
- graded_all_homeworks = len([s for s in all_submissions if s.status == 'graded'])
-
- message = "📊 Ваш прогресс:\n\n"
- message += f"📅 За последний месяц:\n"
- message += f"• Занятий: {total_lessons} (завершено: {completed_lessons})\n"
- message += f"• Процент завершения: {completion_rate:.1f}%\n"
- message += f"• ДЗ сдано: {len(submissions)} (проверено: {total_graded})\n"
- if total_graded > 0:
- message += f"• Процент сдачи: {pass_rate:.1f}%\n"
- message += f"• Средний балл: {avg_score:.1f}\n"
-
- message += f"\n📈 Всего:\n"
- message += f"• Занятий: {total_all_lessons} (завершено: {completed_all_lessons})\n"
- message += f"• ДЗ сдано: {total_all_homeworks} (проверено: {graded_all_homeworks})\n"
-
- # Топ предметы
- if completed_lessons > 0:
- from collections import Counter
- subjects = [l.subject.name if l.subject else 'Без предмета' for l in lessons if l.status == 'completed' and l.subject]
- if subjects:
- subject_counts = Counter(subjects)
- top_subjects = subject_counts.most_common(3)
- message += f"\n📚 Топ предметы (месяц):\n"
- for subject, count in top_subjects:
- message += f"• {subject}: {count} занятий\n"
-
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(message, parse_mode='HTML', reply_markup=keyboard)
-
- except Exception as e:
- logger.error(f"Error in progress_command: {e}", exc_info=True)
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Ошибка получения прогресса.\n\n"
- "Попробуйте позже или обратитесь в поддержку.",
- reply_markup=keyboard
- )
-
- async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик обычных сообщений и кнопок."""
- user = update.effective_user
- telegram_id = user.id
- message_text = update.message.text
-
- from apps.users.models import User
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- # Проверяем, ожидается ли загрузка решения для ДЗ
- if db_user:
- homework_id = context.user_data.get('waiting_for_homework_file')
- if homework_id:
- # Если пользователь отправил текст "отмена", отменяем загрузку
- if message_text and message_text.lower() in ['отмена', 'cancel', '/cancel']:
- context.user_data.pop('waiting_for_homework_file', None)
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "❌ Загрузка решения отменена.",
- reply_markup=keyboard
- )
- return
- else:
- # Обрабатываем текстовое решение
- await self._handle_text_homework_submission(update, context, db_user, homework_id, message_text)
- return
-
- # Обработка нажатий на кнопки
- if message_text == "📅 Расписание" or message_text == "📅 Моё расписание" or message_text == "📅 Расписание детей":
- await self.schedule_command(update, context)
- elif message_text == "📚 Следующее занятие":
- await self.nextlesson_command(update, context)
- elif message_text == "📝 Домашние задания" or message_text == "📝 Мои задания" or message_text == "📝 Задания детей":
- await self.homework_command(update, context)
- elif message_text == "📊 Мой прогресс":
- await self.progress_command(update, context)
- elif message_text == "👥 Клиенты":
- await self.clients_command(update, context)
- elif message_text == "📊 Статистика":
- await self.stats_command(update, context)
- elif message_text == "⚙️ Настройки":
- await self.settings_command(update, context)
- elif message_text == "ℹ️ Статус":
- await self.status_command(update, context)
- elif message_text == "❓ Помощь":
- await self.help_command(update, context)
- elif message_text == "🔗 Связать аккаунт":
- await update.message.reply_text(
- "🔗 Связывание аккаунта\n\n"
- "1. Войдите на платформу\n"
- "2. Перейдите в Профиль → Настройки → Telegram\n"
- "3. Нажмите \"Связать Telegram\"\n"
- "4. Отправьте команду: /link ВАШ_КОД\n\n"
- "Или используйте команду:\n"
- "/link <ваш_код_связывания>",
- parse_mode='HTML'
- )
- else:
- # Если аккаунт связан, показываем клавиатуру
- if db_user:
- keyboard = get_main_keyboard(db_user.role)
- await update.message.reply_text(
- "Используйте кнопки или команды для работы с ботом.\n\n"
- "Введите /help для списка команд.",
- reply_markup=keyboard
- )
- else:
- keyboard = get_main_keyboard(None)
- await update.message.reply_text(
- "Используйте кнопки или команды для работы с ботом.\n\n"
- "Введите /help для списка команд.",
- reply_markup=keyboard
- )
-
- async def handle_document(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик загрузки документов (для решений ДЗ)."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- await update.message.reply_text(
- "❌ Аккаунт не связан.\n\n"
- "Используйте /link <код> для связывания аккаунта."
- )
- return
-
- # Проверяем, ожидается ли загрузка файла для ДЗ
- homework_id = context.user_data.get('waiting_for_homework_file')
- if not homework_id:
- await update.message.reply_text(
- "📎 Чтобы загрузить файл для решения ДЗ:\n\n"
- "1. Используйте команду /homework\n"
- "2. Выберите задание\n"
- "3. Нажмите кнопку '📎 Загрузить решение'"
- )
- return
-
- # Получаем файл
- document = update.message.document
- if not document:
- await update.message.reply_text("❌ Файл не найден.")
- return
-
- try:
- # Скачиваем файл из Telegram
- from telegram import Bot
- bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
- file = await bot.get_file(document.file_id)
-
- # Получаем домашнее задание для проверок
- from apps.homework.models import Homework, HomeworkSubmission
- homework = await sync_to_async(Homework.objects.get)(id=homework_id)
-
- # Безопасность: Проверка размера файла (максимум 50MB для Telegram, но проверяем и настройки задания)
- MAX_TELEGRAM_FILE_SIZE = 50 * 1024 * 1024 # 50 MB - максимум для Telegram
- file_size = document.file_size or 0
-
- if file_size > MAX_TELEGRAM_FILE_SIZE:
- await update.message.reply_text(
- f"❌ Файл слишком большой. Максимальный размер: {MAX_TELEGRAM_FILE_SIZE / (1024*1024):.0f} MB"
- )
- await bot.close()
- return
-
- # Проверяем размер файла согласно настройкам задания
- if homework.max_file_size > 0 and file_size > homework.max_file_size:
- max_size_mb = homework.max_file_size / (1024 * 1024)
- await update.message.reply_text(
- f"❌ Файл слишком большой. Максимальный размер для этого задания: {max_size_mb:.1f} MB"
- )
- await bot.close()
- return
-
- # Безопасность: Проверка типа файла
- from apps.homework.utils import validate_file_type, sanitize_filename
- safe_filename = sanitize_filename(document.file_name or 'file')
-
- if homework.allowed_file_types and not validate_file_type(safe_filename, homework.allowed_file_types):
- allowed = homework.allowed_file_types.replace(',', ', ')
- await update.message.reply_text(
- f"❌ Тип файла не разрешен.\n\n"
- f"Разрешенные типы: {allowed}"
- )
- await bot.close()
- return
-
- # Создаем временный файл
- import tempfile
- import os
- from django.core.files import File
-
- file_ext = os.path.splitext(safe_filename)[1]
- tmp_file_path = None
- try:
- with tempfile.NamedTemporaryFile(delete=False, suffix=file_ext) as tmp_file:
- tmp_file_path = tmp_file.name
- await file.download_to_drive(tmp_file_path)
-
- # Проверяем, что пользователь имеет право сдавать это ДЗ
- if db_user.role != 'client' or db_user not in await sync_to_async(list)(homework.assigned_to.all()):
- await update.message.reply_text(
- "❌ У вас нет доступа к этому заданию."
- )
- await bot.close()
- return
-
- # Проверяем, есть ли уже решение
- existing_submission = await sync_to_async(
- HomeworkSubmission.objects.filter(
- homework=homework,
- student=db_user
- ).order_by('-attempt_number').first
- )()
-
- if existing_submission and existing_submission.status != 'returned':
- # Обновляем существующее решение
- with open(tmp_file_path, 'rb') as f:
- django_file = File(f, name=safe_filename)
- existing_submission.attachment = django_file
- existing_submission.content = f"Решение загружено через Telegram: {safe_filename}"
- existing_submission.status = 'pending'
- await sync_to_async(existing_submission.save)()
-
- await update.message.reply_text(
- f"✅ Решение обновлено!\n\n"
- f"📎 Файл: {safe_filename}\n"
- f"📝 Задание: {homework.title}\n\n"
- f"Решение отправлено на проверку.",
- parse_mode='HTML'
- )
- else:
- # Определяем номер попытки
- attempt_number = 1
- if existing_submission:
- attempt_number = existing_submission.attempt_number + 1
-
- # Создаем новое решение
- with open(tmp_file_path, 'rb') as f:
- django_file = File(f, name=safe_filename)
- submission = HomeworkSubmission(
- homework=homework,
- student=db_user,
- content=f"Решение загружено через Telegram: {safe_filename}",
- attachment=django_file,
- status='pending',
- attempt_number=attempt_number
- )
- await sync_to_async(submission.save)()
-
- # Проверяем опоздание
- await sync_to_async(submission.check_if_late)()
-
- # Обновляем статистику задания
- await sync_to_async(homework.update_statistics)()
-
- await update.message.reply_text(
- f"✅ Решение загружено!\n\n"
- f"📎 Файл: {safe_filename}\n"
- f"📝 Задание: {homework.title}\n\n"
- f"Решение отправлено на проверку.",
- parse_mode='HTML'
- )
-
- # Отправляем уведомление ментору
- from apps.notifications.services import NotificationService
- await sync_to_async(NotificationService.create_notification_with_telegram)(
- recipient=homework.mentor,
- notification_type='homework_submitted',
- title='📝 ДЗ сдано',
- message=f'{db_user.get_full_name()} сдал ДЗ "{homework.title}"',
- priority='normal',
- action_url=f'/homework/{homework.id}/',
- content_object=homework
- )
-
- # Очищаем состояние ожидания
- context.user_data.pop('waiting_for_homework_file', None)
-
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "Используйте кнопки для навигации:",
- reply_markup=keyboard
- )
- finally:
- # Гарантированно удаляем временный файл
- if tmp_file_path and os.path.exists(tmp_file_path):
- try:
- os.unlink(tmp_file_path)
- except Exception as e:
- logger.error(f"Error deleting temp file {tmp_file_path}: {e}")
-
- await bot.close()
-
- except Homework.DoesNotExist:
- await update.message.reply_text(
- "❌ Домашнее задание не найдено."
- )
- context.user_data.pop('waiting_for_homework_file', None)
- except Exception as e:
- logger.error(f"Error handling document upload: {e}", exc_info=True)
- await update.message.reply_text(
- "❌ Ошибка загрузки файла.\n\n"
- "Попробуйте позже или обратитесь в поддержку."
- )
- context.user_data.pop('waiting_for_homework_file', None)
-
- async def handle_photo(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
- """Обработчик загрузки фото (для решений ДЗ)."""
- user = update.effective_user
- telegram_id = user.id
-
- from apps.users.models import User
- db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
-
- if not db_user:
- await update.message.reply_text(
- "❌ Аккаунт не связан.\n\n"
- "Используйте /link <код> для связывания аккаунта."
- )
- return
-
- # Проверяем, ожидается ли загрузка файла для ДЗ
- homework_id = context.user_data.get('waiting_for_homework_file')
- if not homework_id:
- await update.message.reply_text(
- "📎 Чтобы загрузить фото для решения ДЗ:\n\n"
- "1. Используйте команду /homework\n"
- "2. Выберите задание\n"
- "3. Нажмите кнопку '📎 Загрузить решение'"
- )
- return
-
- # Получаем фото (берем самое большое)
- photos = update.message.photo
- if not photos:
- await update.message.reply_text("❌ Фото не найдено.")
- return
-
- photo = photos[-1] # Самое большое фото
-
- try:
- # Скачиваем фото из Telegram
- from telegram import Bot
- bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
- file = await bot.get_file(photo.file_id)
-
- # Получаем домашнее задание для проверок
- from apps.homework.models import Homework, HomeworkSubmission
- homework = await sync_to_async(Homework.objects.get)(id=homework_id)
-
- # Безопасность: Проверка размера файла (максимум 50MB для Telegram)
- MAX_TELEGRAM_FILE_SIZE = 50 * 1024 * 1024 # 50 MB - максимум для Telegram
- file_size = photo.file_size or 0
-
- if file_size > MAX_TELEGRAM_FILE_SIZE:
- await update.message.reply_text(
- f"❌ Фото слишком большое. Максимальный размер: {MAX_TELEGRAM_FILE_SIZE / (1024*1024):.0f} MB"
- )
- await bot.close()
- return
-
- # Проверяем размер файла согласно настройкам задания
- if homework.max_file_size > 0 and file_size > homework.max_file_size:
- max_size_mb = homework.max_file_size / (1024 * 1024)
- await update.message.reply_text(
- f"❌ Фото слишком большое. Максимальный размер для этого задания: {max_size_mb:.1f} MB"
- )
- await bot.close()
- return
-
- # Безопасность: Проверка типа файла (фото должно быть разрешено)
- from apps.homework.utils import validate_file_type, sanitize_filename
- if homework.allowed_file_types and not validate_file_type('photo.jpg', homework.allowed_file_types):
- allowed = homework.allowed_file_types.replace(',', ', ')
- await update.message.reply_text(
- f"❌ Фото не разрешено для этого задания.\n\n"
- f"Разрешенные типы: {allowed}"
- )
- await bot.close()
- return
-
- # Создаем временный файл
- import tempfile
- import os
- from django.core.files import File
-
- tmp_file_path = None
- try:
- with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_file:
- tmp_file_path = tmp_file.name
- await file.download_to_drive(tmp_file_path)
-
- # Проверяем, что пользователь имеет право сдавать это ДЗ
- if db_user.role != 'client' or db_user not in await sync_to_async(list)(homework.assigned_to.all()):
- await update.message.reply_text(
- "❌ У вас нет доступа к этому заданию."
- )
- await bot.close()
- return
-
- from django.utils import timezone as tz
- from apps.homework.utils import sanitize_filename
- filename = sanitize_filename(f"photo_{homework.id}_{tz.now().strftime('%Y%m%d_%H%M%S')}.jpg")
-
- # Проверяем, есть ли уже решение
- existing_submission = await sync_to_async(
- HomeworkSubmission.objects.filter(
- homework=homework,
- student=db_user
- ).order_by('-attempt_number').first
- )()
-
- if existing_submission and existing_submission.status != 'returned':
- # Обновляем существующее решение
- with open(tmp_file_path, 'rb') as f:
- django_file = File(f, name=filename)
- existing_submission.attachment = django_file
- existing_submission.content = f"Решение загружено через Telegram (фото)"
- existing_submission.status = 'pending'
- await sync_to_async(existing_submission.save)()
-
- await update.message.reply_text(
- f"✅ Решение обновлено!\n\n"
- f"📎 Фото загружено\n"
- f"📝 Задание: {homework.title}\n\n"
- f"Решение отправлено на проверку.",
- parse_mode='HTML'
- )
- else:
- # Определяем номер попытки
- attempt_number = 1
- if existing_submission:
- attempt_number = existing_submission.attempt_number + 1
-
- # Создаем новое решение
- with open(tmp_file_path, 'rb') as f:
- django_file = File(f, name=filename)
- submission = HomeworkSubmission(
- homework=homework,
- student=db_user,
- content="Решение загружено через Telegram (фото)",
- attachment=django_file,
- status='pending',
- attempt_number=attempt_number
- )
- await sync_to_async(submission.save)()
-
- # Проверяем опоздание
- await sync_to_async(submission.check_if_late)()
-
- # Обновляем статистику задания
- await sync_to_async(homework.update_statistics)()
-
- await update.message.reply_text(
- f"✅ Решение загружено!\n\n"
- f"📎 Фото загружено\n"
- f"📝 Задание: {homework.title}\n\n"
- f"Решение отправлено на проверку.",
- parse_mode='HTML'
- )
-
- # Отправляем уведомление ментору
- from apps.notifications.services import NotificationService
- await sync_to_async(NotificationService.create_notification_with_telegram)(
- recipient=homework.mentor,
- notification_type='homework_submitted',
- title='📝 ДЗ сдано',
- message=f'{db_user.get_full_name()} сдал ДЗ "{homework.title}"',
- priority='normal',
- action_url=f'/homework/{homework.id}/',
- content_object=homework
- )
-
- # Очищаем состояние ожидания
- context.user_data.pop('waiting_for_homework_file', None)
-
- keyboard = await get_user_keyboard(telegram_id)
- await update.message.reply_text(
- "Используйте кнопки для навигации:",
- reply_markup=keyboard
- )
- finally:
- # Гарантированно удаляем временный файл
- if tmp_file_path and os.path.exists(tmp_file_path):
- try:
- os.unlink(tmp_file_path)
- except Exception as e:
- logger.error(f"Error deleting temp file {tmp_file_path}: {e}")
-
- await bot.close()
-
- except Homework.DoesNotExist:
- await update.message.reply_text(
- "❌ Домашнее задание не найдено."
- )
- context.user_data.pop('waiting_for_homework_file', None)
- except Exception as e:
- logger.error(f"Error handling photo upload: {e}", exc_info=True)
- await update.message.reply_text(
- "❌ Ошибка загрузки фото.\n\n"
- "Попробуйте позже или обратитесь в поддержку."
- )
- context.user_data.pop('waiting_for_homework_file', None)
-
- def setup_handlers(self):
- """Настройка обработчиков команд."""
- if not self.application:
- return
-
- # Команды
- self.application.add_handler(CommandHandler("start", self.start_command))
- self.application.add_handler(CommandHandler("help", self.help_command))
- self.application.add_handler(CommandHandler("link", self.link_command))
- self.application.add_handler(CommandHandler("unlink", self.unlink_command))
- self.application.add_handler(CommandHandler("status", self.status_command))
- self.application.add_handler(CommandHandler("settings", self.settings_command))
-
- # Команды для клиентов и менторов
- self.application.add_handler(CommandHandler("schedule", self.schedule_command))
- self.application.add_handler(CommandHandler("nextlesson", self.nextlesson_command))
- self.application.add_handler(CommandHandler("homework", self.homework_command))
-
- # Команды для клиентов
- self.application.add_handler(CommandHandler("progress", self.progress_command))
-
- # Команды для менторов
- self.application.add_handler(CommandHandler("clients", self.clients_command))
- self.application.add_handler(CommandHandler("stats", self.stats_command))
-
- # Кнопки
- self.application.add_handler(CallbackQueryHandler(self.button_callback))
-
- # Обычные сообщения
- self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_message))
-
- # Загрузка документов (для решений ДЗ)
- self.application.add_handler(MessageHandler(filters.Document.ALL, self.handle_document))
-
- # Загрузка фото (для решений ДЗ)
- self.application.add_handler(MessageHandler(filters.PHOTO, self.handle_photo))
-
- async def start(self):
- """Запуск бота."""
- if not self.token:
- logger.error("TELEGRAM_BOT_TOKEN not set")
- return
-
- logger.info("Starting Telegram bot...")
-
- # Создаем приложение
- self.application = Application.builder().token(self.token).build()
-
- # Настраиваем обработчики
- self.setup_handlers()
-
- # Запускаем бота
- await self.application.initialize()
- await self.application.start()
-
- if self.use_webhook and self.webhook_url:
- # Используем webhook режим
- logger.info(f"Setting up webhook: {self.webhook_url}")
- await self.setup_webhook()
- else:
- # Используем polling режим
- logger.info("Starting bot in polling mode...")
- await self.application.updater.start_polling()
-
- logger.info("Telegram bot started successfully")
-
- async def setup_webhook(self):
- """Настройка webhook для бота."""
- from telegram import Bot
- from telegram.error import TelegramError
-
- try:
- bot = Bot(token=self.token)
-
- # Устанавливаем webhook
- webhook_kwargs = {
- 'url': self.webhook_url,
- 'allowed_updates': ['message', 'callback_query', 'inline_query', 'chosen_inline_result'],
- }
-
- # Добавляем secret token если указан
- if self.webhook_secret_token:
- webhook_kwargs['secret_token'] = self.webhook_secret_token
-
- await bot.set_webhook(**webhook_kwargs)
-
- # Проверяем информацию о webhook
- webhook_info = await bot.get_webhook_info()
- logger.info(f"Webhook info: {webhook_info.url}, pending updates: {webhook_info.pending_update_count}")
-
- await bot.close()
- logger.info("Webhook set successfully")
- except TelegramError as e:
- logger.error(f"Error setting webhook: {e}")
- raise
- except Exception as e:
- logger.error(f"Unexpected error setting webhook: {e}")
- raise
-
- async def remove_webhook(self):
- """Удаление webhook."""
- from telegram import Bot
- from telegram.error import TelegramError
-
- try:
- bot = Bot(token=self.token)
- await bot.delete_webhook(drop_pending_updates=True)
- await bot.close()
- logger.info("Webhook removed successfully")
- except TelegramError as e:
- logger.error(f"Error removing webhook: {e}")
- raise
- except Exception as e:
- logger.error(f"Unexpected error removing webhook: {e}")
- raise
-
- async def stop(self):
- """Остановка бота."""
- if self.application:
- logger.info("Stopping Telegram bot...")
- if not self.use_webhook:
- # Останавливаем polling только если не используем webhook
- await self.application.updater.stop()
- await self.application.stop()
- await self.application.shutdown()
- logger.info("Telegram bot stopped")
-
- def get_application(self):
- """
- Получить экземпляр Application для обработки webhook.
-
- ВАЖНО: Этот метод создает приложение, но не инициализирует его.
- Инициализация происходит в process_webhook_update при первом запросе.
- """
- if not self.application:
- if not self.token:
- logger.error("TELEGRAM_BOT_TOKEN not set")
- return None
-
- # Создаем приложение
- self.application = Application.builder().token(self.token).build()
-
- # Настраиваем обработчики
- self.setup_handlers()
-
- logger.info("Bot application created for webhook")
-
- return self.application
-
- async def process_webhook_update(self, update: Update):
- """
- Обработать update от webhook.
-
- Args:
- update: Update объект от Telegram
- """
- # Получаем или создаем приложение
- if not self.application:
- self.get_application()
-
- if not self.application:
- logger.error("Failed to initialize bot application")
- return False
-
- try:
- # Убеждаемся что приложение инициализировано и запущено
- # Для webhook режима мы не используем updater, только application
- if not hasattr(self.application, '_webhook_initialized'):
- await self.application.initialize()
- await self.application.start()
- self.application._webhook_initialized = True
- logger.info("Bot application initialized for webhook")
-
- # Обрабатываем update
- await self.application.process_update(update)
- return True
- except Exception as e:
- logger.error(f"Error processing webhook update: {e}", exc_info=True)
- return False
-
-
-# Глобальный экземпляр бота
-bot_instance = None
-
-
-async def get_bot():
- """Получить экземпляр бота."""
- global bot_instance
-
- if bot_instance is None:
- bot_instance = TelegramBot()
- await bot_instance.start()
-
- return bot_instance
-
-
-async def send_telegram_message(telegram_id: int, message: str, parse_mode: str = 'HTML'):
- """
- Отправить сообщение в Telegram.
-
- Args:
- telegram_id: ID пользователя в Telegram
- message: Текст сообщения
- parse_mode: Режим парсинга (HTML, Markdown)
- """
- from telegram import Bot
- from telegram.error import TelegramError
-
- if not settings.TELEGRAM_BOT_TOKEN:
- logger.error("TELEGRAM_BOT_TOKEN not set")
- return False
-
- try:
- # Создаем временный экземпляр бота для отправки сообщения
- bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
- await bot.send_message(
- chat_id=telegram_id,
- text=message,
- parse_mode=parse_mode
- )
- await bot.close()
- logger.info(f"Telegram message sent to {telegram_id}")
- return True
- except TelegramError as e:
- logger.error(f"Telegram API error sending message to {telegram_id}: {e}")
- return False
- except Exception as e:
- logger.error(f"Error sending Telegram message to {telegram_id}: {e}")
- return False
-
-
-async def send_telegram_message_with_buttons(telegram_id: int, message: str, reply_markup, parse_mode: str = 'HTML'):
- """
- Отправить сообщение в Telegram с кнопками.
-
- Args:
- telegram_id: ID пользователя в Telegram
- message: Текст сообщения
- reply_markup: InlineKeyboardMarkup с кнопками
- parse_mode: Режим парсинга (HTML, Markdown)
- """
- from telegram import Bot
- from telegram.error import TelegramError
-
- if not settings.TELEGRAM_BOT_TOKEN:
- logger.error("TELEGRAM_BOT_TOKEN not set")
- return False
-
- try:
- bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
- await bot.send_message(
- chat_id=telegram_id,
- text=message,
- parse_mode=parse_mode,
- reply_markup=reply_markup
- )
- await bot.close()
- logger.info(f"Telegram message with buttons sent to {telegram_id}")
- return True
- except TelegramError as e:
- logger.error(f"Telegram API error sending message with buttons to {telegram_id}: {e}")
- return False
- except Exception as e:
- logger.error(f"Error sending Telegram message with buttons to {telegram_id}: {e}")
- return False
-
+"""
+Telegram бот для уведомлений и интеграции.
+"""
+import logging
+from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardMarkup, KeyboardButton
+from telegram.ext import (
+ Application,
+ CommandHandler,
+ CallbackQueryHandler,
+ MessageHandler,
+ filters,
+ ContextTypes,
+)
+from django.conf import settings
+from django.utils import timezone
+from asgiref.sync import sync_to_async
+
+logger = logging.getLogger(__name__)
+
+
+def get_main_keyboard(role=None):
+ """
+ Получить основную клавиатуру в зависимости от роли.
+
+ Args:
+ role: Роль пользователя ('mentor', 'client', 'parent', None)
+
+ Returns:
+ ReplyKeyboardMarkup: Клавиатура с кнопками
+ """
+ if role == 'mentor':
+ keyboard = [
+ [KeyboardButton("📅 Расписание"), KeyboardButton("📚 Следующее занятие")],
+ [KeyboardButton("📝 Домашние задания"), KeyboardButton("👥 Клиенты")],
+ [KeyboardButton("📊 Статистика"), KeyboardButton("⚙️ Настройки")],
+ [KeyboardButton("ℹ️ Статус"), KeyboardButton("❓ Помощь")]
+ ]
+ elif role == 'client':
+ keyboard = [
+ [KeyboardButton("📅 Моё расписание"), KeyboardButton("📚 Следующее занятие")],
+ [KeyboardButton("📝 Мои задания"), KeyboardButton("📊 Мой прогресс")],
+ [KeyboardButton("⚙️ Настройки"), KeyboardButton("❓ Помощь")]
+ ]
+ elif role == 'parent':
+ keyboard = [
+ [KeyboardButton("📅 Расписание детей"), KeyboardButton("📚 Следующее занятие")],
+ [KeyboardButton("📝 Задания детей"), KeyboardButton("⚙️ Настройки")],
+ [KeyboardButton("ℹ️ Статус"), KeyboardButton("❓ Помощь")]
+ ]
+ else:
+ # Для не связанных пользователей
+ keyboard = [
+ [KeyboardButton("🔗 Связать аккаунт"), KeyboardButton("❓ Помощь")]
+ ]
+
+ return ReplyKeyboardMarkup(keyboard, resize_keyboard=True, one_time_keyboard=False)
+
+
+async def get_user_keyboard(telegram_id):
+ """
+ Получить клавиатуру для пользователя по его telegram_id.
+
+ Args:
+ telegram_id: ID пользователя в Telegram
+
+ Returns:
+ ReplyKeyboardMarkup: Клавиатура с кнопками
+ """
+ from apps.users.models import User
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+ role = db_user.role if db_user else None
+ return get_main_keyboard(role)
+
+
+# Текст кнопки «Назад» в панели списка заданий
+HOMEWORK_LIST_BACK_BUTTON = "◀️ Назад в меню"
+
+# Кнопки для «Мой прогресс»
+PROGRESS_BACK_TO_MENU = "◀️ В меню"
+PROGRESS_PERIOD_WEEK = "📅 За текущую неделю"
+PROGRESS_PERIOD_30 = "📅 За последние 30 дней"
+PROGRESS_PERIOD_ALL = "📅 За всё время"
+PROGRESS_BACK_SUBJECT = "◀️ К выбору предмета"
+
+
+def make_homework_list_keyboard(homeworks):
+ """
+ Собрать клавиатуру панели со списком заданий (кнопки в панели Telegram).
+
+ Args:
+ homeworks: список объектов Homework
+
+ Returns:
+ (ReplyKeyboardMarkup, dict): клавиатура и маппинг текст_кнопки -> homework_id
+ """
+ mapping = {}
+ rows = []
+ for idx, hw in enumerate(homeworks, 1):
+ btn_text = f"📝 Задание {idx}"
+ mapping[btn_text] = hw.id
+ rows.append([KeyboardButton(btn_text)])
+ rows.append([KeyboardButton(HOMEWORK_LIST_BACK_BUTTON)])
+ keyboard = ReplyKeyboardMarkup(rows, resize_keyboard=True, one_time_keyboard=False)
+ return keyboard, mapping
+
+
+class TelegramBot:
+ """Класс для управления Telegram ботом."""
+
+ def __init__(self):
+ """Инициализация бота."""
+ self.token = settings.TELEGRAM_BOT_TOKEN
+ self.application = None
+ self.use_webhook = getattr(settings, 'TELEGRAM_USE_WEBHOOK', False)
+ self.webhook_url = getattr(settings, 'TELEGRAM_WEBHOOK_URL', None)
+ self.webhook_secret_token = getattr(settings, 'TELEGRAM_WEBHOOK_SECRET_TOKEN', None)
+
+ async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """
+ Обработчик команды /start.
+ Приветствие и инструкции по связыванию аккаунта.
+ """
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+
+ # Проверяем связан ли аккаунт
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ # Если аккаунт не связан, показываем общее приветствие
+ welcome_message = f"""
+👋 Привет, {user.first_name}!
+
+Я бот образовательной платформы. Я буду присылать вам уведомления о:
+• Новых занятиях
+• Домашних заданиях
+• Сообщениях от ментора/ученика
+• Напоминаниях о занятиях
+
+📱 Чтобы связать ваш аккаунт:
+1. Войдите на платформу
+2. Перейдите в Профиль → Настройки → Telegram
+3. Нажмите "Связать Telegram"
+4. Введите код связывания
+
+Или используйте команду:
+/link <ваш_код_связывания>
+
+После связывания вы увидите команды, доступные для вашей роли.
+"""
+ else:
+ # Если аккаунт связан, показываем приветствие в зависимости от роли
+ role = db_user.role
+ user_name = db_user.get_full_name() or db_user.email
+
+ if role == 'mentor':
+ welcome_message = f"""
+👋 Привет, {user_name}!
+
+Я бот образовательной платформы для менторов.
+
+Я буду присылать вам уведомления о:
+• Новых запросах на занятия
+• Отменах занятий
+• Сданных домашних заданиях
+• Сообщениях от учеников
+• Напоминаниях о занятиях
+
+📚 Доступные команды:
+/help - Полный список команд
+/schedule - Расписание занятий
+/nextlesson - Следующее занятие
+/homework - Домашние задания на проверку
+/clients - Список клиентов
+/stats - Статистика
+/settings - Настройки уведомлений
+/status - Статус аккаунта
+"""
+ keyboard = get_main_keyboard('mentor')
+ await update.message.reply_text(
+ welcome_message,
+ reply_markup=keyboard
+ )
+ return
+ elif role == 'client':
+ welcome_message = f"""
+👋 Привет, {user_name}!
+
+Я бот образовательной платформы для учеников.
+
+Я буду присылать вам уведомления о:
+• Новых занятиях
+• Отменах занятий
+• Новых домашних заданиях
+• Проверенных домашних заданиях
+• Сообщениях от ментора
+• Напоминаниях о занятиях
+
+📚 Доступные команды:
+/help - Полный список команд
+/schedule - Моё расписание
+/nextlesson - Следующее занятие
+/homework - Мои домашние задания
+/progress - Мой прогресс обучения
+/settings - Настройки уведомлений
+/status - Статус аккаунта
+"""
+ keyboard = get_main_keyboard('client')
+ await update.message.reply_text(
+ welcome_message,
+ reply_markup=keyboard
+ )
+ return
+ elif role == 'parent':
+ welcome_message = f"""
+👋 Привет, {user_name}!
+
+Я бот образовательной платформы для родителей.
+
+Я буду присылать вам уведомления о:
+• Занятиях ваших детей
+• Домашних заданиях детей
+• Прогрессе обучения
+• Отчётах от менторов
+
+📚 Доступные команды:
+/help - Полный список команд
+/schedule - Расписание детей
+/nextlesson - Следующее занятие ребёнка
+/homework - Домашние задания детей
+/settings - Настройки уведомлений
+/status - Статус аккаунта
+"""
+ keyboard = get_main_keyboard('parent')
+ await update.message.reply_text(
+ welcome_message,
+ reply_markup=keyboard
+ )
+ return
+ else:
+ # Для других ролей (admin и т.д.)
+ welcome_message = f"""
+👋 Привет, {user_name}!
+
+Я бот образовательной платформы.
+
+Я буду присылать вам уведомления о событиях на платформе.
+
+📚 Доступные команды:
+/help - Полный список команд
+/settings - Настройки уведомлений
+/status - Статус аккаунта
+"""
+ keyboard = get_main_keyboard(None)
+ await update.message.reply_text(
+ welcome_message,
+ reply_markup=keyboard
+ )
+ return
+
+ # Если дошли сюда, отправляем без клавиатуры
+ await update.message.reply_text(welcome_message)
+
+ async def help_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик команды /help."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+
+ # Проверяем связан ли аккаунт
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ # Если аккаунт не связан, показываем общую справку
+ help_text = """
+🔔 Доступные команды:
+
+/start - Начать работу с ботом
+/link <код> - Связать аккаунт с платформой
+/help - Эта справка
+
+💡 Как связать аккаунт:
+1. Получите код связывания на платформе (Профиль → Telegram)
+2. Отправьте команду: /link ВАШ_КОД
+
+После связывания вы увидите команды, доступные для вашей роли.
+"""
+ else:
+ # Если аккаунт связан, показываем справку в зависимости от роли
+ role = db_user.role
+
+ if role == 'mentor':
+ help_text = """
+👨🏫 Справка для менторов:
+
+🔔 Основные команды:
+/start - Главное меню
+/help - Эта справка
+/status - Статус аккаунта
+/settings - Настройки уведомлений
+
+📚 Расписание:
+/schedule - Ближайшие занятия (до 5 занятий)
+/nextlesson - Следующее занятие
+
+📝 Домашние задания:
+/homework - Задания, требующие проверки
+
+👥 Клиенты:
+/clients - Список ваших клиентов со статистикой
+/stats - Ваша статистика (занятия, ДЗ, клиенты)
+
+🔗 Управление аккаунтом:
+/unlink - Отвязать Telegram аккаунт
+
+💡 Вы будете получать уведомления о:
+• Новых запросах на занятия
+• Отменах занятий
+• Сданных домашних заданиях
+• Сообщениях от учеников
+"""
+ elif role == 'client':
+ help_text = """
+👨🎓 Справка для учеников:
+
+🔔 Основные команды:
+/start - Главное меню
+/help - Эта справка
+/status - Статус аккаунта
+/settings - Настройки уведомлений
+
+📚 Расписание:
+/schedule - Моё расписание (до 5 ближайших занятий)
+/nextlesson - Следующее занятие
+
+📝 Домашние задания:
+/homework - Мои активные домашние задания
+
+📊 Прогресс:
+/progress - Мой прогресс обучения
+
+🔗 Управление аккаунтом:
+/unlink - Отвязать Telegram аккаунт
+
+💡 Вы будете получать уведомления о:
+• Новых занятиях
+• Отменах занятий
+• Новых домашних заданиях
+• Проверенных домашних заданиях
+• Сообщениях от ментора
+"""
+ elif role == 'parent':
+ help_text = """
+👨👩👧 Справка для родителей:
+
+🔔 Основные команды:
+/start - Главное меню
+/help - Эта справка
+/status - Статус аккаунта
+/settings - Настройки уведомлений
+
+📚 Расписание детей:
+/schedule - Расписание всех детей (до 5 ближайших занятий)
+/nextlesson - Следующее занятие ребёнка
+
+📝 Домашние задания:
+/homework - Домашние задания детей
+
+🔗 Управление аккаунтом:
+/unlink - Отвязать Telegram аккаунт
+
+💡 Вы будете получать уведомления о:
+• Занятиях ваших детей
+• Домашних заданиях детей
+• Прогрессе обучения
+• Отчётах от менторов
+"""
+ else:
+ # Для других ролей (admin и т.д.)
+ help_text = """
+🔔 Доступные команды:
+
+/start - Начать работу с ботом
+/help - Эта справка
+/settings - Настройки уведомлений
+/status - Статус связывания
+/unlink - Отвязать аккаунт
+
+💡 Вы будете получать уведомления о событиях на платформе.
+"""
+
+ # Добавляем клавиатуру если аккаунт связан
+ if db_user:
+ keyboard = get_main_keyboard(db_user.role)
+ await update.message.reply_text(help_text, reply_markup=keyboard)
+ else:
+ await update.message.reply_text(help_text)
+
+ async def link_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """
+ Обработчик команды /link.
+ Связывание Telegram аккаунта с аккаунтом на платформе.
+ """
+ user = update.effective_user
+ telegram_id = user.id
+ telegram_username = user.username or ''
+
+ # Проверяем наличие кода
+ if not context.args:
+ await update.message.reply_text(
+ "❌ Укажите код связывания.\n\n"
+ "Использование: /link <код>\n\n"
+ "Получите код на платформе: Профиль → Настройки → Telegram"
+ )
+ return
+
+ link_code = context.args[0]
+
+ # Проверяем код и связываем аккаунт
+ from .services import TelegramLinkService
+
+ try:
+ result = await sync_to_async(TelegramLinkService.link_account)(
+ link_code=link_code,
+ telegram_id=telegram_id,
+ telegram_username=telegram_username
+ )
+
+ if result['success']:
+ user_name = result.get('user_name', 'Пользователь')
+ # Получаем роль пользователя для клавиатуры
+ from apps.users.models import User
+ linked_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+ keyboard = get_main_keyboard(linked_user.role if linked_user else None)
+ await update.message.reply_text(
+ f"✅ Аккаунт успешно связан!\n\n"
+ f"👤 {user_name}\n\n"
+ f"Теперь вы будете получать уведомления в Telegram.",
+ reply_markup=keyboard
+ )
+ else:
+ keyboard = get_main_keyboard(None)
+ await update.message.reply_text(
+ f"❌ Ошибка связывания: {result.get('error', 'Неизвестная ошибка')}",
+ reply_markup=keyboard
+ )
+
+ except Exception as e:
+ logger.error(f"Error linking Telegram account: {e}")
+ await update.message.reply_text(
+ "❌ Произошла ошибка при связывании аккаунта.\n"
+ "Попробуйте позже или обратитесь в поддержку."
+ )
+
+ async def unlink_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """
+ Обработчик команды /unlink.
+ Отвязка Telegram аккаунта.
+ """
+ user = update.effective_user
+ telegram_id = user.id
+
+ from .services import TelegramLinkService
+
+ try:
+ result = await sync_to_async(TelegramLinkService.unlink_account)(telegram_id)
+
+ if result['success']:
+ await update.message.reply_text(
+ "✅ Аккаунт успешно отвязан.\n\n"
+ "Вы больше не будете получать уведомления в Telegram.\n\n"
+ "Чтобы снова связать аккаунт, используйте /link"
+ )
+ else:
+ await update.message.reply_text(
+ f"❌ {result.get('error', 'Аккаунт не найден')}"
+ )
+
+ except Exception as e:
+ logger.error(f"Error unlinking Telegram account: {e}")
+ await update.message.reply_text(
+ "❌ Произошла ошибка при отвязке аккаунта."
+ )
+
+ async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик команды /status."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+
+ try:
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if db_user:
+ role_display = db_user.get_role_display()
+ role_emoji = {
+ 'mentor': '👨🏫',
+ 'client': '👨🎓',
+ 'parent': '👨👩👧',
+ 'admin': '👤'
+ }.get(db_user.role, '👤')
+
+ status_message = (
+ f"✅ Аккаунт связан\n\n"
+ f"{role_emoji} {db_user.get_full_name() or db_user.email}\n"
+ f"📧 {db_user.email}\n"
+ f"🎭 Роль: {role_display}\n\n"
+ )
+
+ # Дополнительная информация в зависимости от роли
+ if db_user.role == 'parent':
+ from apps.users.models import Parent
+ try:
+ parent_profile = await sync_to_async(lambda: db_user.parent_profile)()
+ children = await sync_to_async(list)(parent_profile.children.all())
+ children_count = len(children)
+ status_message += f"👶 Привязано детей: {children_count}\n\n"
+ except:
+ pass
+
+ # Проверяем настройки уведомлений
+ try:
+ preferences = await sync_to_async(lambda: db_user.notification_preferences)()
+ notifications_status = "✅ Включены" if preferences.telegram_enabled else "❌ Выключены"
+ status_message += f"🔔 Уведомления: {notifications_status}"
+ except:
+ status_message += "🔔 Уведомления: ⚙️ Настройте на платформе"
+
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(status_message, parse_mode='HTML', reply_markup=keyboard)
+ else:
+ keyboard = get_main_keyboard(None)
+ await update.message.reply_text(
+ "❌ Аккаунт не связан\n\n"
+ "Используйте /link <код> для связывания аккаунта.",
+ reply_markup=keyboard
+ )
+
+ except Exception as e:
+ logger.error(f"Error checking status: {e}")
+ await update.message.reply_text(
+ "❌ Ошибка проверки статуса."
+ )
+
+ async def settings_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик команды /settings."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+
+ try:
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ keyboard = get_main_keyboard(None)
+ await update.message.reply_text(
+ "❌ Аккаунт не связан.\n\n"
+ "Используйте /link <код> для связывания.",
+ reply_markup=keyboard
+ )
+ return
+
+ # Получаем настройки уведомлений
+ try:
+ preferences = await sync_to_async(lambda: db_user.notification_preferences)()
+ except:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "⚙️ Настройки уведомлений не найдены.\n\n"
+ "Настройте уведомления на платформе: Профиль → Настройки",
+ reply_markup=keyboard
+ )
+ return
+
+ # Формируем расширенное меню настроек
+ message_text = "⚙️ Настройки\n\n"
+
+ # Статус уведомлений
+ all_status = "✅ Включены" if preferences.enabled else "❌ Выключены"
+ telegram_status = "✅ Вкл" if preferences.telegram_enabled else "❌ Выкл"
+ email_status = "✅ Вкл" if preferences.email_enabled else "❌ Выкл"
+ in_app_status = "✅ Вкл" if preferences.in_app_enabled else "❌ Выкл"
+
+ message_text += f"🔔 Все уведомления: {all_status}\n"
+ message_text += f"📱 Telegram: {telegram_status}\n"
+ message_text += f"📧 Email: {email_status}\n"
+ message_text += f"💬 В приложении: {in_app_status}\n\n"
+
+ # Режим тишины
+ if preferences.quiet_hours_enabled and preferences.quiet_hours_start and preferences.quiet_hours_end:
+ quiet_start = preferences.quiet_hours_start.strftime('%H:%M')
+ quiet_end = preferences.quiet_hours_end.strftime('%H:%M')
+ message_text += f"🔇 Режим тишины: {quiet_start} - {quiet_end}\n\n"
+ else:
+ message_text += "🔇 Режим тишины: ❌ Выключен\n\n"
+
+ # Язык
+ user_lang = db_user.language or 'ru'
+ lang_display = 'Русский' if user_lang == 'ru' else 'English'
+ message_text += f"🌐 Язык: {lang_display}\n"
+
+ # Создаем inline клавиатуру
+ keyboard = []
+
+ # Основные настройки уведомлений
+ keyboard.append([
+ InlineKeyboardButton(
+ "🔔 " + ("Выключить все" if preferences.enabled else "Включить все"),
+ callback_data="settings_toggle_all"
+ )
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton(
+ "📱 Telegram: " + ("Выкл" if preferences.telegram_enabled else "Вкл"),
+ callback_data="settings_toggle_telegram"
+ ),
+ InlineKeyboardButton(
+ "📧 Email: " + ("Выкл" if preferences.email_enabled else "Вкл"),
+ callback_data="settings_toggle_email"
+ )
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton(
+ "🔇 Режим тишины",
+ callback_data="settings_quiet_hours"
+ ),
+ InlineKeyboardButton(
+ "📋 Типы уведомлений",
+ callback_data="settings_notification_types"
+ )
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton("🌐 Язык", callback_data="settings_language")
+ ])
+
+ # Добавляем кнопку с URL только если это не localhost
+ frontend_url = settings.FRONTEND_URL
+ if frontend_url and 'localhost' not in frontend_url and '127.0.0.1' not in frontend_url:
+ keyboard.append([
+ InlineKeyboardButton("🌐 Открыть на сайте", url=f"{frontend_url}/profile")
+ ])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+
+ # Добавляем основную клавиатуру вместе с inline кнопками
+ main_keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ message_text,
+ parse_mode='HTML',
+ reply_markup=reply_markup
+ )
+ # Отправляем отдельное сообщение с основной клавиатурой
+ await update.message.reply_text(
+ "Используйте кнопки ниже для навигации:",
+ reply_markup=main_keyboard
+ )
+
+ except Exception as e:
+ logger.error(f"Error getting settings: {e}")
+ await update.message.reply_text(
+ "❌ Ошибка получения настроек."
+ )
+
+ async def button_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик нажатий на кнопки."""
+ query = update.callback_query
+ await query.answer()
+
+ # Сначала обрабатываем настройки (они имеют приоритет)
+ if (query.data.startswith('settings_') or query.data.startswith('toggle_type_')
+ or query.data.startswith('set_timezone_') or query.data.startswith('set_language_')
+ or query.data.startswith('quiet_hours_') or query.data == "noop"):
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+
+ try:
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ await query.edit_message_text("❌ Аккаунт не связан.")
+ return
+
+ preferences = await sync_to_async(lambda: db_user.notification_preferences)()
+
+ # Переключение всех уведомлений
+ if query.data == "settings_toggle_all":
+ preferences.enabled = not preferences.enabled
+ await sync_to_async(preferences.save)()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Переключение Telegram уведомлений
+ elif query.data == "settings_toggle_telegram":
+ preferences.telegram_enabled = not preferences.telegram_enabled
+ await sync_to_async(preferences.save)()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Переключение Email уведомлений
+ elif query.data == "settings_toggle_email":
+ preferences.email_enabled = not preferences.email_enabled
+ await sync_to_async(preferences.save)()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Режим тишины
+ elif query.data == "settings_quiet_hours":
+ await self._handle_quiet_hours(query, db_user, preferences)
+ return
+
+ # Обработка режима тишины
+ elif query.data.startswith("quiet_hours_"):
+ from datetime import time
+
+ if query.data == "quiet_hours_disable":
+ preferences.quiet_hours_enabled = False
+ await sync_to_async(preferences.save)()
+ await query.answer("Режим тишины выключен")
+ await self._handle_quiet_hours(query, db_user, preferences)
+ return
+ elif query.data == "quiet_hours_enable_22_8":
+ preferences.quiet_hours_enabled = True
+ preferences.quiet_hours_start = time(22, 0)
+ preferences.quiet_hours_end = time(8, 0)
+ await sync_to_async(preferences.save)()
+ await query.answer("Режим тишины: 22:00 - 08:00")
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+ elif query.data == "quiet_hours_enable_23_7":
+ preferences.quiet_hours_enabled = True
+ preferences.quiet_hours_start = time(23, 0)
+ preferences.quiet_hours_end = time(7, 0)
+ await sync_to_async(preferences.save)()
+ await query.answer("Режим тишины: 23:00 - 07:00")
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+ elif query.data == "quiet_hours_custom":
+ await query.answer("Настройка времени через сайт", show_alert=True)
+ await self._handle_quiet_hours(query, db_user, preferences)
+ return
+
+ # Типы уведомлений
+ elif query.data == "settings_notification_types":
+ await self._handle_notification_types(query, db_user, preferences)
+ return
+
+ # Часовой пояс — кнопка убрана; при нажатии старой кнопки просто возврат в настройки
+ elif query.data == "settings_timezone":
+ await query.answer()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Язык
+ elif query.data == "settings_language":
+ await self._handle_language(query, db_user)
+ return
+
+ # Обработка типов уведомлений
+ elif query.data.startswith("toggle_type_"):
+ ntype = query.data.replace("toggle_type_", "")
+ type_prefs = preferences.type_preferences.get(ntype, {})
+ if not isinstance(type_prefs, dict):
+ type_prefs = {}
+
+ current = type_prefs.get('telegram', True)
+ type_prefs['telegram'] = not current
+ preferences.type_preferences[ntype] = type_prefs
+ await sync_to_async(preferences.save)()
+
+ # Получаем название типа для ответа
+ from .models import Notification
+ type_display = dict(Notification.TYPE_CHOICES).get(ntype, ntype)
+ status = "включены" if not current else "выключены"
+ await query.answer(f"{type_display} {status}")
+ await self._handle_notification_types(query, db_user, preferences)
+ return
+
+ # Игнорируем заголовки (noop)
+ elif query.data == "noop":
+ await query.answer()
+ return
+
+ # Установка часового пояса (меню убрано; при нажатии старой кнопки — возврат в настройки)
+ elif query.data.startswith("set_timezone_"):
+ await query.answer()
+ preferences = await sync_to_async(lambda: db_user.notification_preferences)()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Установка языка
+ elif query.data.startswith("set_language_"):
+ language = query.data.replace("set_language_", "")
+ db_user.language = language
+ await sync_to_async(db_user.save)(update_fields=['language'])
+ await query.answer(f"Язык установлен: {language}")
+ await self._handle_language(query, db_user)
+ return
+
+ # Возврат к настройкам
+ elif query.data == "settings_back":
+ preferences = await sync_to_async(lambda: db_user.notification_preferences)()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ except Exception as e:
+ logger.error(f"Error handling settings callback: {e}", exc_info=True)
+ await query.edit_message_text("❌ Ошибка обновления настроек.")
+ return
+
+ # Обработка домашних заданий и кнопок ментора (список заданий → выбор задания)
+ if (query.data.startswith('homework_') or query.data.startswith('mentor_submission_')
+ or query.data.startswith('submission_') or query.data == 'mentor_homework_back'):
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ await query.answer("❌ Аккаунт не связан", show_alert=True)
+ return
+
+ try:
+ if query.data.startswith('homework_upload_'):
+ # Загрузка решения ДЗ
+ homework_id = int(query.data.replace('homework_upload_', ''))
+
+ from apps.homework.models import Homework
+ homework = await sync_to_async(Homework.objects.get)(id=homework_id)
+
+ # Проверяем доступ
+ if db_user.role != 'client' or db_user not in await sync_to_async(list)(homework.assigned_to.all()):
+ await query.answer("❌ У вас нет доступа к этому заданию", show_alert=True)
+ return
+
+ # Сохраняем ID задания в контексте
+ context.user_data['waiting_for_homework_file'] = homework_id
+
+ await query.answer("📎 Отправьте файл или фото для решения")
+ await query.edit_message_text(
+ f"📎 Загрузка решения\n\n"
+ f"📝 Задание: {homework.title}\n\n"
+ f"Отправьте:\n"
+ f"• Текст решения\n"
+ f"• Файл (документ)\n"
+ f"• Фото с решением\n\n"
+ f"Или отправьте 'отмена' для отмены.",
+ parse_mode='HTML'
+ )
+ return
+
+ elif query.data.startswith('homework_detail_'):
+ # Детали задания
+ homework_id = int(query.data.replace('homework_detail_', ''))
+
+ from apps.homework.models import Homework, HomeworkSubmission
+ homework = await sync_to_async(Homework.objects.get)(id=homework_id)
+
+ deadline_str = homework.deadline.strftime('%d.%m.%Y в %H:%M') if homework.deadline else 'Без дедлайна'
+
+ submission = await sync_to_async(
+ HomeworkSubmission.objects.filter(
+ homework=homework,
+ student=db_user
+ ).first
+ )()
+
+ message = f"📝 {homework.title}\n\n"
+ message += f"📅 Дедлайн: {deadline_str}\n"
+ if homework.description:
+ desc = homework.description[:200] + "..." if len(homework.description) > 200 else homework.description
+ message += f"\n📄 {desc}\n"
+
+ if submission:
+ message += f"\n✅ Статус: Сдано\n"
+ if submission.status == 'graded' and submission.score is not None:
+ message += f"🎯 Оценка: {submission.score}/{homework.max_score}\n"
+ elif submission.status == 'returned':
+ message += f"🔄 Возвращено на доработку\n"
+ else:
+ message += f"⏳ Ожидает проверки\n"
+ else:
+ message += f"\n⏳ Статус: Не сдано\n"
+
+ inline_keyboard = []
+ if not submission or submission.status == 'returned':
+ inline_keyboard.append([
+ InlineKeyboardButton(
+ "📎 Загрузить решение",
+ callback_data=f"homework_upload_{homework.id}"
+ )
+ ])
+
+ if submission:
+ inline_keyboard.append([
+ InlineKeyboardButton(
+ "👁️ Просмотреть решение",
+ callback_data=f"homework_view_{submission.id}"
+ )
+ ])
+
+ inline_keyboard.append([
+ InlineKeyboardButton(
+ "◀️ Назад к списку",
+ callback_data="homework_back"
+ )
+ ])
+
+ reply_markup = InlineKeyboardMarkup(inline_keyboard)
+ await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
+ return
+
+ elif query.data.startswith('homework_view_'):
+ # Просмотр решения
+ submission_id = int(query.data.replace('homework_view_', ''))
+
+ from apps.homework.models import HomeworkSubmission
+ submission = await sync_to_async(HomeworkSubmission.objects.select_related('homework', 'student').get)(id=submission_id)
+ if submission.student_id != db_user.id and db_user.role != 'mentor':
+ await query.answer("❌ У вас нет доступа", show_alert=True)
+ return
+ view_data = await sync_to_async(lambda s: (
+ s.homework.title,
+ s.student.get_full_name(),
+ s.homework.max_score,
+ s.submitted_at.strftime('%d.%m.%Y в %H:%M') if s.submitted_at else 'Неизвестно',
+ s.get_status_display(),
+ ))(submission)
+ hw_title_v, student_name_v, hw_max_v, submitted_at_v, status_v = view_data
+ message = f"👁️ Решение ДЗ\n\n"
+ message += f"📝 Задание: {hw_title_v}\n"
+ message += f"👤 Студент: {student_name_v}\n"
+ message += f"📅 Сдано: {submitted_at_v}\n"
+ message += f"📊 Статус: {status_v}\n"
+ if submission.score is not None:
+ message += f"🎯 Оценка: {submission.score}/{hw_max_v}\n"
+
+ # Комментарии (экранируем HTML, чтобы не сломать сообщение в Telegram)
+ import html as _html
+ if submission.feedback and submission.feedback.strip():
+ feedback = submission.feedback[:500] + "..." if len(submission.feedback) > 500 else submission.feedback
+ message += f"\n💬 Комментарий ментора:\n{_html.escape(feedback)}\n"
+ if submission.ai_checked_at and submission.ai_feedback and submission.ai_feedback.strip():
+ ai_feedback = submission.ai_feedback[:500] + "..." if len(submission.ai_feedback) > 500 else submission.ai_feedback
+ message += f"\n🤖 Комментарий ИИ:\n{_html.escape(ai_feedback)}\n"
+
+ if submission.attachment:
+ message += f"\n📎 Файл прикреплен"
+
+ inline_keyboard = [[
+ InlineKeyboardButton(
+ "🌐 Открыть на сайте",
+ url=f"{settings.FRONTEND_URL}/homework"
+ )
+ ]]
+
+ reply_markup = InlineKeyboardMarkup(inline_keyboard)
+ await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
+ return
+
+ elif query.data.startswith('mentor_submission_'):
+ # Просмотр submission ментором: все обращения к БД/связям — только внутри sync_to_async
+ submission_id = int(query.data.replace('mentor_submission_', ''))
+ import html
+
+ def _load_mentor_submission(sid, mentor_user_id):
+ from apps.homework.models import HomeworkSubmission, HomeworkFile
+ sub = HomeworkSubmission.objects.select_related('homework', 'student').prefetch_related(
+ 'files'
+ ).get(id=sid)
+ if sub.homework.mentor_id != mentor_user_id:
+ return None
+ # Основной файл
+ att_name = None
+ has_att = bool(getattr(sub, 'attachment', None))
+ if has_att:
+ att_name = getattr(sub.attachment, 'name', None) or 'Файл'
+ if att_name and '/' in att_name:
+ att_name = att_name.split('/')[-1]
+ # Список всех файлов: основной + доп. файлы решения (используем prefetch)
+ file_names = []
+ if att_name:
+ file_names.append(att_name)
+ for hf in sub.files.all():
+ if getattr(hf, 'file_type', None) == 'submission' and hf.file:
+ name = getattr(hf.file, 'name', None) or 'Файл'
+ if '/' in name:
+ name = name.split('/')[-1]
+ if name and name not in file_names:
+ file_names.append(name)
+ return {
+ 'id': sub.id,
+ 'student_name': sub.student.get_full_name(),
+ 'hw_title': sub.homework.title,
+ 'hw_id': sub.homework_id,
+ 'max_score': sub.homework.max_score,
+ 'submitted_at': sub.submitted_at,
+ 'status_display': sub.get_status_display(),
+ 'content': (sub.content or '').strip(),
+ 'attachment_url': (sub.attachment_url or '').strip(),
+ 'score': sub.score,
+ 'status': sub.status,
+ 'ai_checked_at': sub.ai_checked_at,
+ 'ai_score': sub.ai_score,
+ 'ai_feedback': (sub.ai_feedback or '').strip(),
+ 'feedback': (sub.feedback or '').strip(),
+ 'has_attachment': has_att,
+ 'attachment_name': att_name,
+ 'file_names': file_names,
+ 'sub': sub,
+ }
+
+ data = await sync_to_async(_load_mentor_submission)(submission_id, db_user.id)
+ if data is None or db_user.role != 'mentor':
+ await query.answer("❌ У вас нет доступа", show_alert=True)
+ return
+
+ submitted_at = data['submitted_at'].strftime('%d.%m.%Y в %H:%M') if data['submitted_at'] else 'Неизвестно'
+ message = f"📝 {data['hw_title']}\n\n"
+ message += f"👤 Студент: {data['student_name']}\n"
+ message += f"📅 Сдано: {submitted_at}\n"
+ message += f"📊 Статус: {data['status_display']}\n\n"
+
+ # Текст, который отправил студент
+ message += "📄 Текст от студента:\n"
+ if data['content']:
+ content_preview = data['content'][:1500] + "…" if len(data['content']) > 1500 else data['content']
+ message += "" + html.escape(content_preview) + "\n\n"
+ else:
+ message += "Студент текста не написал.\n\n"
+
+ # Все прикреплённые файлы (основной + доп. из HomeworkFile)
+ message += "📎 Прикреплённые файлы:\n"
+ file_names = data.get('file_names') or []
+ if file_names:
+ for i, name in enumerate(file_names[:15], 1):
+ message += f" {i}. {html.escape(name)}\n"
+ if len(file_names) > 15:
+ message += f" … и ещё {len(file_names) - 15}\n"
+ else:
+ message += " Файлов нет\n"
+ if data['attachment_url']:
+ message += f"🔗 Ссылка студента: {data['attachment_url'][:100]}{'…' if len(data['attachment_url']) > 100 else ''}\n"
+ message += "\nСкачать/открыть файлы — кнопка «Открыть на сайте» ниже.\n\n"
+
+ if data['status'] == 'graded' and data['score'] is not None:
+ message += f"🎯 Оценка: {data['score']}/{data['max_score']}\n\n"
+
+ message += "💬 Комментарии к решению:\n"
+ has_comment = False
+ if data['ai_checked_at'] and data['ai_feedback']:
+ ai_score_text = f"{data['ai_score']}/5" if data['ai_score'] is not None else "—"
+ message += f"🤖 ИИ (оценка: {ai_score_text}):\n"
+ ai_fb = data['ai_feedback'][:800] + "..." if len(data['ai_feedback']) > 800 else data['ai_feedback']
+ message += f"{html.escape(ai_fb)}\n\n"
+ has_comment = True
+ if data['feedback']:
+ message += "✏️ Ваш комментарий:\n"
+ fb = data['feedback'][:800] + "..." if len(data['feedback']) > 800 else data['feedback']
+ message += f"{html.escape(fb)}\n"
+ has_comment = True
+ if not has_comment:
+ message += "Пока нет комментариев.\n"
+
+ inline_keyboard = [
+ [InlineKeyboardButton("✏️ Редактировать ответ", callback_data=f"submission_edit_feedback_{data['id']}")],
+ ]
+ if data['status'] == 'pending' or (data['ai_checked_at'] and data['status'] != 'graded'):
+ if data['ai_score'] and data['ai_feedback']:
+ inline_keyboard.append([InlineKeyboardButton("💾 Сохранить ответ", callback_data=f"submission_publish_ai_{data['id']}")])
+ inline_keyboard.append([InlineKeyboardButton("⭐ Выставить оценку", callback_data=f"submission_grade_{data['id']}")])
+ elif data['status'] == 'graded':
+ inline_keyboard.append([InlineKeyboardButton("⭐ Изменить оценку", callback_data=f"submission_grade_{data['id']}")])
+ # Вернуть на пересдачу — студент получит уведомление
+ inline_keyboard.append([InlineKeyboardButton("🔄 Вернуть на доработку", callback_data=f"submission_return_revision_{data['id']}")])
+ inline_keyboard.append([InlineKeyboardButton("🌐 Открыть на сайте", url=f"{settings.FRONTEND_URL}/homework/{data['hw_id']}/submissions/{data['id']}/")])
+ inline_keyboard.append([InlineKeyboardButton("◀️ Назад к списку", callback_data="mentor_homework_back")])
+
+ reply_markup = InlineKeyboardMarkup(inline_keyboard)
+ await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
+
+ # Картинка вложения — только в sync_to_async, submission в data['sub']
+ def _get_attachment_photo_data(s):
+ if not s:
+ return None, None, False
+ att = getattr(s, 'attachment', None)
+ if not att:
+ return None, None, False
+ name = (getattr(att, 'name', None) or '').lower()
+ if not any(name.endswith(ext) for ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp')):
+ return None, None, False
+ path, url = None, None
+ try:
+ path = getattr(att, 'path', None)
+ path = str(path) if path else None
+ except Exception:
+ pass
+ try:
+ url = getattr(att, 'url', None)
+ except Exception:
+ pass
+ return path, url, True
+
+ _path, _url, _is_image = await sync_to_async(_get_attachment_photo_data)(data['sub'])
+ if _is_image:
+ try:
+ import os
+ sent = False
+ if _path and os.path.exists(_path):
+ await context.bot.send_photo(
+ chat_id=query.message.chat_id,
+ photo=_path,
+ caption="📎 Файл решения (прикреплён учеником)"
+ )
+ sent = True
+ if not sent and _url:
+ base = getattr(settings, 'BASE_URL', None) or getattr(settings, 'API_URL', None) or ''
+ if base:
+ photo_url = base.rstrip('/') + _url
+ await context.bot.send_photo(
+ chat_id=query.message.chat_id,
+ photo=photo_url,
+ caption="📎 Файл решения (прикреплён учеником)"
+ )
+ except Exception as img_err:
+ logger.warning("Could not send submission image to Telegram: %s", img_err)
+
+ return
+
+ elif query.data.startswith('submission_publish_ai_'):
+ # Публикация черновика ИИ как есть
+ submission_id = int(query.data.replace('submission_publish_ai_', ''))
+
+ from apps.homework.models import HomeworkSubmission, Homework
+ submission = await sync_to_async(
+ HomeworkSubmission.objects.select_related('homework', 'student').get
+ )(id=submission_id)
+ homework_mentor_id = await sync_to_async(
+ lambda hw_id: Homework.objects.values_list('mentor_id', flat=True).get(id=hw_id)
+ )(submission.homework_id)
+ if db_user.role != 'mentor' or homework_mentor_id != db_user.id:
+ await query.answer("❌ У вас нет доступа", show_alert=True)
+ return
+
+ if not submission.ai_score or not submission.ai_feedback:
+ await query.answer("❌ Нет данных от ИИ для публикации", show_alert=True)
+ return
+
+ # Публикуем оценку и комментарий ИИ
+ await sync_to_async(submission.grade)(
+ submission.ai_score,
+ submission.ai_feedback,
+ checked_by=db_user
+ )
+ submission.graded_by_ai = True
+ await sync_to_async(submission.save)(update_fields=['graded_by_ai'])
+
+ # Обновляем статистику и уведомление — всё в sync (доступ к homework/student)
+ def _publish_ai_and_notify(sub):
+ sub.homework.update_statistics()
+ from apps.notifications.services import NotificationService
+ msg = f'Проверено ДЗ "{sub.homework.title}". Оценка: {sub.ai_score}/{sub.homework.max_score}'
+ if sub.ai_feedback and str(sub.ai_feedback).strip():
+ comment = (sub.ai_feedback[:500] + '…') if len(sub.ai_feedback) > 500 else sub.ai_feedback
+ msg += f'\n\n💬 Комментарий:\n{comment}'
+ NotificationService.create_notification_with_telegram(
+ recipient=sub.student,
+ notification_type='homework_reviewed',
+ title='✅ ДЗ проверено',
+ message=msg,
+ priority='normal',
+ action_url=f'/homework/{sub.homework.id}/submissions/{sub.id}/',
+ content_object=sub
+ )
+ return (sub.homework.title, sub.student.get_full_name(), sub.homework.max_score)
+ hw_title, student_name, hw_max = await sync_to_async(_publish_ai_and_notify)(submission)
+
+ await query.answer("✅ Оценка опубликована!")
+ await query.edit_message_text(
+ f"✅ Оценка опубликована!\n\n"
+ f"📝 Задание: {hw_title}\n"
+ f"👤 Студент: {student_name}\n"
+ f"⭐ Оценка: {submission.ai_score}/{hw_max}\n"
+ f"💬 Комментарий: {submission.ai_feedback[:200]}{'...' if len(submission.ai_feedback) > 200 else ''}\n\n"
+ f"📤 Студент получил уведомление.",
+ parse_mode='HTML'
+ )
+ return
+
+ elif query.data.startswith('submission_grade_'):
+ # Начало редактирования оценки
+ submission_id = int(query.data.replace('submission_grade_', ''))
+
+ from apps.homework.models import HomeworkSubmission, Homework
+ submission = await sync_to_async(HomeworkSubmission.objects.select_related('homework', 'student').get)(id=submission_id)
+ homework_mentor_id = await sync_to_async(
+ lambda hw_id: Homework.objects.values_list('mentor_id', flat=True).get(id=hw_id)
+ )(submission.homework_id)
+ if db_user.role != 'mentor' or homework_mentor_id != db_user.id:
+ await query.answer("❌ У вас нет доступа", show_alert=True)
+ return
+
+ grade_display = await sync_to_async(lambda s: (
+ s.score if s.score is not None else (s.ai_score if s.ai_score else None),
+ s.homework.max_score,
+ s.homework.title,
+ s.student.get_full_name()
+ ))(submission)
+ current_score, hw_max, hw_title, student_name = grade_display
+ current_score_text = f" (текущая: {current_score}/{hw_max})" if current_score else ""
+
+ context.user_data['waiting_for_submission_score'] = submission_id
+ await query.answer()
+ await query.message.reply_text(
+ f"⭐ Выставление оценки\n\n"
+ f"📝 Задание: {hw_title}\n"
+ f"👤 Студент: {student_name}\n\n"
+ f"Введите оценку от 1 до {hw_max}{current_score_text}.\n\n"
+ f"Или отправьте оценку и комментарий в формате:\n"
+ f"5\nОтличная работа!",
+ parse_mode='HTML'
+ )
+ return
+
+ elif query.data.startswith('submission_edit_feedback_'):
+ # Начало редактирования ответа
+ import html as _html_mod
+ submission_id = int(query.data.replace('submission_edit_feedback_', ''))
+
+ from apps.homework.models import HomeworkSubmission, Homework
+ submission = await sync_to_async(HomeworkSubmission.objects.select_related('homework', 'student').get)(id=submission_id)
+ homework_mentor_id = await sync_to_async(
+ lambda hw_id: Homework.objects.values_list('mentor_id', flat=True).get(id=hw_id)
+ )(submission.homework_id)
+ if db_user.role != 'mentor' or homework_mentor_id != db_user.id:
+ await query.answer("❌ У вас нет доступа", show_alert=True)
+ return
+
+ current_feedback = (submission.feedback or submission.ai_feedback or "").strip()
+ cf_preview = current_feedback[:200] + "..." if len(current_feedback) > 200 else current_feedback
+ current_feedback_text = f"\n\nТекущий ответ:\n{_html_mod.escape(cf_preview)}" if current_feedback else ""
+ hw_title_fb, student_name_fb = await sync_to_async(
+ lambda s: (s.homework.title, s.student.get_full_name())
+ )(submission)
+ context.user_data['waiting_for_submission_feedback'] = submission_id
+ await query.answer()
+ await query.message.reply_text(
+ f"✏️ Редактировать ответ\n\n"
+ f"📝 Задание: {hw_title_fb}\n"
+ f"👤 Студент: {student_name_fb}\n\n"
+ f"Введите ваш ответ. Отправка сообщения сохранит ответ.{current_feedback_text}",
+ parse_mode='HTML'
+ )
+ return
+
+ elif query.data.startswith('submission_return_revision_'):
+ # Вернуть ДЗ на доработку — студент получит уведомление
+ submission_id = int(query.data.replace('submission_return_revision_', ''))
+ from apps.homework.models import HomeworkSubmission, Homework
+ submission = await sync_to_async(
+ HomeworkSubmission.objects.select_related('homework', 'student').get
+ )(id=submission_id)
+ homework_mentor_id = await sync_to_async(
+ lambda hw_id: Homework.objects.values_list('mentor_id', flat=True).get(id=hw_id)
+ )(submission.homework_id)
+ if db_user.role != 'mentor' or homework_mentor_id != db_user.id:
+ await query.answer("❌ У вас нет доступа", show_alert=True)
+ return
+ hw_title_rr, student_name_rr = await sync_to_async(
+ lambda s: (s.homework.title, s.student.get_full_name())
+ )(submission)
+ context.user_data['waiting_for_return_revision'] = submission_id
+ await query.answer()
+ await query.message.reply_text(
+ f"🔄 Вернуть на доработку\n\n"
+ f"📝 Задание: {hw_title_rr}\n"
+ f"👤 Студент: {student_name_rr}\n\n"
+ f"Введите комментарий для студента (почему нужно доработать). "
+ f"Студент получит уведомление и сможет отправить решение заново.\n\n"
+ f"Или отправьте «отмена» для отмены.",
+ parse_mode='HTML'
+ )
+ return
+
+ elif query.data == 'mentor_homework_back':
+ # Возврат к списку заданий для ментора
+ from apps.homework.models import HomeworkSubmission
+ from django.utils import timezone
+
+ def _mentor_submissions_list(mentor_user):
+ qs = HomeworkSubmission.objects.filter(
+ homework__mentor=mentor_user,
+ status='pending'
+ ).select_related('homework', 'student').order_by('-submitted_at')[:5]
+ return [
+ {
+ 'id': s.id,
+ 'student_name': s.student.get_full_name() if s.student else 'Студент',
+ 'hw_title': s.homework.title or 'Без названия',
+ 'submitted_at': s.submitted_at.strftime('%d.%m.%Y') if s.submitted_at else 'Неизвестно',
+ 'ai_checked_at': getattr(s, 'ai_checked_at', None),
+ }
+ for s in qs
+ ]
+ submissions_data = await sync_to_async(_mentor_submissions_list)(db_user)
+ if not submissions_data:
+ message = "📝 Нет домашних заданий, требующих проверки."
+ keyboard = await get_user_keyboard(update.effective_user.id)
+ await query.edit_message_text(message, parse_mode='HTML')
+ await query.message.reply_text("Используйте кнопки для навигации:", reply_markup=keyboard)
+ return
+ message = "📝 Домашние задания на проверку:\n\n"
+ inline_keyboard = []
+ for item in submissions_data:
+ ai_status = " 🤖 ИИ проверил" if item['ai_checked_at'] else ""
+ message += f"{item['hw_title']}{ai_status}\n"
+ message += f"👤 {item['student_name']}\n"
+ message += f"📅 Сдано: {item['submitted_at']}\n\n"
+ inline_keyboard.append([
+ InlineKeyboardButton(
+ f"👁️ {item['hw_title'][:30]} — {item['student_name']}",
+ callback_data=f"mentor_submission_{item['id']}"
+ )
+ ])
+ reply_markup = InlineKeyboardMarkup(inline_keyboard)
+ await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
+ return
+
+ elif query.data == 'homework_back':
+ # Возврат к списку заданий в виде кнопок в панели
+ from apps.homework.models import Homework
+ from django.utils import timezone
+
+ # Используем правильный sync_to_async
+ def get_homeworks():
+ return list(Homework.objects.filter(
+ assigned_to=db_user,
+ deadline__gte=timezone.now()
+ ).order_by('deadline')[:5])
+
+ homeworks = await sync_to_async(get_homeworks)()
+
+ if not homeworks:
+ keyboard = await get_user_keyboard(query.from_user.id)
+ await query.edit_message_text("📝 У вас нет активных домашних заданий.")
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="Возврат в главное меню.",
+ reply_markup=keyboard
+ )
+ return
+
+ # Возврат к списку в виде кнопок в панели
+ homework_keyboard, homework_buttons = make_homework_list_keyboard(homeworks)
+ context.user_data['homework_buttons'] = homework_buttons
+ context.user_data['homework_list_active'] = True
+ context.user_data.pop('current_homework_id', None)
+
+ await query.edit_message_text(
+ "📝 Домашние задания\n\nВыберите задание кнопкой в панели ниже:",
+ parse_mode='HTML'
+ )
+ await context.bot.send_message(
+ chat_id=query.message.chat_id,
+ text="👇",
+ reply_markup=homework_keyboard
+ )
+ return
+
+ except Exception as e:
+ logger.error(f"Error handling homework callback: {e}", exc_info=True)
+ await query.answer("❌ Ошибка обработки запроса", show_alert=True)
+ return
+
+ # Обработка деталей занятия
+ if query.data.startswith('lesson_detail_'):
+ try:
+ lesson_id = int(query.data.replace('lesson_detail_', ''))
+
+ from apps.schedule.models import Lesson
+ from django.utils import timezone
+ import pytz
+
+ lesson = await sync_to_async(
+ Lesson.objects.select_related('mentor', 'client', 'client__user', 'subject').get
+ )(id=lesson_id)
+
+ user = update.effective_user
+ telegram_id = user.id
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ await query.answer("❌ Аккаунт не связан", show_alert=True)
+ return
+
+ # Проверяем доступ
+ has_access = False
+ if db_user.role == 'mentor' and lesson.mentor == db_user:
+ has_access = True
+ elif db_user.role == 'client' and lesson.client and lesson.client.user == db_user:
+ has_access = True
+ elif db_user.role == 'parent' and lesson.client and lesson.client.user.parent_profile and lesson.client.user.parent_profile.user == db_user:
+ has_access = True
+
+ if not has_access:
+ await query.answer("❌ У вас нет доступа к этому занятию", show_alert=True)
+ return
+
+ # Формируем сообщение
+ from apps.users.utils import get_user_timezone
+ user_tz = get_user_timezone(db_user.timezone or 'UTC')
+ if timezone.is_aware(lesson.start_time):
+ local_start = lesson.start_time.astimezone(user_tz)
+ local_end = lesson.end_time.astimezone(user_tz) if lesson.end_time else None
+ else:
+ utc_start = timezone.make_aware(lesson.start_time, pytz.UTC)
+ local_start = utc_start.astimezone(user_tz)
+ local_end = lesson.end_time.astimezone(user_tz) if lesson.end_time and timezone.is_aware(lesson.end_time) else None
+
+ message = f"📚 {lesson.title}\n\n"
+
+ if lesson.subject:
+ message += f"📖 Предмет: {lesson.subject.name}\n"
+
+ message += f"🕐 Начало: {local_start.strftime('%d.%m.%Y в %H:%M')}\n"
+ if local_end:
+ message += f"🕐 Окончание: {local_end.strftime('%d.%m.%Y в %H:%M')}\n"
+ message += f"⏱ Длительность: {lesson.duration} минут\n"
+ message += f"📊 Статус: {lesson.get_status_display()}\n\n"
+
+ if db_user.role == 'mentor':
+ if lesson.client:
+ message += f"👤 Студент: {lesson.client.user.get_full_name()}\n"
+ else:
+ message += f"👨🏫 Ментор: {lesson.mentor.get_full_name()}\n"
+
+ if lesson.description:
+ desc = lesson.description[:200] + "..." if len(lesson.description) > 200 else lesson.description
+ message += f"\n📄 {desc}\n"
+
+ if lesson.meeting_url:
+ message += f"\n🔗 Ссылка на видеоконференцию\n"
+
+ if lesson.mentor_grade is not None:
+ message += f"\n🎯 Оценка: {lesson.mentor_grade}/100\n"
+
+ inline_keyboard = [[
+ InlineKeyboardButton(
+ "🌐 Открыть на сайте",
+ url=f"{settings.FRONTEND_URL}/schedule"
+ )
+ ]]
+
+ reply_markup = InlineKeyboardMarkup(inline_keyboard)
+ await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
+
+ except Lesson.DoesNotExist:
+ await query.answer("❌ Занятие не найдено", show_alert=True)
+ except Exception as e:
+ logger.error(f"Error handling lesson detail: {e}", exc_info=True)
+ await query.answer("❌ Ошибка обработки запроса", show_alert=True)
+ return
+
+ # Обработка деталей клиента (для менторов)
+ if query.data.startswith('client_detail_'):
+ try:
+ client_id = int(query.data.replace('client_detail_', ''))
+
+ from apps.users.models import Client, User
+ from apps.schedule.models import Lesson
+ from apps.homework.models import HomeworkSubmission
+ from django.utils import timezone
+ from datetime import timedelta
+
+ client = await sync_to_async(
+ Client.objects.select_related('user').get
+ )(id=client_id)
+
+ user = update.effective_user
+ telegram_id = user.id
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user or db_user.role != 'mentor' or db_user not in await sync_to_async(list)(client.mentors.all()):
+ await query.answer("❌ У вас нет доступа", show_alert=True)
+ return
+
+ client_name = client.user.get_full_name() or client.user.email
+ message = f"👤 {client_name}\n\n"
+
+ # Статистика занятий
+ all_lessons = await sync_to_async(list)(
+ Lesson.objects.filter(mentor=db_user, client=client)
+ )
+ total_lessons = len(all_lessons)
+ completed_lessons = len([l for l in all_lessons if l.status == 'completed'])
+ upcoming_lessons = len([
+ l for l in all_lessons
+ if l.start_time >= timezone.now() and l.status == 'scheduled'
+ ])
+
+ # Занятия за месяц
+ month_ago = timezone.now() - timedelta(days=30)
+ lessons_this_month = len([
+ l for l in all_lessons
+ if l.start_time >= month_ago
+ ])
+
+ message += f"📚 Занятия:\n"
+ message += f"• Всего: {total_lessons}\n"
+ message += f"• Завершено: {completed_lessons}\n"
+ message += f"• Предстоящих: {upcoming_lessons}\n"
+ message += f"• За месяц: {lessons_this_month}\n\n"
+
+ # Статистика ДЗ
+ all_submissions = await sync_to_async(list)(
+ HomeworkSubmission.objects.filter(
+ homework__mentor=db_user,
+ student=client.user
+ )
+ )
+ total_homeworks = len(all_submissions)
+ pending_homeworks = len([s for s in all_submissions if s.status == 'pending'])
+ graded_homeworks = len([s for s in all_submissions if s.status == 'graded'])
+
+ message += f"📝 Домашние задания:\n"
+ message += f"• Всего решений: {total_homeworks}\n"
+ message += f"• На проверке: {pending_homeworks}\n"
+ message += f"• Проверено: {graded_homeworks}\n"
+
+ if graded_homeworks > 0:
+ scores = [s.score for s in all_submissions if s.score is not None]
+ avg_score = sum(scores) / len(scores) if scores else 0
+ message += f"• Средний балл: {avg_score:.1f}\n"
+
+ # Доходы от клиента
+ lessons_with_price = [l for l in all_lessons if l.price and l.status == 'completed']
+ if lessons_with_price:
+ total_revenue = sum([float(l.price) for l in lessons_with_price])
+ message += f"\n💰 Доходы:\n"
+ message += f"• Всего: {total_revenue:.2f} ₽\n"
+
+ inline_keyboard = [[
+ InlineKeyboardButton(
+ "🌐 Открыть на сайте",
+ url=f"{settings.FRONTEND_URL}/students"
+ ),
+ InlineKeyboardButton(
+ "◀️ Назад к списку",
+ callback_data="clients_back"
+ )
+ ]]
+
+ reply_markup = InlineKeyboardMarkup(inline_keyboard)
+ await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
+
+ except Client.DoesNotExist:
+ await query.answer("❌ Клиент не найден", show_alert=True)
+ except Exception as e:
+ logger.error(f"Error handling client detail: {e}", exc_info=True)
+ await query.answer("❌ Ошибка обработки запроса", show_alert=True)
+ return
+
+ # Обработка возврата к списку клиентов
+ if query.data == 'clients_back':
+ try:
+ user = update.effective_user
+ telegram_id = user.id
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user or db_user.role != 'mentor':
+ await query.answer("❌ Ошибка", show_alert=True)
+ return
+
+ from apps.users.models import Client
+ clients = await sync_to_async(list)(
+ Client.objects.filter(mentors=db_user).select_related('user')[:10]
+ )
+
+ if not clients:
+ await query.edit_message_text("👥 У вас пока нет клиентов.")
+ return
+
+ message = "👥 Ваши клиенты:\n\n"
+ inline_keyboard = []
+
+ for client in clients:
+ client_name = client.user.get_full_name() or client.user.email
+ message += f"👤 {client_name}\n\n"
+ inline_keyboard.append([
+ InlineKeyboardButton(
+ f"👤 {client_name[:30]}",
+ callback_data=f"client_detail_{client.id}"
+ )
+ ])
+
+ reply_markup = InlineKeyboardMarkup(inline_keyboard)
+ await query.edit_message_text(message, parse_mode='HTML', reply_markup=reply_markup)
+
+ except Exception as e:
+ logger.error(f"Error handling clients back: {e}", exc_info=True)
+ await query.answer("❌ Ошибка", show_alert=True)
+ return
+
+ # Обработка подтверждения присутствия
+ if query.data.startswith('attendance_yes_') or query.data.startswith('attendance_no_'):
+ try:
+ lesson_id = int(query.data.split('_')[-1])
+ response_bool = query.data.startswith('attendance_yes_')
+
+ from apps.schedule.models import Lesson
+ from django.utils import timezone
+
+ lesson = await sync_to_async(Lesson.objects.select_related('client', 'client__user', 'mentor').get)(id=lesson_id)
+ user = update.effective_user
+ telegram_id = user.id
+
+ # Проверяем, что пользователь - студент этого занятия
+ if not lesson.client or lesson.client.user.telegram_id != telegram_id:
+ await query.edit_message_text(
+ "❌ Ошибка: вы не являетесь студентом этого занятия"
+ )
+ return
+
+ # Сохраняем ответ
+ lesson.attendance_confirmed = response_bool
+ lesson.attendance_response_at = timezone.now()
+ await sync_to_async(lesson.save)(update_fields=['attendance_confirmed', 'attendance_response_at'])
+
+ # Отправляем уведомление ментору
+ from apps.notifications.services import NotificationService
+ await sync_to_async(NotificationService.send_attendance_response_to_mentor)(lesson, response_bool)
+
+ response_text = "будете присутствовать" if response_bool else "не сможете присутствовать"
+
+ await query.edit_message_text(
+ f"✅ Ответ сохранен\n\n"
+ f"Вы подтвердили, что {response_text} на занятии:\n"
+ f"{lesson.title}\n\n"
+ f"Преподаватель получил уведомление."
+ )
+
+ except Lesson.DoesNotExist:
+ await query.edit_message_text("❌ Занятие не найдено")
+ except Exception as e:
+ logger.error(f"Error processing attendance confirmation: {e}")
+ await query.edit_message_text("❌ Ошибка обработки ответа")
+ return
+
+ # Обработка настроек уведомлений
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+
+ try:
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ await query.edit_message_text("❌ Аккаунт не связан.")
+ return
+
+ preferences = await sync_to_async(lambda: db_user.notification_preferences)()
+
+ # Переключение всех уведомлений
+ if query.data == "settings_toggle_all":
+ preferences.enabled = not preferences.enabled
+ await sync_to_async(preferences.save)()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Переключение Telegram уведомлений
+ elif query.data == "settings_toggle_telegram":
+ preferences.telegram_enabled = not preferences.telegram_enabled
+ await sync_to_async(preferences.save)()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Переключение Email уведомлений
+ elif query.data == "settings_toggle_email":
+ preferences.email_enabled = not preferences.email_enabled
+ await sync_to_async(preferences.save)()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Режим тишины
+ elif query.data == "settings_quiet_hours":
+ await self._handle_quiet_hours(query, db_user, preferences)
+ return
+
+ # Типы уведомлений
+ elif query.data == "settings_notification_types":
+ await self._handle_notification_types(query, db_user, preferences)
+ return
+
+ # Часовой пояс — кнопка убрана
+ elif query.data == "settings_timezone":
+ await query.answer()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Язык
+ elif query.data == "settings_language":
+ await self._handle_language(query, db_user)
+ return
+
+ # Обработка типов уведомлений
+ elif query.data.startswith("toggle_type_"):
+ ntype = query.data.replace("toggle_type_", "")
+ type_prefs = preferences.type_preferences.get(ntype, {})
+ if not isinstance(type_prefs, dict):
+ type_prefs = {}
+
+ current = type_prefs.get('telegram', True)
+ type_prefs['telegram'] = not current
+ preferences.type_preferences[ntype] = type_prefs
+ await sync_to_async(preferences.save)()
+
+ await query.answer("Настройка обновлена")
+ await self._handle_notification_types(query, db_user, preferences)
+ return
+
+ # Установка часового пояса (меню убрано)
+ elif query.data.startswith("set_timezone_"):
+ await query.answer()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Установка языка
+ elif query.data.startswith("set_language_"):
+ language = query.data.replace("set_language_", "")
+ db_user.language = language
+ await sync_to_async(db_user.save)(update_fields=['language'])
+ await query.answer(f"Язык установлен: {language}")
+ await self._handle_language(query, db_user)
+ return
+
+ # Возврат к настройкам
+ elif query.data == "settings_back":
+ preferences = await sync_to_async(lambda: db_user.notification_preferences)()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ # Старый обработчик для обратной совместимости
+ elif query.data == "toggle_notifications":
+ preferences.telegram_enabled = not preferences.telegram_enabled
+ await sync_to_async(preferences.save)()
+ await self._refresh_settings_message(query, db_user, preferences)
+ return
+
+ except Exception as e:
+ logger.error(f"Error handling settings callback: {e}", exc_info=True)
+ await query.edit_message_text(
+ "❌ Ошибка обновления настроек."
+ )
+
+ async def schedule_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик команды /schedule - показать расписание."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+
+ try:
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ await update.message.reply_text(
+ "❌ Аккаунт не связан.\n\n"
+ "Используйте /link <код> для связывания аккаунта."
+ )
+ return
+
+ # Получаем ближайшие занятия
+ from apps.schedule.models import Lesson
+ from django.utils import timezone
+
+ now = timezone.now()
+
+ # Для ментора - все занятия
+ if db_user.role == 'mentor':
+ lessons = await sync_to_async(list)(
+ Lesson.objects.filter(
+ mentor=db_user,
+ start_time__gte=now
+ ).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
+ )
+ # Для клиента - только его занятия
+ elif db_user.role == 'client':
+ # Проверяем наличие Client профиля
+ try:
+ from apps.users.models import Client
+ client_profile = await sync_to_async(
+ Client.objects.filter(user=db_user).first
+ )()
+
+ if not client_profile:
+ await update.message.reply_text(
+ "❌ Профиль клиента не найден.\n\n"
+ "Обратитесь к администратору для настройки профиля."
+ )
+ return
+ except Exception as e:
+ logger.error(f"Error getting client profile: {e}")
+ await update.message.reply_text(
+ "❌ Ошибка получения профиля клиента."
+ )
+ return
+
+ lessons = await sync_to_async(list)(
+ Lesson.objects.filter(
+ client=client_profile,
+ start_time__gte=now
+ ).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
+ )
+ # Для родителя - занятия всех детей
+ elif db_user.role == 'parent':
+ from apps.users.models import Parent
+ try:
+ parent_profile = await sync_to_async(lambda: db_user.parent_profile)()
+ children = await sync_to_async(list)(parent_profile.children.all())
+
+ if not children:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ У вас нет привязанных детей.\n\n"
+ "Обратитесь к администратору для привязки детей к вашему аккаунту.",
+ reply_markup=keyboard
+ )
+ return
+
+ # Получаем занятия всех детей
+ child_clients = list(children) # children уже список Client объектов
+ lessons = await sync_to_async(list)(
+ Lesson.objects.filter(
+ client__in=child_clients,
+ start_time__gte=now
+ ).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
+ )
+ except:
+ await update.message.reply_text(
+ "❌ Ошибка получения данных родителя."
+ )
+ return
+ else:
+ await update.message.reply_text(
+ "❌ Эта команда доступна только для менторов, клиентов и родителей."
+ )
+ return
+
+ if not lessons:
+ await update.message.reply_text(
+ "📅 У вас нет предстоящих занятий."
+ )
+ return
+
+ message = "📅 Ближайшие занятия:\n\n"
+
+ for lesson in lessons:
+ import pytz
+ from apps.users.utils import get_user_timezone
+ user_tz = get_user_timezone(db_user.timezone or 'UTC')
+ if timezone.is_aware(lesson.start_time):
+ local_time = lesson.start_time.astimezone(user_tz)
+ else:
+ utc_time = timezone.make_aware(lesson.start_time, pytz.UTC)
+ local_time = utc_time.astimezone(user_tz)
+
+ time_str = local_time.strftime('%d.%m.%Y в %H:%M')
+
+ if db_user.role == 'mentor':
+ student_name = lesson.client.user.get_full_name() if lesson.client and lesson.client.user else 'Студент'
+ message += f"📚 {lesson.title}\n"
+ message += f"👤 {student_name}\n"
+ elif db_user.role == 'parent':
+ child_name = lesson.client.user.get_full_name() if lesson.client and lesson.client.user else 'Ребёнок'
+ message += f"📚 {lesson.title}\n"
+ message += f"👨🏫 {lesson.mentor.get_full_name()}\n"
+ message += f"👶 {child_name}\n"
+ else:
+ message += f"📚 {lesson.title}\n"
+ message += f"👨🏫 {lesson.mentor.get_full_name()}\n"
+
+ message += f"🕐 {time_str}\n\n"
+
+ await update.message.reply_text(message, parse_mode='HTML')
+
+ except Exception as e:
+ logger.error(f"Error in schedule_command: {e}", exc_info=True)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Ошибка получения расписания.\n\n"
+ "Попробуйте позже или обратитесь в поддержку.",
+ reply_markup=keyboard
+ )
+
+ async def nextlesson_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик команды /nextlesson - показать следующее занятие."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+
+ try:
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ keyboard = get_main_keyboard(None)
+ await update.message.reply_text(
+ "❌ Аккаунт не связан.\n\n"
+ "Используйте /link <код> для связывания аккаунта.",
+ reply_markup=keyboard
+ )
+ return
+
+ from apps.schedule.models import Lesson
+ from django.utils import timezone
+
+ now = timezone.now()
+
+ # Находим ближайшее занятие
+ if db_user.role == 'mentor':
+ lesson = await sync_to_async(
+ Lesson.objects.filter(
+ mentor=db_user,
+ start_time__gte=now
+ ).select_related('mentor', 'client', 'client__user').order_by('start_time').first
+ )()
+ elif db_user.role == 'client':
+ # Проверяем наличие Client профиля
+ try:
+ from apps.users.models import Client
+ client_profile = await sync_to_async(
+ Client.objects.filter(user=db_user).first
+ )()
+
+ if not client_profile:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Профиль клиента не найден.\n\n"
+ "Обратитесь к администратору для настройки профиля.",
+ reply_markup=keyboard
+ )
+ return
+ except Exception as e:
+ logger.error(f"Error getting client profile: {e}")
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Ошибка получения профиля клиента.",
+ reply_markup=keyboard
+ )
+ return
+
+ lesson = await sync_to_async(
+ Lesson.objects.filter(
+ client=client_profile,
+ start_time__gte=now
+ ).select_related('mentor', 'client', 'client__user').order_by('start_time').first
+ )()
+ elif db_user.role == 'parent':
+ from apps.users.models import Parent
+ try:
+ parent_profile = await sync_to_async(lambda: db_user.parent_profile)()
+ children = await sync_to_async(list)(parent_profile.children.all())
+
+ if not children:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ У вас нет привязанных детей.",
+ reply_markup=keyboard
+ )
+ return
+
+ child_clients = list(children) # children уже список Client объектов
+ lesson = await sync_to_async(
+ Lesson.objects.filter(
+ client__in=child_clients,
+ start_time__gte=now
+ ).select_related('mentor', 'client', 'client__user').order_by('start_time').first
+ )()
+ except:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Ошибка получения данных родителя.",
+ reply_markup=keyboard
+ )
+ return
+ else:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Эта команда доступна только для менторов, клиентов и родителей.",
+ reply_markup=keyboard
+ )
+ return
+
+ if not lesson:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "📅 У вас нет предстоящих занятий.",
+ reply_markup=keyboard
+ )
+ return
+
+ import pytz
+ from apps.users.utils import get_user_timezone
+ user_tz = get_user_timezone(db_user.timezone or 'UTC')
+ if timezone.is_aware(lesson.start_time):
+ local_time = lesson.start_time.astimezone(user_tz)
+ else:
+ utc_time = timezone.make_aware(lesson.start_time, pytz.UTC)
+ local_time = utc_time.astimezone(user_tz)
+
+ time_str = local_time.strftime('%d.%m.%Y в %H:%M')
+
+ message = "📚 Следующее занятие:\n\n"
+ message += f"{lesson.title}\n"
+
+ if db_user.role == 'mentor':
+ student_name = lesson.client.user.get_full_name() if lesson.client and lesson.client.user else 'Студент'
+ message += f"👤 {student_name}\n"
+ elif db_user.role == 'parent':
+ child_name = lesson.client.user.get_full_name() if lesson.client and lesson.client.user else 'Ребёнок'
+ message += f"👨🏫 {lesson.mentor.get_full_name()}\n"
+ message += f"👶 {child_name}\n"
+ else:
+ message += f"👨🏫 {lesson.mentor.get_full_name()}\n"
+
+ message += f"🕐 {time_str}\n"
+
+ if lesson.description:
+ message += f"\n📝 {lesson.description[:200]}"
+
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(message, parse_mode='HTML', reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f"Error in nextlesson_command: {e}", exc_info=True)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Ошибка получения следующего занятия.\n\n"
+ "Попробуйте позже или обратитесь в поддержку.",
+ reply_markup=keyboard
+ )
+
+ async def _get_client_homework_detail(self, homework_id: int, db_user):
+ """Вернуть (текст сообщения, ReplyKeyboardMarkup) для деталей задания клиента."""
+ from apps.homework.models import Homework, HomeworkSubmission
+
+ # Используем sync_to_async правильно для всех DB операций
+ try:
+ # Получаем homework с prefetch для оптимизации
+ homework = await sync_to_async(
+ lambda: Homework.objects.select_related('mentor').prefetch_related('assigned_to').get(id=homework_id)
+ )()
+ except Homework.DoesNotExist:
+ return None, None
+
+ # Проверяем доступ (используем sync_to_async для проверки M2M)
+ assigned_users = await sync_to_async(list)(homework.assigned_to.all())
+ if db_user.role != 'client' or db_user not in assigned_users:
+ return None, None
+
+ deadline_str = homework.deadline.strftime('%d.%m.%Y в %H:%M') if homework.deadline else 'Без дедлайна'
+
+ # Получаем submission через sync_to_async
+ submission = await sync_to_async(
+ lambda: HomeworkSubmission.objects.filter(homework=homework, student=db_user).first()
+ )()
+
+ message = f"📝 {homework.title}\n\n"
+ message += f"📅 Дедлайн: {deadline_str}\n"
+ message += f"🎯 Макс. баллов: {homework.max_score}\n"
+ if homework.description:
+ desc = homework.description[:300] + "..." if len(homework.description) > 300 else homework.description
+ message += f"\n📄 {desc}\n"
+
+ if submission:
+ message += f"\n✅ Статус: Сдано\n"
+ if submission.status == 'graded' and submission.score is not None:
+ message += f"🎯 Оценка: {submission.score}/{homework.max_score}\n"
+ # Краткий комментарий к оценке — студент видит, что есть отзыв
+ comment = (submission.feedback or submission.ai_feedback or "").strip()
+ if comment:
+ preview = comment[:120] + "…" if len(comment) > 120 else comment
+ import html as _html
+ message += f"💬 {_html.escape(preview)}\n"
+ elif submission.status == 'returned':
+ message += f"🔄 Возвращено на доработку\n"
+ else:
+ message += f"⏳ Ожидает проверки\n"
+ else:
+ message += f"\n⏳ Статус: Не сдано\n"
+
+ # Создаём Reply клавиатуру для управления ДЗ
+ keyboard_buttons = []
+ if not submission or submission.status == 'returned':
+ keyboard_buttons.append([KeyboardButton("📎 Загрузить решение")])
+ if submission:
+ keyboard_buttons.append([KeyboardButton("👁️ Просмотреть решение")])
+ keyboard_buttons.append([KeyboardButton("◀️ Назад к списку"), KeyboardButton("🏠 В главное меню")])
+
+ reply_keyboard = ReplyKeyboardMarkup(keyboard_buttons, resize_keyboard=True, one_time_keyboard=False)
+ return message, reply_keyboard
+
+ async def _show_submission_view(self, update: Update, context: ContextTypes.DEFAULT_TYPE, submission, db_user):
+ """Показать просмотр решения ДЗ (оценка и комментарии к работе)."""
+ import html as _html
+
+ message = f"👁️ Решение ДЗ\n\n"
+ message += f"📝 Задание: {submission.homework.title}\n"
+ message += f"📅 Сдано: {submission.submitted_at.strftime('%d.%m.%Y в %H:%M') if submission.submitted_at else 'Неизвестно'}\n"
+ message += f"📊 Статус: {submission.get_status_display()}\n"
+
+ if submission.score is not None:
+ message += f"🎯 Оценка: {submission.score}/{submission.homework.max_score}\n"
+
+ # Комментарий к оценённой работе — студент должен всегда видеть отзыв
+ mentor_fb = (submission.feedback or "").strip()
+ ai_fb = (submission.ai_feedback or "").strip() if submission.ai_checked_at else ""
+
+ if mentor_fb or ai_fb:
+ message += f"\n📋 Комментарий к вашей работе:\n"
+ if mentor_fb:
+ feedback_esc = _html.escape(mentor_fb[:800] + "..." if len(mentor_fb) > 800 else mentor_fb)
+ message += f"💬 {feedback_esc}\n"
+ if ai_fb and ai_fb != mentor_fb:
+ ai_esc = _html.escape(ai_fb[:800] + "..." if len(ai_fb) > 800 else ai_fb)
+ message += f"🤖 {ai_esc}\n"
+ elif submission.status == 'graded':
+ message += f"\n📋 Комментарий к вашей работе: нет текста отзыва.\n"
+
+ message += f"\n🌐 Открыть на сайте"
+
+ await update.message.reply_text(message, parse_mode='HTML', disable_web_page_preview=True)
+
+ async def homework_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик команды /homework - показать домашние задания."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+
+ try:
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ await update.message.reply_text(
+ "❌ Аккаунт не связан.\n\n"
+ "Используйте /link <код> для связывания аккаунта."
+ )
+ return
+
+ from apps.homework.models import Homework, HomeworkSubmission
+
+ # Для клиента - показать его задания
+ if db_user.role == 'client':
+ # Используем правильный sync_to_async для избежания ошибки SynchronousOnlyOperation
+ def get_client_homeworks():
+ return list(Homework.objects.filter(
+ assigned_to=db_user,
+ deadline__gte=timezone.now()
+ ).order_by('deadline')[:5])
+
+ homeworks = await sync_to_async(get_client_homeworks)()
+
+ if not homeworks:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "📝 У вас нет активных домашних заданий.",
+ reply_markup=keyboard
+ )
+ return
+
+ # Если только одно задание, показываем его сразу с Reply клавиатурой
+ if len(homeworks) == 1:
+ hw = homeworks[0]
+ context.user_data['current_homework_id'] = hw.id
+ msg, reply_markup = await self._get_client_homework_detail(hw.id, db_user)
+ if msg and reply_markup:
+ await update.message.reply_text(msg, parse_mode='HTML', reply_markup=reply_markup)
+ else:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text("❌ Ошибка загрузки задания.", reply_markup=keyboard)
+ else:
+ # Несколько заданий: список в виде кнопок в панели Telegram
+ homework_keyboard, homework_buttons = make_homework_list_keyboard(homeworks)
+ context.user_data['homework_buttons'] = homework_buttons
+ context.user_data['homework_list_active'] = True
+ await update.message.reply_text(
+ "📝 Домашние задания\n\nВыберите задание кнопкой в панели ниже:",
+ parse_mode='HTML',
+ reply_markup=homework_keyboard
+ )
+
+ # Для родителя - показать задания всех детей
+ elif db_user.role == 'parent':
+ from apps.users.models import Parent
+ try:
+ parent_profile = await sync_to_async(lambda: db_user.parent_profile)()
+ children = await sync_to_async(list)(parent_profile.children.all())
+
+ if not children:
+ await update.message.reply_text(
+ "❌ У вас нет привязанных детей."
+ )
+ return
+
+ child_users = [child.user for child in children]
+ homeworks = await sync_to_async(list)(
+ Homework.objects.filter(
+ assigned_to__in=child_users,
+ deadline__gte=timezone.now()
+ ).order_by('deadline')[:5]
+ )
+
+ if not homeworks:
+ await update.message.reply_text(
+ "📝 У ваших детей нет активных домашних заданий."
+ )
+ return
+
+ message = "📝 Домашние задания детей:\n\n"
+
+ for hw in homeworks:
+ deadline_str = hw.deadline.strftime('%d.%m.%Y') if hw.deadline else 'Без дедлайна'
+
+ # Находим для какого ребёнка это задание
+ child_students = [student for student in hw.assigned_to.all() if student in child_users]
+ child_names = ', '.join([child.get_full_name() for child in child_students[:2]])
+ if len(child_students) > 2:
+ child_names += f" и ещё {len(child_students) - 2}"
+
+ message += f"{hw.title}\n"
+ message += f"👶 {child_names}\n"
+ message += f"📅 Дедлайн: {deadline_str}\n\n"
+
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(message, parse_mode='HTML', reply_markup=keyboard)
+ except Exception as e:
+ logger.error(f"Error in homework_command for parent: {e}")
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Ошибка получения домашних заданий.",
+ reply_markup=keyboard
+ )
+
+ # Для ментора - показать задания требующие проверки
+ elif db_user.role == 'mentor':
+ def _mentor_submissions_display(mentor_user):
+ qs = HomeworkSubmission.objects.filter(
+ homework__mentor=mentor_user,
+ status='pending'
+ ).select_related('homework', 'student').order_by('-submitted_at')[:5]
+ return [
+ {
+ 'id': s.id,
+ 'student_name': (s.student.get_full_name() or 'Студент') if s.student else 'Студент',
+ 'hw_title': (s.homework.title or 'Без названия') if s.homework else 'Без названия',
+ 'submitted_at': s.submitted_at.strftime('%d.%m.%Y') if s.submitted_at else 'Неизвестно',
+ 'ai_checked_at': getattr(s, 'ai_checked_at', None),
+ }
+ for s in qs
+ ]
+ submissions_data = await sync_to_async(_mentor_submissions_display)(db_user)
+ if not submissions_data:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "📝 Нет домашних заданий, требующих проверки.",
+ reply_markup=keyboard
+ )
+ return
+ message = "📝 Домашние задания на проверку:\n\n"
+ inline_keyboard = []
+ for item in submissions_data:
+ ai_status = " 🤖 ИИ проверил" if item['ai_checked_at'] else ""
+ message += f"{item['hw_title']}{ai_status}\n"
+ message += f"👤 {item['student_name']}\n"
+ message += f"📅 Сдано: {item['submitted_at']}\n\n"
+ btn_text = f"👁️ {item['hw_title'][:28]} — {item['student_name'][:15]}"
+ if len(btn_text) > 60:
+ btn_text = btn_text[:57] + "..."
+ inline_keyboard.append([
+ InlineKeyboardButton(btn_text, callback_data=f"mentor_submission_{item['id']}")
+ ])
+ reply_markup = InlineKeyboardMarkup(inline_keyboard)
+ await update.message.reply_text(message, parse_mode='HTML', reply_markup=reply_markup)
+ else:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Эта команда доступна только для менторов, клиентов и родителей.",
+ reply_markup=keyboard
+ )
+
+ except Exception as e:
+ logger.exception("Error in homework_command: %s", e)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Ошибка получения домашних заданий.",
+ reply_markup=keyboard
+ )
+
+ async def _handle_text_homework_submission(self, update: Update, context: ContextTypes.DEFAULT_TYPE, db_user, homework_id: int, text_content: str):
+ """
+ Обработка текстового решения домашнего задания.
+
+ Args:
+ update: Update объект от Telegram
+ context: Context объект
+ db_user: Пользователь из базы данных
+ homework_id: ID домашнего задания
+ text_content: Текстовое содержание решения
+ """
+ try:
+ from apps.homework.models import Homework, HomeworkSubmission
+ from django.utils import timezone as tz
+
+ homework = await sync_to_async(Homework.objects.get)(id=homework_id)
+
+ # Проверяем, что пользователь имеет право сдавать это ДЗ
+ if db_user.role != 'client' or db_user not in await sync_to_async(list)(homework.assigned_to.all()):
+ await update.message.reply_text(
+ "❌ У вас нет доступа к этому заданию."
+ )
+ context.user_data.pop('waiting_for_homework_file', None)
+ return
+
+ # Проверяем, есть ли уже решение
+ existing_submission = await sync_to_async(
+ HomeworkSubmission.objects.filter(
+ homework=homework,
+ student=db_user
+ ).order_by('-attempt_number').first
+ )()
+
+ if existing_submission and existing_submission.status != 'returned':
+ # Обновляем существующее решение
+ existing_submission.content = text_content
+ existing_submission.status = 'pending'
+ await sync_to_async(existing_submission.save)()
+
+ await update.message.reply_text(
+ f"✅ Решение обновлено!\n\n"
+ f"📝 Задание: {homework.title}\n\n"
+ f"Решение отправлено на проверку.",
+ parse_mode='HTML'
+ )
+ else:
+ # Определяем номер попытки
+ attempt_number = 1
+ if existing_submission:
+ attempt_number = existing_submission.attempt_number + 1
+
+ # Создаем новое решение
+ submission = HomeworkSubmission(
+ homework=homework,
+ student=db_user,
+ content=text_content,
+ status='pending',
+ attempt_number=attempt_number
+ )
+ await sync_to_async(submission.save)()
+
+ # Проверяем опоздание
+ await sync_to_async(submission.check_if_late)()
+
+ # Обновляем статистику задания
+ await sync_to_async(homework.update_statistics)()
+
+ await update.message.reply_text(
+ f"✅ Решение загружено!\n\n"
+ f"📝 Задание: {homework.title}\n\n"
+ f"Решение отправлено на проверку.",
+ parse_mode='HTML'
+ )
+
+ # Отправляем уведомление ментору
+ from apps.notifications.services import NotificationService
+ await sync_to_async(NotificationService.create_notification_with_telegram)(
+ recipient=homework.mentor,
+ notification_type='homework_submitted',
+ title='📝 ДЗ сдано',
+ message=f'{db_user.get_full_name()} сдал ДЗ "{homework.title}"',
+ priority='normal',
+ action_url=f'/homework/{homework.id}/',
+ content_object=homework
+ )
+
+ # Очищаем состояние ожидания
+ context.user_data.pop('waiting_for_homework_file', None)
+
+ keyboard = await get_user_keyboard(update.effective_user.id)
+ await update.message.reply_text(
+ "Используйте кнопки для навигации:",
+ reply_markup=keyboard
+ )
+
+ except Homework.DoesNotExist:
+ await update.message.reply_text(
+ "❌ Домашнее задание не найдено."
+ )
+ context.user_data.pop('waiting_for_homework_file', None)
+ except Exception as e:
+ logger.error(f"Error handling text homework submission: {e}", exc_info=True)
+ await update.message.reply_text(
+ "❌ Ошибка загрузки решения.\n\n"
+ "Попробуйте позже или обратитесь в поддержку."
+ )
+ context.user_data.pop('waiting_for_homework_file', None)
+
+ async def _handle_submission_grade_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE, db_user, submission_id: int, message_text: str):
+ """Обработка ввода оценки для submission."""
+ try:
+ from apps.homework.models import HomeworkSubmission
+ from django.utils import timezone
+
+ from apps.homework.models import Homework
+ submission = await sync_to_async(
+ HomeworkSubmission.objects.select_related('homework', 'student').get
+ )(id=submission_id)
+ homework_mentor_id = await sync_to_async(
+ lambda hw_id: Homework.objects.values_list('mentor_id', flat=True).get(id=hw_id)
+ )(submission.homework_id)
+ if db_user.role != 'mentor' or homework_mentor_id != db_user.id:
+ await update.message.reply_text("❌ У вас нет доступа к этому заданию.")
+ context.user_data.pop('waiting_for_submission_score', None)
+ return
+
+ hw_max_score = await sync_to_async(lambda s: s.homework.max_score)(submission)
+ # Парсим ввод: может быть только оценка или "оценка\nкомментарий"
+ lines = message_text.strip().split('\n', 1)
+ score_text = lines[0].strip()
+ feedback_text = lines[1].strip() if len(lines) > 1 else None
+
+ # Пытаемся извлечь оценку
+ try:
+ score = float(score_text.replace(',', '.'))
+ score = int(round(score))
+ except ValueError:
+ await update.message.reply_text(
+ f"❌ Неверный формат оценки.\n\n"
+ f"Введите число от 1 до {hw_max_score}."
+ )
+ return
+
+ # Проверяем диапазон
+ if score < 1 or score > hw_max_score:
+ await update.message.reply_text(
+ f"❌ Оценка должна быть от 1 до {hw_max_score}."
+ )
+ return
+
+ # Если комментарий не указан, используем существующий или ai_feedback
+ if not feedback_text:
+ feedback_text = submission.feedback or submission.ai_feedback or ""
+
+ # Выставляем оценку
+ await sync_to_async(submission.grade)(score, feedback_text, checked_by=db_user)
+
+ def _grade_done_and_notify(sub, sc, fb):
+ sub.homework.update_statistics()
+ from apps.notifications.services import NotificationService
+ msg = f'Проверено ДЗ "{sub.homework.title}". Оценка: {sc}/{sub.homework.max_score}'
+ if fb and str(fb).strip():
+ comment = (fb[:500] + '…') if len(fb) > 500 else fb
+ msg += f'\n\n💬 Комментарий:\n{comment}'
+ NotificationService.create_notification_with_telegram(
+ recipient=sub.student,
+ notification_type='homework_reviewed',
+ title='✅ ДЗ проверено',
+ message=msg,
+ priority='normal',
+ action_url=f'/homework/{sub.homework.id}/submissions/{sub.id}/',
+ content_object=sub
+ )
+ return (sub.homework.title, sub.student.get_full_name(), sub.homework.max_score)
+ hw_title, student_name, _hw_max = await sync_to_async(_grade_done_and_notify)(submission, score, feedback_text)
+
+ context.user_data.pop('waiting_for_submission_score', None)
+
+ await update.message.reply_text(
+ f"✅ Оценка выставлена!\n\n"
+ f"📝 Задание: {hw_title}\n"
+ f"👤 Студент: {student_name}\n"
+ f"⭐ Оценка: {score}/{_hw_max}\n"
+ f"💬 Комментарий: {feedback_text[:100]}{'...' if len(feedback_text) > 100 else ''}",
+ parse_mode='HTML'
+ )
+
+ keyboard = await get_user_keyboard(update.effective_user.id)
+ await update.message.reply_text(
+ "Используйте кнопки для навигации:",
+ reply_markup=keyboard
+ )
+
+ except HomeworkSubmission.DoesNotExist:
+ await update.message.reply_text("❌ Решение не найдено.")
+ context.user_data.pop('waiting_for_submission_score', None)
+ except Exception as e:
+ logger.error(f"Error handling submission grade input: {e}", exc_info=True)
+ await update.message.reply_text(
+ "❌ Ошибка выставления оценки.\n\n"
+ "Попробуйте позже или обратитесь в поддержку."
+ )
+ context.user_data.pop('waiting_for_submission_score', None)
+
+ async def _handle_submission_feedback_input(self, update: Update, context: ContextTypes.DEFAULT_TYPE, db_user, submission_id: int, message_text: str):
+ """Обработка ввода комментария для submission."""
+ try:
+ from apps.homework.models import HomeworkSubmission
+ from django.utils import timezone
+
+ from apps.homework.models import Homework
+ submission = await sync_to_async(
+ HomeworkSubmission.objects.select_related('homework', 'student').get
+ )(id=submission_id)
+ homework_mentor_id = await sync_to_async(
+ lambda hw_id: Homework.objects.values_list('mentor_id', flat=True).get(id=hw_id)
+ )(submission.homework_id)
+ if db_user.role != 'mentor' or homework_mentor_id != db_user.id:
+ await update.message.reply_text("❌ У вас нет доступа к этому заданию.")
+ context.user_data.pop('waiting_for_submission_feedback', None)
+ return
+
+ # Обновляем комментарий
+ submission.feedback = message_text.strip()
+ await sync_to_async(submission.save)(update_fields=['feedback'])
+
+ # Если есть оценка, обновляем статус на graded
+ if submission.score is not None and submission.status != 'graded':
+ submission.status = 'graded'
+ submission.checked_by = db_user
+ submission.checked_at = timezone.now()
+ await sync_to_async(submission.save)(update_fields=['status', 'checked_by', 'checked_at'])
+
+ await sync_to_async(lambda s: s.homework.update_statistics())(submission)
+ context.user_data.pop('waiting_for_submission_feedback', None)
+ hw_title_f, student_name_f = await sync_to_async(
+ lambda s: (s.homework.title, s.student.get_full_name())
+ )(submission)
+ await update.message.reply_text(
+ f"✅ Ответ сохранён!\n\n"
+ f"📝 Задание: {hw_title_f}\n"
+ f"👤 Студент: {student_name_f}\n"
+ f"💬 Ответ: {message_text[:200]}{'...' if len(message_text) > 200 else ''}",
+ parse_mode='HTML'
+ )
+
+ keyboard = await get_user_keyboard(update.effective_user.id)
+ await update.message.reply_text(
+ "Используйте кнопки для навигации:",
+ reply_markup=keyboard
+ )
+
+ except HomeworkSubmission.DoesNotExist:
+ await update.message.reply_text("❌ Решение не найдено.")
+ context.user_data.pop('waiting_for_submission_feedback', None)
+ except Exception as e:
+ logger.error(f"Error handling submission feedback input: {e}", exc_info=True)
+ await update.message.reply_text(
+ "❌ Ошибка сохранения комментария.\n\n"
+ "Попробуйте позже или обратитесь в поддержку."
+ )
+ context.user_data.pop('waiting_for_submission_feedback', None)
+
+ async def _handle_return_revision_input(
+ self, update: Update, context: ContextTypes.DEFAULT_TYPE, db_user, submission_id: int, feedback_text: str
+ ):
+ """Вернуть ДЗ на доработку и уведомить студента."""
+ try:
+ from apps.homework.models import HomeworkSubmission, Homework
+ from apps.notifications.services import NotificationService
+
+ submission = await sync_to_async(
+ HomeworkSubmission.objects.select_related('homework', 'student').get
+ )(id=submission_id)
+ homework_mentor_id = await sync_to_async(
+ lambda hw_id: Homework.objects.values_list('mentor_id', flat=True).get(id=hw_id)
+ )(submission.homework_id)
+ if db_user.role != 'mentor' or homework_mentor_id != db_user.id:
+ await update.message.reply_text("❌ У вас нет доступа к этому заданию.")
+ context.user_data.pop('waiting_for_return_revision', None)
+ return
+
+ feedback_text = (feedback_text or '').strip() or 'Нужно доработать решение.'
+
+ def _do_return_and_notify():
+ submission.return_for_revision(feedback_text, checked_by=db_user)
+ hw_title = submission.homework.title
+ student_name = submission.student.get_full_name()
+ msg = f'ДЗ "{hw_title}" возвращено на доработку. Нужно отправить решение заново.'
+ comment = (feedback_text[:500] + '…') if len(feedback_text) > 500 else feedback_text
+ msg += f'\n\n💬 Комментарий:\n{comment}'
+ NotificationService.create_notification_with_telegram(
+ recipient=submission.student,
+ notification_type='homework_returned',
+ title='🔄 ДЗ возвращено на доработку',
+ message=msg,
+ priority='normal',
+ action_url=f'/homework/{submission.homework.id}/submissions/{submission.id}/',
+ content_object=submission
+ )
+ return (hw_title, student_name)
+
+ hw_title, student_name = await sync_to_async(_do_return_and_notify)()
+
+ context.user_data.pop('waiting_for_return_revision', None)
+ await update.message.reply_text(
+ f"🔄 ДЗ возвращено на доработку\n\n"
+ f"📝 Задание: {hw_title}\n"
+ f"👤 Студент: {student_name}\n"
+ f"💬 Комментарий отправлен. Студент получил уведомление и может отправить решение заново.",
+ parse_mode='HTML'
+ )
+ keyboard = await get_user_keyboard(update.effective_user.id)
+ await update.message.reply_text("Используйте кнопки для навигации:", reply_markup=keyboard)
+ except HomeworkSubmission.DoesNotExist:
+ await update.message.reply_text("❌ Решение не найдено.")
+ context.user_data.pop('waiting_for_return_revision', None)
+ except Exception as e:
+ logger.error(f"Error handling return revision: {e}", exc_info=True)
+ await update.message.reply_text("❌ Ошибка. Попробуйте позже.")
+ context.user_data.pop('waiting_for_return_revision', None)
+
+ async def _refresh_settings_message(self, query, db_user, preferences):
+ """Обновить сообщение с настройками."""
+ message_text = "⚙️ Настройки\n\n"
+
+ all_status = "✅ Включены" if preferences.enabled else "❌ Выключены"
+ telegram_status = "✅ Вкл" if preferences.telegram_enabled else "❌ Выкл"
+ email_status = "✅ Вкл" if preferences.email_enabled else "❌ Выкл"
+ in_app_status = "✅ Вкл" if preferences.in_app_enabled else "❌ Выкл"
+
+ message_text += f"🔔 Все уведомления: {all_status}\n"
+ message_text += f"📱 Telegram: {telegram_status}\n"
+ message_text += f"📧 Email: {email_status}\n"
+ message_text += f"💬 В приложении: {in_app_status}\n\n"
+
+ if preferences.quiet_hours_enabled and preferences.quiet_hours_start and preferences.quiet_hours_end:
+ quiet_start = preferences.quiet_hours_start.strftime('%H:%M')
+ quiet_end = preferences.quiet_hours_end.strftime('%H:%M')
+ message_text += f"🔇 Режим тишины: {quiet_start} - {quiet_end}\n\n"
+ else:
+ message_text += "🔇 Режим тишины: ❌ Выключен\n\n"
+
+ user_lang = db_user.language or 'ru'
+ lang_display = 'Русский' if user_lang == 'ru' else 'English'
+ message_text += f"🌐 Язык: {lang_display}\n"
+
+ keyboard = []
+ keyboard.append([
+ InlineKeyboardButton(
+ "🔔 " + ("Выключить все" if preferences.enabled else "Включить все"),
+ callback_data="settings_toggle_all"
+ )
+ ])
+ keyboard.append([
+ InlineKeyboardButton(
+ "📱 Telegram: " + ("Выкл" if preferences.telegram_enabled else "Вкл"),
+ callback_data="settings_toggle_telegram"
+ ),
+ InlineKeyboardButton(
+ "📧 Email: " + ("Выкл" if preferences.email_enabled else "Вкл"),
+ callback_data="settings_toggle_email"
+ )
+ ])
+ keyboard.append([
+ InlineKeyboardButton("🔇 Режим тишины", callback_data="settings_quiet_hours"),
+ InlineKeyboardButton("📋 Типы уведомлений", callback_data="settings_notification_types")
+ ])
+ keyboard.append([
+ InlineKeyboardButton("🌐 Язык", callback_data="settings_language")
+ ])
+
+ frontend_url = settings.FRONTEND_URL
+ if frontend_url and 'localhost' not in frontend_url and '127.0.0.1' not in frontend_url:
+ keyboard.append([
+ InlineKeyboardButton("🌐 Открыть на сайте", url=f"{frontend_url}/profile")
+ ])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ await query.edit_message_text(message_text, parse_mode='HTML', reply_markup=reply_markup)
+
+ async def _handle_quiet_hours(self, query, db_user, preferences):
+ """Обработка настроек режима тишины."""
+ from datetime import time
+
+ message_text = "🔇 Режим тишины\n\n"
+
+ if preferences.quiet_hours_enabled and preferences.quiet_hours_start and preferences.quiet_hours_end:
+ quiet_start = preferences.quiet_hours_start.strftime('%H:%M')
+ quiet_end = preferences.quiet_hours_end.strftime('%H:%M')
+ message_text += f"Текущий: {quiet_start} - {quiet_end}\n\n"
+ else:
+ message_text += "Текущий: ❌ Выключен\n\n"
+
+ message_text += "Выберите действие:"
+
+ keyboard = []
+
+ # Предустановленные варианты
+ if not preferences.quiet_hours_enabled:
+ keyboard.append([
+ InlineKeyboardButton("✅ Включить (22:00 - 08:00)", callback_data="quiet_hours_enable_22_8")
+ ])
+ keyboard.append([
+ InlineKeyboardButton("✅ Включить (23:00 - 07:00)", callback_data="quiet_hours_enable_23_7")
+ ])
+ else:
+ keyboard.append([
+ InlineKeyboardButton("❌ Выключить", callback_data="quiet_hours_disable")
+ ])
+ keyboard.append([
+ InlineKeyboardButton("🕐 Изменить время", callback_data="quiet_hours_custom")
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton("◀️ Назад к настройкам", callback_data="settings_back")
+ ])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ await query.edit_message_text(message_text, parse_mode='HTML', reply_markup=reply_markup)
+
+ async def _handle_notification_types(self, query, db_user, preferences):
+ """Обработка настроек типов уведомлений."""
+ from .models import Notification
+
+ # Показываем меню выбора типов уведомлений
+ message_text = "📋 Типы уведомлений\n\n"
+ message_text += "Выберите типы уведомлений для Telegram:\n\n"
+
+ # Группируем типы по категориям
+ lesson_types = [t for t in Notification.TYPE_CHOICES if 'lesson' in t[0]]
+ homework_types = [t for t in Notification.TYPE_CHOICES if 'homework' in t[0]]
+ other_types = [t for t in Notification.TYPE_CHOICES if 'lesson' not in t[0] and 'homework' not in t[0]]
+
+ keyboard = []
+
+ # Кнопки для занятий
+ if lesson_types:
+ keyboard.append([InlineKeyboardButton("📅 Занятия", callback_data="noop")])
+ for ntype, display in lesson_types:
+ type_prefs = preferences.type_preferences.get(ntype, {})
+ if not isinstance(type_prefs, dict):
+ type_prefs = {}
+ enabled = type_prefs.get('telegram', True)
+ status = "✅" if enabled else "❌"
+ # Сокращаем длинные названия
+ short_display = display.replace('Занятие ', '').replace(' занятие', '')
+ keyboard.append([
+ InlineKeyboardButton(
+ f"{status} {short_display}",
+ callback_data=f"toggle_type_{ntype}"
+ )
+ ])
+
+ # Кнопки для домашних заданий
+ if homework_types:
+ keyboard.append([InlineKeyboardButton("📝 Домашние задания", callback_data="noop")])
+ for ntype, display in homework_types:
+ type_prefs = preferences.type_preferences.get(ntype, {})
+ if not isinstance(type_prefs, dict):
+ type_prefs = {}
+ enabled = type_prefs.get('telegram', True)
+ status = "✅" if enabled else "❌"
+ short_display = display.replace('Домашнее задание', 'ДЗ')
+ keyboard.append([
+ InlineKeyboardButton(
+ f"{status} {short_display}",
+ callback_data=f"toggle_type_{ntype}"
+ )
+ ])
+
+ # Кнопки для других типов (первые 3)
+ if other_types:
+ keyboard.append([InlineKeyboardButton("📢 Другие", callback_data="noop")])
+ for ntype, display in other_types[:3]:
+ type_prefs = preferences.type_preferences.get(ntype, {})
+ if not isinstance(type_prefs, dict):
+ type_prefs = {}
+ enabled = type_prefs.get('telegram', True)
+ status = "✅" if enabled else "❌"
+ keyboard.append([
+ InlineKeyboardButton(
+ f"{status} {display}",
+ callback_data=f"toggle_type_{ntype}"
+ )
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton("◀️ Назад к настройкам", callback_data="settings_back")
+ ])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ await query.edit_message_text(message_text, parse_mode='HTML', reply_markup=reply_markup)
+
+ async def _handle_timezone(self, query, db_user):
+ """Обработка настройки часового пояса."""
+ # Популярные часовые пояса
+ popular_timezones = [
+ ('Europe/Moscow', 'Москва (UTC+3)'),
+ ('Europe/Kiev', 'Киев (UTC+2)'),
+ ('Asia/Almaty', 'Алматы (UTC+6)'),
+ ('Europe/Minsk', 'Минск (UTC+3)'),
+ ('Asia/Tashkent', 'Ташкент (UTC+5)'),
+ ('Asia/Yekaterinburg', 'Екатеринбург (UTC+5)'),
+ ('Asia/Novosibirsk', 'Новосибирск (UTC+7)'),
+ ('Europe/Kaliningrad', 'Калининград (UTC+2)'),
+ ]
+
+ message_text = "🕐 Часовой пояс\n\n"
+ message_text += "Текущий: " + (db_user.timezone or 'Europe/Moscow') + "\n\n"
+ message_text += "Выберите часовой пояс:"
+
+ keyboard = []
+ for tz, name in popular_timezones:
+ keyboard.append([
+ InlineKeyboardButton(
+ name + (" ✅" if db_user.timezone == tz else ""),
+ callback_data=f"set_timezone_{tz}"
+ )
+ ])
+
+ keyboard.append([
+ InlineKeyboardButton("◀️ Назад к настройкам", callback_data="settings_back")
+ ])
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ await query.edit_message_text(message_text, parse_mode='HTML', reply_markup=reply_markup)
+
+ async def _handle_language(self, query, db_user):
+ """Обработка настройки языка."""
+ message_text = "🌐 Язык интерфейса\n\n"
+ message_text += "Текущий: " + ('Русский' if db_user.language == 'ru' else 'English') + "\n\n"
+ message_text += "Выберите язык:"
+
+ keyboard = [
+ [
+ InlineKeyboardButton(
+ "Русский" + (" ✅" if db_user.language == 'ru' else ""),
+ callback_data="set_language_ru"
+ ),
+ InlineKeyboardButton(
+ "English" + (" ✅" if db_user.language == 'en' else ""),
+ callback_data="set_language_en"
+ )
+ ],
+ [
+ InlineKeyboardButton("◀️ Назад к настройкам", callback_data="settings_back")
+ ]
+ ]
+
+ reply_markup = InlineKeyboardMarkup(keyboard)
+ await query.edit_message_text(message_text, parse_mode='HTML', reply_markup=reply_markup)
+
+ async def clients_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик команды /clients - список клиентов ментора."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User, Client
+
+ try:
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ keyboard = get_main_keyboard(None)
+ await update.message.reply_text(
+ "❌ Аккаунт не связан.\n\n"
+ "Используйте /link <код> для связывания аккаунта.",
+ reply_markup=keyboard
+ )
+ return
+
+ if db_user.role != 'mentor':
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Эта команда доступна только для менторов.",
+ reply_markup=keyboard
+ )
+ return
+
+ # Получаем клиентов ментора
+ clients = await sync_to_async(list)(
+ Client.objects.filter(mentors=db_user).select_related('user')[:10]
+ )
+
+ if not clients:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "👥 У вас пока нет клиентов.\n\n"
+ "Клиенты появятся здесь после того, как они запишутся на ваши занятия.",
+ reply_markup=keyboard
+ )
+ return
+
+ message = "👥 Ваши клиенты:\n\n"
+
+ from apps.schedule.models import Lesson
+ from apps.homework.models import HomeworkSubmission
+ from django.utils import timezone
+
+ inline_keyboard = []
+ for client in clients:
+ client_name = client.user.get_full_name() or client.user.email
+ message += f"👤 {client_name}\n"
+
+ # Статистика занятий
+ lessons = await sync_to_async(list)(
+ Lesson.objects.filter(
+ mentor=db_user,
+ client=client
+ )
+ )
+ total_lessons = len(lessons)
+ completed_lessons = len([l for l in lessons if l.status == 'completed'])
+ upcoming_lessons = len([
+ l for l in lessons
+ if l.start_time >= timezone.now() and l.status == 'scheduled'
+ ])
+
+ message += f"📚 Занятий: {total_lessons} (завершено: {completed_lessons}, предстоящих: {upcoming_lessons})\n"
+
+ # Статистика ДЗ
+ submissions = await sync_to_async(list)(
+ HomeworkSubmission.objects.filter(
+ homework__mentor=db_user,
+ student=client.user
+ )
+ )
+ total_homeworks = len(submissions)
+ graded_homeworks = len([s for s in submissions if s.status == 'graded'])
+ if graded_homeworks > 0:
+ scores = [s.score for s in submissions if s.score is not None]
+ avg_score = sum(scores) / len(scores) if scores else 0
+ message += f"📝 ДЗ: {total_homeworks} (проверено: {graded_homeworks}, средний балл: {avg_score:.1f})\n"
+ else:
+ message += f"📝 ДЗ: {total_homeworks}\n"
+
+ message += "\n"
+
+ # Добавляем кнопку для просмотра деталей клиента
+ inline_keyboard.append([
+ InlineKeyboardButton(
+ f"👤 {client_name[:30]}",
+ callback_data=f"client_detail_{client.id}"
+ )
+ ])
+
+ reply_markup = InlineKeyboardMarkup(inline_keyboard) if inline_keyboard else None
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(message, parse_mode='HTML', reply_markup=reply_markup)
+
+ except Exception as e:
+ logger.error(f"Error in clients_command: {e}", exc_info=True)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Ошибка получения списка клиентов.\n\n"
+ "Попробуйте позже или обратитесь в поддержку.",
+ reply_markup=keyboard
+ )
+
+ async def stats_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик команды /stats - статистика для ментора."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User, Client
+
+ try:
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ keyboard = get_main_keyboard(None)
+ await update.message.reply_text(
+ "❌ Аккаунт не связан.\n\n"
+ "Используйте /link <код> для связывания аккаунта.",
+ reply_markup=keyboard
+ )
+ return
+
+ if db_user.role != 'mentor':
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Эта команда доступна только для менторов.",
+ reply_markup=keyboard
+ )
+ return
+
+ from apps.schedule.models import Lesson
+ from apps.homework.models import Homework, HomeworkSubmission
+ from django.utils import timezone
+ from datetime import timedelta
+
+ now = timezone.now()
+ month_ago = now - timedelta(days=30)
+
+ # Общая статистика
+ total_clients = await sync_to_async(Client.objects.filter(mentors=db_user).count)()
+
+ # Занятия
+ all_lessons = await sync_to_async(list)(
+ Lesson.objects.filter(mentor=db_user)
+ )
+ total_lessons = len(all_lessons)
+ completed_lessons = len([l for l in all_lessons if l.status == 'completed'])
+ upcoming_lessons = len([
+ l for l in all_lessons
+ if l.start_time >= now and l.status == 'scheduled'
+ ])
+ lessons_this_month = len([
+ l for l in all_lessons
+ if l.start_time >= month_ago
+ ])
+
+ # Домашние задания
+ all_submissions = await sync_to_async(list)(
+ HomeworkSubmission.objects.filter(homework__mentor=db_user)
+ )
+ total_homeworks = len(all_submissions)
+ pending_homeworks = len([s for s in all_submissions if s.status == 'pending'])
+ graded_homeworks = len([s for s in all_submissions if s.status == 'graded'])
+
+ if graded_homeworks > 0:
+ scores = [s.score for s in all_submissions if s.score is not None]
+ avg_score = sum(scores) / len(scores) if scores else 0
+ else:
+ avg_score = 0
+
+ message = "📊 Ваша статистика:\n\n"
+ message += f"👥 Клиентов: {total_clients}\n\n"
+ message += f"📚 Занятия:\n"
+ message += f"• Всего: {total_lessons}\n"
+ message += f"• Завершено: {completed_lessons}\n"
+ message += f"• Предстоящих: {upcoming_lessons}\n"
+ message += f"• За месяц: {lessons_this_month}\n\n"
+ message += f"📝 Домашние задания:\n"
+ message += f"• Всего решений: {total_homeworks}\n"
+ message += f"• На проверке: {pending_homeworks}\n"
+ message += f"• Проверено: {graded_homeworks}\n"
+ if avg_score > 0:
+ message += f"• Средний балл: {avg_score:.1f}\n"
+
+ # Доходы (если есть занятия с ценой)
+ lessons_with_price = [l for l in all_lessons if l.price and l.status == 'completed']
+ if lessons_with_price:
+ total_revenue = sum([float(l.price) for l in lessons_with_price])
+ avg_price = total_revenue / len(lessons_with_price) if lessons_with_price else 0
+ revenue_this_month = sum([
+ float(l.price) for l in lessons_with_price
+ if l.start_time >= month_ago
+ ])
+
+ message += f"\n💰 Доходы:\n"
+ message += f"• Всего: {total_revenue:.2f} ₽\n"
+ message += f"• Средняя цена: {avg_price:.2f} ₽\n"
+ message += f"• За месяц: {revenue_this_month:.2f} ₽\n"
+
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(message, parse_mode='HTML', reply_markup=keyboard)
+
+ except Exception as e:
+ logger.error(f"Error in stats_command: {e}", exc_info=True)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Ошибка получения статистики.\n\n"
+ "Попробуйте позже или обратитесь в поддержку.",
+ reply_markup=keyboard
+ )
+
+ async def progress_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Мой прогресс: выбор предмета → период → средние оценки от репетитора и от школы."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+
+ try:
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ keyboard = get_main_keyboard(None)
+ await update.message.reply_text(
+ "❌ Аккаунт не связан.\n\n"
+ "Используйте /link <код> для связывания аккаунта.",
+ reply_markup=keyboard
+ )
+ return
+
+ if db_user.role != 'client':
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Эта команда доступна только для учеников.",
+ reply_markup=keyboard
+ )
+ return
+
+ from apps.schedule.models import Lesson
+
+ def _get_subject_names():
+ lessons = Lesson.objects.filter(client__user=db_user).select_related('subject', 'mentor_subject')
+ names = set()
+ for lec in lessons:
+ name = lec.subject_name or (lec.subject.name if lec.subject else '') or (lec.mentor_subject.name if lec.mentor_subject else '') or 'Без предмета'
+ if name and name.strip():
+ names.add(name.strip())
+ return sorted(names)
+
+ subject_names = await sync_to_async(_get_subject_names)()
+
+ if not subject_names:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "📊 У вас пока нет занятий по предметам.\n"
+ "После проведённых занятий здесь можно будет посмотреть прогресс.",
+ reply_markup=keyboard
+ )
+ return
+
+ context.user_data['progress_step'] = 'subject'
+ context.user_data['progress_subjects'] = subject_names
+
+ rows = [[KeyboardButton(name)] for name in subject_names]
+ rows.append([KeyboardButton(PROGRESS_BACK_TO_MENU)])
+ keyboard = ReplyKeyboardMarkup(rows, resize_keyboard=True, one_time_keyboard=False)
+
+ await update.message.reply_text(
+ "📊 Мой прогресс\n\n"
+ "Выберите предмет, по которому проводились занятия:",
+ parse_mode='HTML',
+ reply_markup=keyboard
+ )
+
+ except Exception as e:
+ logger.error(f"Error in progress_command: {e}", exc_info=True)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Ошибка. Попробуйте позже.",
+ reply_markup=keyboard
+ )
+
+ async def _progress_send_result(
+ self, update: Update, context: ContextTypes.DEFAULT_TYPE,
+ db_user, subject_name: str, period: str
+ ):
+ """Посчитать и отправить прогресс по предмету за период (средняя от репетитора, от школы)."""
+ from apps.schedule.models import Lesson
+ from django.utils import timezone
+ from datetime import timedelta
+
+ now = timezone.now()
+ if period == PROGRESS_PERIOD_WEEK:
+ start = now - timedelta(days=7)
+ elif period == PROGRESS_PERIOD_30:
+ start = now - timedelta(days=30)
+ else:
+ start = None
+
+ def _get_lessons_and_grades():
+ qs = Lesson.objects.filter(client__user=db_user).select_related('subject', 'mentor_subject')
+ if start:
+ qs = qs.filter(start_time__gte=start)
+ lessons = list(qs)
+ mentor_grades = []
+ school_grades = []
+ count = 0
+ target = (subject_name or '').strip()
+ for lec in lessons:
+ name = ((lec.subject_name or '') or (lec.subject.name if lec.subject else '') or (lec.mentor_subject.name if lec.mentor_subject else '') or 'Без предмета').strip()
+ if name != target:
+ continue
+ count += 1
+ if lec.mentor_grade is not None:
+ mentor_grades.append(lec.mentor_grade)
+ if lec.school_grade is not None:
+ school_grades.append(lec.school_grade)
+ return count, mentor_grades, school_grades
+
+ total_lessons, mentor_grades, school_grades = await sync_to_async(_get_lessons_and_grades)()
+
+ avg_mentor = sum(mentor_grades) / len(mentor_grades) if mentor_grades else None
+ avg_school = sum(school_grades) / len(school_grades) if school_grades else None
+
+ period_label = "за текущую неделю" if period == PROGRESS_PERIOD_WEEK else "за последние 30 дней" if period == PROGRESS_PERIOD_30 else "за всё время"
+ mentor_str = f"{avg_mentor:.1f}" if avg_mentor is not None else "—"
+ school_str = f"{avg_school:.1f}" if avg_school is not None else "—"
+ message = (
+ f"📊 Прогресс: {subject_name}\n\n"
+ f"Период: {period_label}\n"
+ f"Занятий: {total_lessons}\n\n"
+ f"📌 Средняя оценка от репетитора: {mentor_str}\n"
+ f"📌 Средняя оценка от школы: {school_str}"
+ )
+
+ context.user_data.pop('progress_step', None)
+ context.user_data.pop('progress_subjects', None)
+ context.user_data.pop('progress_subject', None)
+ keyboard = await get_user_keyboard(update.effective_user.id)
+ await update.message.reply_text(message, parse_mode='HTML', reply_markup=keyboard)
+
+ async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик обычных сообщений и кнопок."""
+ user = update.effective_user
+ telegram_id = user.id
+ message_text = update.message.text
+
+ from apps.users.models import User
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ # Проверяем, ожидается ли ввод оценки или комментария для submission
+ if db_user and db_user.role == 'mentor':
+ submission_id = context.user_data.get('waiting_for_submission_score')
+ if submission_id:
+ # Если пользователь отправил текст "отмена", отменяем
+ if message_text and message_text.lower() in ['отмена', 'cancel', '/cancel']:
+ context.user_data.pop('waiting_for_submission_score', None)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Выставление оценки отменено.",
+ reply_markup=keyboard
+ )
+ return
+ else:
+ # Обрабатываем ввод оценки
+ await self._handle_submission_grade_input(update, context, db_user, submission_id, message_text)
+ return
+
+ submission_id = context.user_data.get('waiting_for_submission_feedback')
+ if submission_id:
+ # Если пользователь отправил текст "отмена", отменяем
+ if message_text and message_text.lower() in ['отмена', 'cancel', '/cancel']:
+ context.user_data.pop('waiting_for_submission_feedback', None)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Редактирование ответа отменено.",
+ reply_markup=keyboard
+ )
+ return
+ else:
+ # Обрабатываем ввод комментария
+ await self._handle_submission_feedback_input(update, context, db_user, submission_id, message_text)
+ return
+
+ submission_id = context.user_data.get('waiting_for_return_revision')
+ if submission_id:
+ if message_text and message_text.lower() in ['отмена', 'cancel', '/cancel']:
+ context.user_data.pop('waiting_for_return_revision', None)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text("❌ Возврат на доработку отменён.", reply_markup=keyboard)
+ return
+ else:
+ await self._handle_return_revision_input(update, context, db_user, submission_id, message_text or '')
+ return
+
+ # Проверяем, ожидается ли загрузка решения для ДЗ
+ if db_user:
+ homework_id = context.user_data.get('waiting_for_homework_file')
+ if homework_id:
+ # Если пользователь отправил текст "отмена", отменяем загрузку
+ if message_text and message_text.lower() in ['отмена', 'cancel', '/cancel']:
+ context.user_data.pop('waiting_for_homework_file', None)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "❌ Загрузка решения отменена.",
+ reply_markup=keyboard
+ )
+ return
+ else:
+ # Обрабатываем текстовое решение
+ await self._handle_text_homework_submission(update, context, db_user, homework_id, message_text)
+ return
+
+ # Список заданий в панели: нажатие на задание или «Назад в меню»
+ if db_user and db_user.role == 'client':
+ # Обработка кнопок управления конкретным ДЗ
+ if message_text == "🏠 В главное меню":
+ context.user_data.pop('homework_list_active', None)
+ context.user_data.pop('homework_buttons', None)
+ context.user_data.pop('current_homework_id', None)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text("Возврат в главное меню.", reply_markup=keyboard)
+ return
+
+ if message_text == "◀️ Назад к списку":
+ # Возврат к списку заданий
+ from apps.homework.models import Homework
+
+ def get_homeworks():
+ return list(Homework.objects.filter(
+ assigned_to=db_user,
+ deadline__gte=timezone.now()
+ ).order_by('deadline')[:5])
+
+ homeworks = await sync_to_async(get_homeworks)()
+ if homeworks:
+ homework_keyboard, homework_buttons = make_homework_list_keyboard(homeworks)
+ context.user_data['homework_buttons'] = homework_buttons
+ context.user_data['homework_list_active'] = True
+ context.user_data.pop('current_homework_id', None)
+ await update.message.reply_text(
+ "📝 Домашние задания\n\nВыберите задание кнопкой в панели ниже:",
+ parse_mode='HTML',
+ reply_markup=homework_keyboard
+ )
+ else:
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text("📝 У вас нет активных домашних заданий.", reply_markup=keyboard)
+ return
+
+ if message_text == "📎 Загрузить решение":
+ homework_id = context.user_data.get('current_homework_id')
+ if homework_id:
+ context.user_data['waiting_for_homework_file'] = homework_id
+ await update.message.reply_text(
+ "📎 Загрузка решения\n\n"
+ "Отправьте файл с решением (документ, фото) или текстовое решение.\n\n"
+ "Или отправьте 'отмена' для отмены.",
+ parse_mode='HTML'
+ )
+ return
+
+ if message_text == "👁️ Просмотреть решение":
+ homework_id = context.user_data.get('current_homework_id')
+ if homework_id:
+ from apps.homework.models import HomeworkSubmission
+ # select_related чтобы не было lazy load в async и комментарии точно подгружены
+ submission = await sync_to_async(
+ lambda: HomeworkSubmission.objects.select_related('homework').filter(
+ homework_id=homework_id, student=db_user
+ ).first()
+ )()
+ if submission:
+ await self._show_submission_view(update, context, submission, db_user)
+ return
+
+ # Обработка выбора задания из списка
+ if context.user_data.get('homework_list_active'):
+ homework_buttons = context.user_data.get('homework_buttons') or {}
+ if message_text == HOMEWORK_LIST_BACK_BUTTON:
+ context.user_data.pop('homework_list_active', None)
+ context.user_data.pop('homework_buttons', None)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text("Возврат в главное меню.", reply_markup=keyboard)
+ return
+ if message_text in homework_buttons:
+ homework_id = homework_buttons[message_text]
+ context.user_data['current_homework_id'] = homework_id
+ msg, reply_markup = await self._get_client_homework_detail(homework_id, db_user)
+ if msg and reply_markup:
+ await update.message.reply_text(msg, parse_mode='HTML', reply_markup=reply_markup)
+ else:
+ await update.message.reply_text("❌ Задание не найдено или у вас нет доступа.")
+ return
+
+ # Мой прогресс: выбор предмета → период → результат
+ if db_user and db_user.role == 'client':
+ progress_step = context.user_data.get('progress_step')
+ if progress_step == 'subject':
+ if message_text == PROGRESS_BACK_TO_MENU:
+ context.user_data.pop('progress_step', None)
+ context.user_data.pop('progress_subjects', None)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text("Возврат в меню.", reply_markup=keyboard)
+ return
+ subjects = context.user_data.get('progress_subjects') or []
+ if message_text in subjects:
+ context.user_data['progress_subject'] = message_text
+ context.user_data['progress_step'] = 'period'
+ period_keyboard = ReplyKeyboardMarkup(
+ [
+ [KeyboardButton(PROGRESS_PERIOD_WEEK)],
+ [KeyboardButton(PROGRESS_PERIOD_30)],
+ [KeyboardButton(PROGRESS_PERIOD_ALL)],
+ [KeyboardButton(PROGRESS_BACK_SUBJECT), KeyboardButton(PROGRESS_BACK_TO_MENU)],
+ ],
+ resize_keyboard=True,
+ one_time_keyboard=False,
+ )
+ await update.message.reply_text(
+ f"📊 Предмет: {message_text}\n\nВыберите период:",
+ parse_mode='HTML',
+ reply_markup=period_keyboard
+ )
+ return
+ elif progress_step == 'period':
+ if message_text == PROGRESS_BACK_TO_MENU:
+ context.user_data.pop('progress_step', None)
+ context.user_data.pop('progress_subjects', None)
+ context.user_data.pop('progress_subject', None)
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text("Возврат в меню.", reply_markup=keyboard)
+ return
+ if message_text == PROGRESS_BACK_SUBJECT:
+ from apps.schedule.models import Lesson
+ def _get_subject_names():
+ lessons = Lesson.objects.filter(client__user=db_user).select_related('subject', 'mentor_subject')
+ names = set()
+ for lec in lessons:
+ name = lec.subject_name or (lec.subject.name if lec.subject else '') or (lec.mentor_subject.name if lec.mentor_subject else '') or 'Без предмета'
+ if name and name.strip():
+ names.add(name.strip())
+ return sorted(names)
+ subject_names = await sync_to_async(_get_subject_names)()
+ context.user_data['progress_step'] = 'subject'
+ context.user_data['progress_subjects'] = subject_names
+ context.user_data.pop('progress_subject', None)
+ rows = [[KeyboardButton(n)] for n in subject_names]
+ rows.append([KeyboardButton(PROGRESS_BACK_TO_MENU)])
+ keyboard = ReplyKeyboardMarkup(rows, resize_keyboard=True, one_time_keyboard=False)
+ await update.message.reply_text(
+ "📊 Выберите предмет:",
+ parse_mode='HTML',
+ reply_markup=keyboard
+ )
+ return
+ if message_text in (PROGRESS_PERIOD_WEEK, PROGRESS_PERIOD_30, PROGRESS_PERIOD_ALL):
+ subject_name = context.user_data.get('progress_subject', '')
+ await self._progress_send_result(update, context, db_user, subject_name, message_text)
+ return
+
+ # Обработка нажатий на кнопки
+ if message_text == "📅 Расписание" or message_text == "📅 Моё расписание" or message_text == "📅 Расписание детей":
+ await self.schedule_command(update, context)
+ elif message_text == "📚 Следующее занятие":
+ await self.nextlesson_command(update, context)
+ elif message_text == "📝 Домашние задания" or message_text == "📝 Мои задания" or message_text == "📝 Задания детей":
+ await self.homework_command(update, context)
+ elif message_text == "📊 Мой прогресс":
+ await self.progress_command(update, context)
+ elif message_text == "👥 Клиенты":
+ await self.clients_command(update, context)
+ elif message_text == "📊 Статистика":
+ await self.stats_command(update, context)
+ elif message_text == "⚙️ Настройки":
+ await self.settings_command(update, context)
+ elif message_text == "ℹ️ Статус":
+ await self.status_command(update, context)
+ elif message_text == "❓ Помощь":
+ await self.help_command(update, context)
+ elif message_text == "🔗 Связать аккаунт":
+ await update.message.reply_text(
+ "🔗 Связывание аккаунта\n\n"
+ "1. Войдите на платформу\n"
+ "2. Перейдите в Профиль → Настройки → Telegram\n"
+ "3. Нажмите \"Связать Telegram\"\n"
+ "4. Отправьте команду: /link ВАШ_КОД\n\n"
+ "Или используйте команду:\n"
+ "/link <ваш_код_связывания>",
+ parse_mode='HTML'
+ )
+ else:
+ # Если аккаунт связан, показываем клавиатуру
+ if db_user:
+ keyboard = get_main_keyboard(db_user.role)
+ await update.message.reply_text(
+ "Используйте кнопки или команды для работы с ботом.\n\n"
+ "Введите /help для списка команд.",
+ reply_markup=keyboard
+ )
+ else:
+ keyboard = get_main_keyboard(None)
+ await update.message.reply_text(
+ "Используйте кнопки или команды для работы с ботом.\n\n"
+ "Введите /help для списка команд.",
+ reply_markup=keyboard
+ )
+
+ async def handle_document(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик загрузки документов (для решений ДЗ)."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ await update.message.reply_text(
+ "❌ Аккаунт не связан.\n\n"
+ "Используйте /link <код> для связывания аккаунта."
+ )
+ return
+
+ # Проверяем, ожидается ли загрузка файла для ДЗ
+ homework_id = context.user_data.get('waiting_for_homework_file')
+ if not homework_id:
+ await update.message.reply_text(
+ "📎 Чтобы загрузить файл для решения ДЗ:\n\n"
+ "1. Используйте команду /homework\n"
+ "2. Выберите задание\n"
+ "3. Нажмите кнопку '📎 Загрузить решение'"
+ )
+ return
+
+ # Получаем файл
+ document = update.message.document
+ if not document:
+ await update.message.reply_text("❌ Файл не найден.")
+ return
+
+ try:
+ # Скачиваем файл из Telegram
+ from telegram import Bot
+ bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
+ file = await bot.get_file(document.file_id)
+
+ # Получаем домашнее задание для проверок
+ from apps.homework.models import Homework, HomeworkSubmission
+ homework = await sync_to_async(Homework.objects.get)(id=homework_id)
+
+ # Безопасность: Проверка размера файла (максимум 50MB для Telegram, но проверяем и настройки задания)
+ MAX_TELEGRAM_FILE_SIZE = 50 * 1024 * 1024 # 50 MB - максимум для Telegram
+ file_size = document.file_size or 0
+
+ if file_size > MAX_TELEGRAM_FILE_SIZE:
+ await update.message.reply_text(
+ f"❌ Файл слишком большой. Максимальный размер: {MAX_TELEGRAM_FILE_SIZE / (1024*1024):.0f} MB"
+ )
+ await bot.close()
+ return
+
+ # Проверяем размер файла согласно настройкам задания
+ if homework.max_file_size > 0 and file_size > homework.max_file_size:
+ max_size_mb = homework.max_file_size / (1024 * 1024)
+ await update.message.reply_text(
+ f"❌ Файл слишком большой. Максимальный размер для этого задания: {max_size_mb:.1f} MB"
+ )
+ await bot.close()
+ return
+
+ # Безопасность: Проверка типа файла
+ from apps.homework.utils import validate_file_type, sanitize_filename
+ safe_filename = sanitize_filename(document.file_name or 'file')
+
+ if homework.allowed_file_types and not validate_file_type(safe_filename, homework.allowed_file_types):
+ allowed = homework.allowed_file_types.replace(',', ', ')
+ await update.message.reply_text(
+ f"❌ Тип файла не разрешен.\n\n"
+ f"Разрешенные типы: {allowed}"
+ )
+ await bot.close()
+ return
+
+ # Создаем временный файл
+ import tempfile
+ import os
+ from django.core.files import File
+
+ file_ext = os.path.splitext(safe_filename)[1]
+ tmp_file_path = None
+ try:
+ with tempfile.NamedTemporaryFile(delete=False, suffix=file_ext) as tmp_file:
+ tmp_file_path = tmp_file.name
+ await file.download_to_drive(tmp_file_path)
+
+ # Проверяем, что пользователь имеет право сдавать это ДЗ
+ if db_user.role != 'client' or db_user not in await sync_to_async(list)(homework.assigned_to.all()):
+ await update.message.reply_text(
+ "❌ У вас нет доступа к этому заданию."
+ )
+ await bot.close()
+ return
+
+ # Проверяем, есть ли уже решение
+ existing_submission = await sync_to_async(
+ HomeworkSubmission.objects.filter(
+ homework=homework,
+ student=db_user
+ ).order_by('-attempt_number').first
+ )()
+
+ if existing_submission and existing_submission.status != 'returned':
+ # Обновляем существующее решение
+ with open(tmp_file_path, 'rb') as f:
+ django_file = File(f, name=safe_filename)
+ existing_submission.attachment = django_file
+ existing_submission.content = f"Решение загружено через Telegram: {safe_filename}"
+ existing_submission.status = 'pending'
+ await sync_to_async(existing_submission.save)()
+
+ await update.message.reply_text(
+ f"✅ Решение обновлено!\n\n"
+ f"📎 Файл: {safe_filename}\n"
+ f"📝 Задание: {homework.title}\n\n"
+ f"Решение отправлено на проверку.",
+ parse_mode='HTML'
+ )
+ else:
+ # Определяем номер попытки
+ attempt_number = 1
+ if existing_submission:
+ attempt_number = existing_submission.attempt_number + 1
+
+ # Создаем новое решение
+ with open(tmp_file_path, 'rb') as f:
+ django_file = File(f, name=safe_filename)
+ submission = HomeworkSubmission(
+ homework=homework,
+ student=db_user,
+ content=f"Решение загружено через Telegram: {safe_filename}",
+ attachment=django_file,
+ status='pending',
+ attempt_number=attempt_number
+ )
+ await sync_to_async(submission.save)()
+
+ # Проверяем опоздание
+ await sync_to_async(submission.check_if_late)()
+
+ # Обновляем статистику задания
+ await sync_to_async(homework.update_statistics)()
+
+ await update.message.reply_text(
+ f"✅ Решение загружено!\n\n"
+ f"📎 Файл: {safe_filename}\n"
+ f"📝 Задание: {homework.title}\n\n"
+ f"Решение отправлено на проверку.",
+ parse_mode='HTML'
+ )
+
+ # Отправляем уведомление ментору
+ from apps.notifications.services import NotificationService
+ await sync_to_async(NotificationService.create_notification_with_telegram)(
+ recipient=homework.mentor,
+ notification_type='homework_submitted',
+ title='📝 ДЗ сдано',
+ message=f'{db_user.get_full_name()} сдал ДЗ "{homework.title}"',
+ priority='normal',
+ action_url=f'/homework/{homework.id}/',
+ content_object=homework
+ )
+
+ # Очищаем состояние ожидания
+ context.user_data.pop('waiting_for_homework_file', None)
+
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "Используйте кнопки для навигации:",
+ reply_markup=keyboard
+ )
+ finally:
+ # Гарантированно удаляем временный файл
+ if tmp_file_path and os.path.exists(tmp_file_path):
+ try:
+ os.unlink(tmp_file_path)
+ except Exception as e:
+ logger.error(f"Error deleting temp file {tmp_file_path}: {e}")
+
+ await bot.close()
+
+ except Homework.DoesNotExist:
+ await update.message.reply_text(
+ "❌ Домашнее задание не найдено."
+ )
+ context.user_data.pop('waiting_for_homework_file', None)
+ except Exception as e:
+ logger.error(f"Error handling document upload: {e}", exc_info=True)
+ await update.message.reply_text(
+ "❌ Ошибка загрузки файла.\n\n"
+ "Попробуйте позже или обратитесь в поддержку."
+ )
+ context.user_data.pop('waiting_for_homework_file', None)
+
+ async def handle_photo(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Обработчик загрузки фото (для решений ДЗ)."""
+ user = update.effective_user
+ telegram_id = user.id
+
+ from apps.users.models import User
+ db_user = await sync_to_async(User.objects.filter(telegram_id=telegram_id).first)()
+
+ if not db_user:
+ await update.message.reply_text(
+ "❌ Аккаунт не связан.\n\n"
+ "Используйте /link <код> для связывания аккаунта."
+ )
+ return
+
+ # Проверяем, ожидается ли загрузка файла для ДЗ
+ homework_id = context.user_data.get('waiting_for_homework_file')
+ if not homework_id:
+ await update.message.reply_text(
+ "📎 Чтобы загрузить фото для решения ДЗ:\n\n"
+ "1. Используйте команду /homework\n"
+ "2. Выберите задание\n"
+ "3. Нажмите кнопку '📎 Загрузить решение'"
+ )
+ return
+
+ # Получаем фото (берем самое большое)
+ photos = update.message.photo
+ if not photos:
+ await update.message.reply_text("❌ Фото не найдено.")
+ return
+
+ photo = photos[-1] # Самое большое фото
+
+ try:
+ # Скачиваем фото из Telegram
+ from telegram import Bot
+ bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
+ file = await bot.get_file(photo.file_id)
+
+ # Получаем домашнее задание для проверок
+ from apps.homework.models import Homework, HomeworkSubmission
+ homework = await sync_to_async(Homework.objects.get)(id=homework_id)
+
+ # Безопасность: Проверка размера файла (максимум 50MB для Telegram)
+ MAX_TELEGRAM_FILE_SIZE = 50 * 1024 * 1024 # 50 MB - максимум для Telegram
+ file_size = photo.file_size or 0
+
+ if file_size > MAX_TELEGRAM_FILE_SIZE:
+ await update.message.reply_text(
+ f"❌ Фото слишком большое. Максимальный размер: {MAX_TELEGRAM_FILE_SIZE / (1024*1024):.0f} MB"
+ )
+ await bot.close()
+ return
+
+ # Проверяем размер файла согласно настройкам задания
+ if homework.max_file_size > 0 and file_size > homework.max_file_size:
+ max_size_mb = homework.max_file_size / (1024 * 1024)
+ await update.message.reply_text(
+ f"❌ Фото слишком большое. Максимальный размер для этого задания: {max_size_mb:.1f} MB"
+ )
+ await bot.close()
+ return
+
+ # Безопасность: Проверка типа файла (фото должно быть разрешено)
+ from apps.homework.utils import validate_file_type, sanitize_filename
+ if homework.allowed_file_types and not validate_file_type('photo.jpg', homework.allowed_file_types):
+ allowed = homework.allowed_file_types.replace(',', ', ')
+ await update.message.reply_text(
+ f"❌ Фото не разрешено для этого задания.\n\n"
+ f"Разрешенные типы: {allowed}"
+ )
+ await bot.close()
+ return
+
+ # Создаем временный файл
+ import tempfile
+ import os
+ from django.core.files import File
+
+ tmp_file_path = None
+ try:
+ with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_file:
+ tmp_file_path = tmp_file.name
+ await file.download_to_drive(tmp_file_path)
+
+ # Проверяем, что пользователь имеет право сдавать это ДЗ
+ if db_user.role != 'client' or db_user not in await sync_to_async(list)(homework.assigned_to.all()):
+ await update.message.reply_text(
+ "❌ У вас нет доступа к этому заданию."
+ )
+ await bot.close()
+ return
+
+ from django.utils import timezone as tz
+ from apps.homework.utils import sanitize_filename
+ filename = sanitize_filename(f"photo_{homework.id}_{tz.now().strftime('%Y%m%d_%H%M%S')}.jpg")
+
+ # Проверяем, есть ли уже решение
+ existing_submission = await sync_to_async(
+ HomeworkSubmission.objects.filter(
+ homework=homework,
+ student=db_user
+ ).order_by('-attempt_number').first
+ )()
+
+ if existing_submission and existing_submission.status != 'returned':
+ # Обновляем существующее решение
+ with open(tmp_file_path, 'rb') as f:
+ django_file = File(f, name=filename)
+ existing_submission.attachment = django_file
+ existing_submission.content = f"Решение загружено через Telegram (фото)"
+ existing_submission.status = 'pending'
+ await sync_to_async(existing_submission.save)()
+
+ await update.message.reply_text(
+ f"✅ Решение обновлено!\n\n"
+ f"📎 Фото загружено\n"
+ f"📝 Задание: {homework.title}\n\n"
+ f"Решение отправлено на проверку.",
+ parse_mode='HTML'
+ )
+ else:
+ # Определяем номер попытки
+ attempt_number = 1
+ if existing_submission:
+ attempt_number = existing_submission.attempt_number + 1
+
+ # Создаем новое решение
+ with open(tmp_file_path, 'rb') as f:
+ django_file = File(f, name=filename)
+ submission = HomeworkSubmission(
+ homework=homework,
+ student=db_user,
+ content="Решение загружено через Telegram (фото)",
+ attachment=django_file,
+ status='pending',
+ attempt_number=attempt_number
+ )
+ await sync_to_async(submission.save)()
+
+ # Проверяем опоздание
+ await sync_to_async(submission.check_if_late)()
+
+ # Обновляем статистику задания
+ await sync_to_async(homework.update_statistics)()
+
+ await update.message.reply_text(
+ f"✅ Решение загружено!\n\n"
+ f"📎 Фото загружено\n"
+ f"📝 Задание: {homework.title}\n\n"
+ f"Решение отправлено на проверку.",
+ parse_mode='HTML'
+ )
+
+ # Отправляем уведомление ментору
+ from apps.notifications.services import NotificationService
+ await sync_to_async(NotificationService.create_notification_with_telegram)(
+ recipient=homework.mentor,
+ notification_type='homework_submitted',
+ title='📝 ДЗ сдано',
+ message=f'{db_user.get_full_name()} сдал ДЗ "{homework.title}"',
+ priority='normal',
+ action_url=f'/homework/{homework.id}/',
+ content_object=homework
+ )
+
+ # Очищаем состояние ожидания
+ context.user_data.pop('waiting_for_homework_file', None)
+
+ keyboard = await get_user_keyboard(telegram_id)
+ await update.message.reply_text(
+ "Используйте кнопки для навигации:",
+ reply_markup=keyboard
+ )
+ finally:
+ # Гарантированно удаляем временный файл
+ if tmp_file_path and os.path.exists(tmp_file_path):
+ try:
+ os.unlink(tmp_file_path)
+ except Exception as e:
+ logger.error(f"Error deleting temp file {tmp_file_path}: {e}")
+
+ await bot.close()
+
+ except Homework.DoesNotExist:
+ await update.message.reply_text(
+ "❌ Домашнее задание не найдено."
+ )
+ context.user_data.pop('waiting_for_homework_file', None)
+ except Exception as e:
+ logger.error(f"Error handling photo upload: {e}", exc_info=True)
+ await update.message.reply_text(
+ "❌ Ошибка загрузки фото.\n\n"
+ "Попробуйте позже или обратитесь в поддержку."
+ )
+ context.user_data.pop('waiting_for_homework_file', None)
+
+ def setup_handlers(self):
+ """Настройка обработчиков команд."""
+ if not self.application:
+ return
+
+ # Команды
+ self.application.add_handler(CommandHandler("start", self.start_command))
+ self.application.add_handler(CommandHandler("help", self.help_command))
+ self.application.add_handler(CommandHandler("link", self.link_command))
+ self.application.add_handler(CommandHandler("unlink", self.unlink_command))
+ self.application.add_handler(CommandHandler("status", self.status_command))
+ self.application.add_handler(CommandHandler("settings", self.settings_command))
+
+ # Команды для клиентов и менторов
+ self.application.add_handler(CommandHandler("schedule", self.schedule_command))
+ self.application.add_handler(CommandHandler("nextlesson", self.nextlesson_command))
+ self.application.add_handler(CommandHandler("homework", self.homework_command))
+
+ # Команды для клиентов
+ self.application.add_handler(CommandHandler("progress", self.progress_command))
+
+ # Команды для менторов
+ self.application.add_handler(CommandHandler("clients", self.clients_command))
+ self.application.add_handler(CommandHandler("stats", self.stats_command))
+
+ # Кнопки
+ self.application.add_handler(CallbackQueryHandler(self.button_callback))
+
+ # Обычные сообщения
+ self.application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, self.handle_message))
+
+ # Загрузка документов (для решений ДЗ)
+ self.application.add_handler(MessageHandler(filters.Document.ALL, self.handle_document))
+
+ # Загрузка фото (для решений ДЗ)
+ self.application.add_handler(MessageHandler(filters.PHOTO, self.handle_photo))
+
+ async def start(self):
+ """Запуск бота."""
+ if not self.token:
+ logger.error("TELEGRAM_BOT_TOKEN not set")
+ return
+
+ logger.info("Starting Telegram bot...")
+
+ # Создаем приложение
+ self.application = Application.builder().token(self.token).build()
+
+ # Настраиваем обработчики
+ self.setup_handlers()
+
+ # Запускаем бота
+ await self.application.initialize()
+ await self.application.start()
+
+ if self.use_webhook and self.webhook_url:
+ # Используем webhook режим
+ logger.info(f"Setting up webhook: {self.webhook_url}")
+ await self.setup_webhook()
+ else:
+ # Используем polling режим
+ logger.info("Starting bot in polling mode...")
+ await self.application.updater.start_polling()
+
+ logger.info("Telegram bot started successfully")
+
+ async def setup_webhook(self):
+ """Настройка webhook для бота."""
+ from telegram import Bot
+ from telegram.error import TelegramError
+
+ try:
+ bot = Bot(token=self.token)
+
+ # Устанавливаем webhook
+ webhook_kwargs = {
+ 'url': self.webhook_url,
+ 'allowed_updates': ['message', 'callback_query', 'inline_query', 'chosen_inline_result'],
+ }
+
+ # Добавляем secret token если указан
+ if self.webhook_secret_token:
+ webhook_kwargs['secret_token'] = self.webhook_secret_token
+
+ await bot.set_webhook(**webhook_kwargs)
+
+ # Проверяем информацию о webhook
+ webhook_info = await bot.get_webhook_info()
+ logger.info(f"Webhook info: {webhook_info.url}, pending updates: {webhook_info.pending_update_count}")
+
+ await bot.close()
+ logger.info("Webhook set successfully")
+ except TelegramError as e:
+ logger.error(f"Error setting webhook: {e}")
+ raise
+ except Exception as e:
+ logger.error(f"Unexpected error setting webhook: {e}")
+ raise
+
+ async def remove_webhook(self):
+ """Удаление webhook."""
+ from telegram import Bot
+ from telegram.error import TelegramError
+
+ try:
+ bot = Bot(token=self.token)
+ await bot.delete_webhook(drop_pending_updates=True)
+ await bot.close()
+ logger.info("Webhook removed successfully")
+ except TelegramError as e:
+ logger.error(f"Error removing webhook: {e}")
+ raise
+ except Exception as e:
+ logger.error(f"Unexpected error removing webhook: {e}")
+ raise
+
+ async def stop(self):
+ """Остановка бота."""
+ if self.application:
+ logger.info("Stopping Telegram bot...")
+ if not self.use_webhook:
+ # Останавливаем polling только если не используем webhook
+ await self.application.updater.stop()
+ await self.application.stop()
+ await self.application.shutdown()
+ logger.info("Telegram bot stopped")
+
+ def get_application(self):
+ """
+ Получить экземпляр Application для обработки webhook.
+
+ ВАЖНО: Этот метод создает приложение, но не инициализирует его.
+ Инициализация происходит в process_webhook_update при первом запросе.
+ """
+ if not self.application:
+ if not self.token:
+ logger.error("TELEGRAM_BOT_TOKEN not set")
+ return None
+
+ # Создаем приложение
+ self.application = Application.builder().token(self.token).build()
+
+ # Настраиваем обработчики
+ self.setup_handlers()
+
+ logger.info("Bot application created for webhook")
+
+ return self.application
+
+ async def process_webhook_update(self, update: Update):
+ """
+ Обработать update от webhook.
+
+ Args:
+ update: Update объект от Telegram
+ """
+ # Получаем или создаем приложение
+ if not self.application:
+ self.get_application()
+
+ if not self.application:
+ logger.error("Failed to initialize bot application")
+ return False
+
+ try:
+ # Убеждаемся что приложение инициализировано и запущено
+ # Для webhook режима мы не используем updater, только application
+ if not hasattr(self.application, '_webhook_initialized'):
+ await self.application.initialize()
+ await self.application.start()
+ self.application._webhook_initialized = True
+ logger.info("Bot application initialized for webhook")
+
+ # Обрабатываем update
+ await self.application.process_update(update)
+ return True
+ except Exception as e:
+ logger.error(f"Error processing webhook update: {e}", exc_info=True)
+ return False
+
+
+# Глобальный экземпляр бота
+bot_instance = None
+
+
+async def get_bot():
+ """Получить экземпляр бота."""
+ global bot_instance
+
+ if bot_instance is None:
+ bot_instance = TelegramBot()
+ await bot_instance.start()
+
+ return bot_instance
+
+
+async def send_telegram_message(telegram_id: int, message: str, parse_mode: str = 'HTML'):
+ """
+ Отправить сообщение в Telegram.
+
+ Args:
+ telegram_id: ID пользователя в Telegram
+ message: Текст сообщения
+ parse_mode: Режим парсинга (HTML, Markdown)
+ """
+ from telegram import Bot
+ from telegram.error import TelegramError
+
+ if not settings.TELEGRAM_BOT_TOKEN:
+ logger.error("TELEGRAM_BOT_TOKEN not set")
+ return False
+
+ try:
+ # Создаем временный экземпляр бота для отправки сообщения
+ bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
+ await bot.send_message(
+ chat_id=telegram_id,
+ text=message,
+ parse_mode=parse_mode
+ )
+ await bot.close()
+ logger.info(f"Telegram message sent to {telegram_id}")
+ return True
+ except TelegramError as e:
+ logger.error(f"Telegram API error sending message to {telegram_id}: {e}")
+ return False
+ except Exception as e:
+ logger.error(f"Error sending Telegram message to {telegram_id}: {e}")
+ return False
+
+
+async def send_telegram_message_with_buttons(telegram_id: int, message: str, reply_markup, parse_mode: str = 'HTML'):
+ """
+ Отправить сообщение в Telegram с кнопками.
+
+ Args:
+ telegram_id: ID пользователя в Telegram
+ message: Текст сообщения
+ reply_markup: InlineKeyboardMarkup с кнопками
+ parse_mode: Режим парсинга (HTML, Markdown)
+ """
+ from telegram import Bot
+ from telegram.error import TelegramError
+
+ if not settings.TELEGRAM_BOT_TOKEN:
+ logger.error("TELEGRAM_BOT_TOKEN not set")
+ return False
+
+ try:
+ bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
+ await bot.send_message(
+ chat_id=telegram_id,
+ text=message,
+ parse_mode=parse_mode,
+ reply_markup=reply_markup
+ )
+ await bot.close()
+ logger.info(f"Telegram message with buttons sent to {telegram_id}")
+ return True
+ except TelegramError as e:
+ logger.error(f"Telegram API error sending message with buttons to {telegram_id}: {e}")
+ return False
+ except Exception as e:
+ logger.error(f"Error sending Telegram message with buttons to {telegram_id}: {e}")
+ return False
+
diff --git a/backend/apps/referrals/views.py b/backend/apps/referrals/views.py
index 402081d..d88fdd7 100644
--- a/backend/apps/referrals/views.py
+++ b/backend/apps/referrals/views.py
@@ -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}'
diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py
index feae832..e25ad6f 100644
--- a/backend/apps/users/models.py
+++ b/backend/apps/users/models.py
@@ -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:
- self.universal_code = self._generate_universal_code()
+ try:
+ self.universal_code = self._generate_universal_code()
+ except Exception:
+ # Если не удалось сгенерировать, не прерываем сохранение
+ # Код будет сгенерирован при следующем запросе профиля или в RegisterView
+ pass
super().save(*args, **kwargs)
diff --git a/backend/apps/users/nav_badges_views.py b/backend/apps/users/nav_badges_views.py
index 8f39432..2561ce0 100644
--- a/backend/apps/users/nav_badges_views.py
+++ b/backend/apps/users/nav_badges_views.py
@@ -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
diff --git a/backend/apps/users/serializers.py b/backend/apps/users/serializers.py
index f1bd9c4..abd9b28 100644
--- a/backend/apps/users/serializers.py
+++ b/backend/apps/users/serializers.py
@@ -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)
diff --git a/backend/apps/users/templates/emails/base.html b/backend/apps/users/templates/emails/base.html
index 828c588..dedf85d 100644
--- a/backend/apps/users/templates/emails/base.html
+++ b/backend/apps/users/templates/emails/base.html
@@ -1,64 +1,64 @@
-
-
-
-
-
-
- {% block title %}Uchill{% endblock %}
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
- |
-
-
-
-
- |
- {% block content %}{% endblock %}
- |
-
-
-
-
-
-
-
- |
- С уважением, Команда Uchill
-
- © {% now "Y" %} Uchill. Все права защищены.
-
- |
-
-
- |
-
-
- |
-
-
-
-
+
+
+
+
+
+
+ {% block title %}Uchill{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+
+
+ |
+ {% block content %}{% endblock %}
+ |
+
+
+
+
+
+
+
+ |
+ С уважением, Команда Uchill
+
+ © {% now "Y" %} Uchill. Все права защищены.
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
diff --git a/backend/apps/users/views.py b/backend/apps/users/views.py
index a76eea8..0d705d0 100644
--- a/backend/apps/users/views.py
+++ b/backend/apps/users/views.py
@@ -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({
diff --git a/docker-compose.yml b/docker-compose.yml
index 1ca80bb..add18bf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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"
diff --git a/docker/livekit/livekit-config.yaml b/docker/livekit/livekit-config.yaml
new file mode 100644
index 0000000..59c8c43
--- /dev/null
+++ b/docker/livekit/livekit-config.yaml
@@ -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
diff --git a/docker/nginx/conf.d/default.conf b/docker/nginx/conf.d/default.conf
index ee659a9..1ca36d6 100644
--- a/docker/nginx/conf.d/default.conf
+++ b/docker/nginx/conf.d/default.conf
@@ -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
diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf
index ab30df2..2412df5 100644
--- a/docker/nginx/nginx.conf
+++ b/docker/nginx/nginx.conf
@@ -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;
+ }
# ==============================================
# ВКЛЮЧЕНИЕ КОНФИГУРАЦИЙ САЙТОВ
diff --git a/front_material/api/referrals.ts b/front_material/api/referrals.ts
index 490f166..1459be8 100644
--- a/front_material/api/referrals.ts
+++ b/front_material/api/referrals.ts
@@ -41,3 +41,24 @@ export async function getReferralStats(): Promise {
export async function setReferrer(referralCode: string): Promise {
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 {
+ const response = await apiClient.get('/referrals/my_referrals/');
+ return response.data;
+}
diff --git a/front_material/app/(auth)/register/page.tsx b/front_material/app/(auth)/register/page.tsx
index f19e907..841931f 100644
--- a/front_material/app/(auth)/register/page.tsx
+++ b/front_material/app/(auth)/register/page.tsx
@@ -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: 'Студент' },
diff --git a/front_material/app/(protected)/layout.tsx b/front_material/app/(protected)/layout.tsx
index 3fbee5e..f3b678e 100644
--- a/front_material/app/(protected)/layout.tsx
+++ b/front_material/app/(protected)/layout.tsx
@@ -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') {
diff --git a/front_material/app/(protected)/my-progress/page.tsx b/front_material/app/(protected)/my-progress/page.tsx
index 1f6c79c..08c49e3 100644
--- a/front_material/app/(protected)/my-progress/page.tsx
+++ b/front_material/app/(protected)/my-progress/page.tsx
@@ -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'),
};
diff --git a/front_material/components/chat/ChatWindow.tsx b/front_material/components/chat/ChatWindow.tsx
index e4d47b9..3c056dd 100644
--- a/front_material/components/chat/ChatWindow.tsx
+++ b/front_material/components/chat/ChatWindow.tsx
@@ -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}`;
diff --git a/front_material/components/homework/HomeworkDetailsModal.tsx b/front_material/components/homework/HomeworkDetailsModal.tsx
index a547c11..030055c 100644
--- a/front_material/components/homework/HomeworkDetailsModal.tsx
+++ b/front_material/components/homework/HomeworkDetailsModal.tsx
@@ -793,6 +793,30 @@ export function HomeworkDetailsModal({ isOpen, homework, userRole, childId, onCl
Оценка: {mySubmission.score} / 5
)}
+ {userRole === 'client' && mySubmission.status === 'returned' && (
+
+
+
+ )}
{userRole === 'client' && SHOW_DELETE_SUBMISSION_FOR_STUDENT && (