""" 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('lesson_confirm_') or query.data.startswith('lesson_cancel_'): try: lesson_id = int(query.data.split('_')[-1]) is_confirmed = query.data.startswith('lesson_confirm_') 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.mentor or lesson.mentor.telegram_id != telegram_id: await query.answer("❌ Только ментор занятия может подтвердить.", show_alert=True) return if is_confirmed: # Занятие состоялось — оставляем completed await query.edit_message_text( f"✅ Подтверждено\n\n" f"Занятие «{lesson.title}» состоялось." ) else: # Занятие отменилось — меняем статус lesson.status = 'cancelled' lesson.cancelled_at = timezone.now() await sync_to_async(lesson.save)(update_fields=['status', 'cancelled_at']) await query.edit_message_text( f"❌ Отменено\n\n" f"Занятие «{lesson.title}» отмечено как отменённое." ) await query.answer() except Lesson.DoesNotExist: await query.answer("❌ Занятие не найдено", show_alert=True) except Exception as e: logger.error(f"Error handling lesson confirmation: {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