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 %} - - - - - - - - -
- - - - - - - - - - - - - - - - -
- - - - - -
- - uchill - -
-
- {% block content %}{% endblock %} -
- - - - -
-

С уважением,
Команда Uchill

-

- © {% now "Y" %} Uchill. Все права защищены. -

-
-
-
- - + + + + + + + {% block title %}Uchill{% endblock %} + + + + + + + + +
+ + + + + + + + + + + + + + + + +
+ + + + + +
+ + uchill + +
+
+ {% 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 && (