uchill/backend/apps/notifications/telegram_bot.py

4179 lines
214 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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"✅ <b>Аккаунт связан</b>\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 = "⚙️ <b>Настройки</b>\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"📎 <b>Загрузка решения</b>\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"📝 <b>{homework.title}</b>\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✅ <b>Статус: Сдано</b>\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⏳ <b>Статус: Не сдано</b>\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"👁️ <b>Решение ДЗ</b>\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💬 <b>Комментарий ментора:</b>\n<i>{_html.escape(feedback)}</i>\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🤖 <b>Комментарий ИИ:</b>\n<i>{_html.escape(ai_feedback)}</i>\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"📝 <b>{data['hw_title']}</b>\n\n"
message += f"👤 <b>Студент:</b> {data['student_name']}\n"
message += f"📅 <b>Сдано:</b> {submitted_at}\n"
message += f"📊 <b>Статус:</b> {data['status_display']}\n\n"
# Текст, который отправил студент
message += "📄 <b>Текст от студента:</b>\n"
if data['content']:
content_preview = data['content'][:1500] + "" if len(data['content']) > 1500 else data['content']
message += "<i>" + html.escape(content_preview) + "</i>\n\n"
else:
message += "<i>Студент текста не написал.</i>\n\n"
# Все прикреплённые файлы (основной + доп. из HomeworkFile)
message += "📎 <b>Прикреплённые файлы:</b>\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 += " <i>Файлов нет</i>\n"
if data['attachment_url']:
message += f"🔗 <b>Ссылка студента:</b> {data['attachment_url'][:100]}{'' if len(data['attachment_url']) > 100 else ''}\n"
message += "\n<i>Скачать/открыть файлы — кнопка «Открыть на сайте» ниже.</i>\n\n"
if data['status'] == 'graded' and data['score'] is not None:
message += f"🎯 <b>Оценка:</b> {data['score']}/{data['max_score']}\n\n"
message += "💬 <b>Комментарии к решению:</b>\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"🤖 <b>ИИ</b> (оценка: {ai_score_text}):\n"
ai_fb = data['ai_feedback'][:800] + "..." if len(data['ai_feedback']) > 800 else data['ai_feedback']
message += f"<i>{html.escape(ai_fb)}</i>\n\n"
has_comment = True
if data['feedback']:
message += "✏️ <b>Ваш комментарий:</b>\n"
fb = data['feedback'][:800] + "..." if len(data['feedback']) > 800 else data['feedback']
message += f"<i>{html.escape(fb)}</i>\n"
has_comment = True
if not has_comment:
message += "<i>Пока нет комментариев.</i>\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"✅ <b>Оценка опубликована!</b>\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"⭐ <b>Выставление оценки</b>\n\n"
f"📝 Задание: {hw_title}\n"
f"👤 Студент: {student_name}\n\n"
f"Введите оценку от 1 до {hw_max}{current_score_text}.\n\n"
f"Или отправьте оценку и комментарий в формате:\n"
f"<code>5\nОтличная работа!</code>",
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<i>{_html_mod.escape(cf_preview)}</i>" 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"✏️ <b>Редактировать ответ</b>\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"🔄 <b>Вернуть на доработку</b>\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 = "📝 <b>Домашние задания на проверку:</b>\n\n"
inline_keyboard = []
for item in submissions_data:
ai_status = " 🤖 ИИ проверил" if item['ai_checked_at'] else ""
message += f"<b>{item['hw_title']}</b>{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(
"📝 <b>Домашние задания</b>\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"📚 <b>{lesson.title}</b>\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🔗 <a href='{lesson.meeting_url}'>Ссылка на видеоконференцию</a>\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"👤 <b>{client_name}</b>\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"📚 <b>Занятия:</b>\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"📝 <b>Домашние задания:</b>\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💰 <b>Доходы:</b>\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 = "👥 <b>Ваши клиенты:</b>\n\n"
inline_keyboard = []
for client in clients:
client_name = client.user.get_full_name() or client.user.email
message += f"👤 <b>{client_name}</b>\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"✅ <b>Подтверждено</b>\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"❌ <b>Отменено</b>\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"✅ <b>Ответ сохранен</b>\n\n"
f"Вы подтвердили, что {response_text} на занятии:\n"
f"<b>{lesson.title}</b>\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 = "📅 <b>Ближайшие занятия:</b>\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"📚 <b>{lesson.title}</b>\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"📚 <b>{lesson.title}</b>\n"
message += f"👨‍🏫 {lesson.mentor.get_full_name()}\n"
message += f"👶 {child_name}\n"
else:
message += f"📚 <b>{lesson.title}</b>\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 = "📚 <b>Следующее занятие:</b>\n\n"
message += f"<b>{lesson.title}</b>\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"📝 <b>{homework.title}</b>\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✅ <b>Статус: Сдано</b>\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"💬 <i>{_html.escape(preview)}</i>\n"
elif submission.status == 'returned':
message += f"🔄 Возвращено на доработку\n"
else:
message += f"⏳ Ожидает проверки\n"
else:
message += f"\n⏳ <b>Статус: Не сдано</b>\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"👁️ <b>Решение ДЗ</b>\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📋 <b>Комментарий к вашей работе:</b>\n"
if mentor_fb:
feedback_esc = _html.escape(mentor_fb[:800] + "..." if len(mentor_fb) > 800 else mentor_fb)
message += f"💬 <i>{feedback_esc}</i>\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"🤖 <i>{ai_esc}</i>\n"
elif submission.status == 'graded':
message += f"\n📋 <b>Комментарий к вашей работе:</b> нет текста отзыва.\n"
message += f"\n🌐 <a href='{settings.FRONTEND_URL}/homework'>Открыть на сайте</a>"
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(
"📝 <b>Домашние задания</b>\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 = "📝 <b>Домашние задания детей:</b>\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"<b>{hw.title}</b>\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 = "📝 <b>Домашние задания на проверку:</b>\n\n"
inline_keyboard = []
for item in submissions_data:
ai_status = " 🤖 ИИ проверил" if item['ai_checked_at'] else ""
message += f"<b>{item['hw_title']}</b>{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"✅ <b>Решение обновлено!</b>\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"✅ <b>Решение загружено!</b>\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"✅ <b>Оценка выставлена!</b>\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"✅ <b>Ответ сохранён!</b>\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"🔄 <b>ДЗ возвращено на доработку</b>\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 = "⚙️ <b>Настройки</b>\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 = "🔇 <b>Режим тишины</b>\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 = "📋 <b>Типы уведомлений</b>\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 = "🕐 <b>Часовой пояс</b>\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 = "🌐 <b>Язык интерфейса</b>\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 = "👥 <b>Ваши клиенты:</b>\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"👤 <b>{client_name}</b>\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 = "📊 <b>Ваша статистика:</b>\n\n"
message += f"👥 Клиентов: {total_clients}\n\n"
message += f"📚 <b>Занятия:</b>\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"📝 <b>Домашние задания:</b>\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💰 <b>Доходы:</b>\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(
"📊 <b>Мой прогресс</b>\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"📊 <b>Прогресс: {subject_name}</b>\n\n"
f"Период: {period_label}\n"
f"Занятий: {total_lessons}\n\n"
f"📌 <b>Средняя оценка от репетитора:</b> {mentor_str}\n"
f"📌 <b>Средняя оценка от школы:</b> {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(
"📝 <b>Домашние задания</b>\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(
"📎 <b>Загрузка решения</b>\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"📊 Предмет: <b>{message_text}</b>\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(
"🔗 <b>Связывание аккаунта</b>\n\n"
"1. Войдите на платформу\n"
"2. Перейдите в Профиль → Настройки → Telegram\n"
"3. Нажмите \"Связать Telegram\"\n"
"4. Отправьте команду: /link ВАШ_КОД\n\n"
"Или используйте команду:\n"
"/link &lt;ваш_код_связывания&gt;",
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"✅ <b>Решение обновлено!</b>\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"✅ <b>Решение загружено!</b>\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"✅ <b>Решение обновлено!</b>\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"✅ <b>Решение загружено!</b>\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