From fcf27c16394f3e34424beb83344e115223949d60 Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Thu, 11 Sep 2025 08:02:35 +0900 Subject: [PATCH] devops --- .drone.yml | 5 +- Dockerfile | 9 +- config/config.py | 8 +- data/korean_level_1.csv | 0 data/korean_level_2.csv | 0 data/korean_level_3.csv | 0 data/korean_level_4.csv | 0 data/korean_level_5.csv | 0 data/quiz_bot.db | Bin 57344 -> 57344 bytes docker-compose.yml | 15 +- requirements.txt | 11 + src/bot.py | 600 ++++++++++++++++++++++++++------------- src/database/database.py | 234 +++++++++------ 13 files changed, 585 insertions(+), 297 deletions(-) mode change 100644 => 100755 data/korean_level_1.csv mode change 100644 => 100755 data/korean_level_2.csv mode change 100644 => 100755 data/korean_level_3.csv mode change 100644 => 100755 data/korean_level_4.csv mode change 100644 => 100755 data/korean_level_5.csv mode change 100644 => 100755 data/quiz_bot.db diff --git a/.drone.yml b/.drone.yml index 272bc12..5c9dc16 100644 --- a/.drone.yml +++ b/.drone.yml @@ -12,10 +12,7 @@ trigger: - push - pull_request -# Глобальные переменные -environment: - IMAGE_NAME: quiz-bot - REGISTRY: localhost:5000 # Локальный registry или замените на ваш +# Примечание: Глобальные переменные определяются в шагах steps: # 1. Клонирование и подготовка diff --git a/Dockerfile b/Dockerfile index 6c189f7..6942707 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,10 +18,10 @@ RUN pip install --no-cache-dir --upgrade pip && \ # Production stage FROM python:3.12-slim -# Создаем пользователя для безопасности +# Создание пользователя и группы для безопасности RUN groupadd -r quizbot && useradd -r -g quizbot quizbot -# Устанавливаем системные зависимости +# Установка sqlite3 для работы с базой данных RUN apt-get update && apt-get install -y \ sqlite3 \ && rm -rf /var/lib/apt/lists/* \ @@ -34,9 +34,10 @@ ENV PATH="/opt/venv/bin:$PATH" # Создаем рабочую директорию WORKDIR /app -# Создаем необходимые директории +# Создание директорий с правильными правами доступа RUN mkdir -p /app/data /app/logs && \ - chown -R quizbot:quizbot /app + chown -R quizbot:quizbot /app && \ + chmod -R 775 /app/data /app/logs # Копируем код приложения COPY --chown=quizbot:quizbot . . diff --git a/config/config.py b/config/config.py index 1e29ed9..d9c8aca 100644 --- a/config/config.py +++ b/config/config.py @@ -1,29 +1,33 @@ import os from dataclasses import dataclass, field from typing import List + from dotenv import load_dotenv load_dotenv() + def get_admin_ids() -> List[int]: admin_str = os.getenv("ADMIN_IDS", "") if admin_str: return [int(x) for x in admin_str.split(",") if x.strip()] return [] + @dataclass class Config: bot_token: str = os.getenv("BOT_TOKEN", "") admin_ids: List[int] = field(default_factory=get_admin_ids) database_path: str = os.getenv("DATABASE_PATH", "data/quiz_bot.db") csv_data_path: str = os.getenv("CSV_DATA_PATH", "data/") - + # Настройки викторины questions_per_quiz: int = int(os.getenv("QUESTIONS_PER_QUIZ", "10")) time_per_question: int = int(os.getenv("TIME_PER_QUESTION", "30")) - + # Режимы работы guest_mode_enabled: bool = os.getenv("GUEST_MODE_ENABLED", "true").lower() == "true" test_mode_enabled: bool = os.getenv("TEST_MODE_ENABLED", "true").lower() == "true" + config = Config() diff --git a/data/korean_level_1.csv b/data/korean_level_1.csv old mode 100644 new mode 100755 diff --git a/data/korean_level_2.csv b/data/korean_level_2.csv old mode 100644 new mode 100755 diff --git a/data/korean_level_3.csv b/data/korean_level_3.csv old mode 100644 new mode 100755 diff --git a/data/korean_level_4.csv b/data/korean_level_4.csv old mode 100644 new mode 100755 diff --git a/data/korean_level_5.csv b/data/korean_level_5.csv old mode 100644 new mode 100755 diff --git a/data/quiz_bot.db b/data/quiz_bot.db old mode 100644 new mode 100755 index 2b408b7e32e31ef41dc95c78f5b4fb4b135219cf..ff82acc21bb3ac6029d603eb23b303597c468bef GIT binary patch delta 265 zcmZoTz}#?vd4e?O1O^5MJ`kR$V$C{%L9eQCW6C0awmt^_v;2LV1r3_`1uUC7mw(CP zVo=m&5H#lG;Fvr|UW47(%D~9V&~oxwd2<;a&YukYC4A5M{CW5A>hbL4appe99l`aO zE06Q%W54J>sH4HS$ZuCvhC{4GwDk(rPG7f`Dm$7Y|5-QkNR4hXOU^)m2o7Ci8U9{?0R BMxg)z delta 204 zcmZoTz}#?vd4e=&1p@;E9}x3SRIz5QV9+b;+?cY6pRI?1|15vcWV18?;c(~o}E0-+~>F> zxE^!mZ59*=;M$z0RW2&c#>>Pk&sbQRT3nKupI2PL$;d3uSOVk~b8m8B5fEVG`^Lb3 znSVKdHNPLf65ltV)<=Aszr~3%GV}8P0`lxQHv44k4rgTqxp_0=0XBin0tepl0|3Zk BI70vc diff --git a/docker-compose.yml b/docker-compose.yml index c98c439..fb7e065 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,15 +7,15 @@ services: dockerfile: Dockerfile container_name: quiz-bot restart: unless-stopped + user: "0:0" environment: - BOT_TOKEN=${BOT_TOKEN} - DATABASE_PATH=data/quiz_bot.db - CSV_DATA_PATH=data/ - LOG_LEVEL=INFO volumes: - # Персистентное хранение данных - - ./data:/app/data - - ./logs:/app/logs + - "./data:/app/data" + - "./logs:/app/logs" networks: - quiz-bot-network healthcheck: @@ -24,7 +24,12 @@ services: timeout: 10s retries: 3 start_period: 60s - # Ограничения ресурсов + command: > + sh -c " + chown -R quizbot:quizbot /app/data /app/logs && + chmod -R 775 /app/data /app/logs && + python -m src.bot + " deploy: resources: limits: @@ -34,7 +39,7 @@ services: cpus: '0.1' memory: 128M - # Опциональный сервис для мониторинга логов + # Опциональный сервис для мониторинга логов log-viewer: image: goharbor/harbor-log:v2.5.0 container_name: quiz-bot-logs diff --git a/requirements.txt b/requirements.txt index 2ad8dbb..8f61e45 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,14 @@ pandas==2.1.4 python-dotenv==1.0.0 asyncio-mqtt==0.16.1 loguru==0.7.2 +pytest==7.4.0 +pytest-asyncio==0.22.0 +black +isort +flake8 +mypy +pytest +pytest-asyncio +pytest-cov +safety +bandit \ No newline at end of file diff --git a/src/bot.py b/src/bot.py index f19b5f7..8225dda 100644 --- a/src/bot.py +++ b/src/bot.py @@ -5,16 +5,18 @@ import asyncio import logging +import os import random +import sys + from aiogram import Bot, Dispatcher, F from aiogram.filters import Command, StateFilter from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.storage.memory import MemoryStorage -from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, InaccessibleMessage +from aiogram.types import (CallbackQuery, InaccessibleMessage, + InlineKeyboardButton, InlineKeyboardMarkup, Message) from aiogram.utils.keyboard import InlineKeyboardBuilder -import sys -import os # Добавляем путь к проекту project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -27,31 +29,33 @@ from src.services.csv_service import CSVQuizLoader, QuizGenerator # Настройка логирования logging.basicConfig(level=logging.INFO) + class QuizStates(StatesGroup): choosing_mode = State() choosing_category = State() choosing_level = State() in_quiz = State() + class QuizBot: def __init__(self): self.bot = Bot(token=config.bot_token) self.dp = Dispatcher(storage=MemoryStorage()) self.db = DatabaseManager(config.database_path) self.csv_loader = CSVQuizLoader(config.csv_data_path) - + # Регистрируем обработчики self.setup_handlers() - + def setup_handlers(self): """Регистрация всех обработчиков""" # Команды self.dp.message(Command("start"))(self.start_command) - self.dp.message(Command("help"))(self.help_command) + self.dp.message(Command("help"))(self.help_command) self.dp.message(Command("stats"))(self.stats_command) self.dp.message(Command("stop"))(self.stop_command) - - # Callback обработчики + + # Callback обработчики self.dp.callback_query(F.data == "guest_mode")(self.guest_mode_handler) self.dp.callback_query(F.data == "test_mode")(self.test_mode_handler) self.dp.callback_query(F.data.startswith("category_"))(self.category_handler) @@ -64,33 +68,43 @@ class QuizBot: async def start_command(self, message: Message, state: FSMContext): """Обработка команды /start""" user = message.from_user - + # Регистрируем пользователя await self.db.register_user( user_id=user.id, username=user.username, first_name=user.first_name, last_name=user.last_name, - language_code=user.language_code or 'ru' + language_code=user.language_code or "ru", ) - + await state.set_state(QuizStates.choosing_mode) - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🎯 Гостевой режим (QUIZ)", callback_data="guest_mode")], - [InlineKeyboardButton(text="📚 Тестирование по материалам", callback_data="test_mode")], - [InlineKeyboardButton(text="📊 Моя статистика", callback_data="stats")], - ]) - + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🎯 Гостевой режим (QUIZ)", callback_data="guest_mode" + ) + ], + [ + InlineKeyboardButton( + text="📚 Тестирование по материалам", callback_data="test_mode" + ) + ], + [InlineKeyboardButton(text="📊 Моя статистика", callback_data="stats")], + ] + ) + await message.answer( f"👋 Добро пожаловать в Quiz Bot, {user.first_name}!\n\n" "🎯 Гостевой режим - быстрая викторина для развлечения\n" "📚 Тестирование - серьезное изучение материалов с результатами\n\n" "Выберите режим работы:", reply_markup=keyboard, - parse_mode='HTML' + parse_mode="HTML", ) - + async def help_command(self, message: Message): """Обработка команды /help""" help_text = """🤖 Команды бота: @@ -115,18 +129,22 @@ class QuizBot: 📊 Доступные категории: • Корейский язык (уровни 1-5) • Более 120 уникальных вопросов""" - await message.answer(help_text, parse_mode='HTML') - + await message.answer(help_text, parse_mode="HTML") + async def stats_command(self, message: Message): """Обработка команды /stats""" user_stats = await self.db.get_user_stats(message.from_user.id) - - if not user_stats or user_stats['total_questions'] == 0: + + if not user_stats or user_stats["total_questions"] == 0: await message.answer("📊 У вас пока нет статистики. Пройдите первый тест!") return - - accuracy = (user_stats['correct_answers'] / user_stats['total_questions']) * 100 if user_stats['total_questions'] > 0 else 0 - + + accuracy = ( + (user_stats["correct_answers"] / user_stats["total_questions"]) * 100 + if user_stats["total_questions"] > 0 + else 0 + ) + stats_text = f"""📊 Ваша статистика: ❓ Всего вопросов: {user_stats['total_questions']} @@ -135,12 +153,18 @@ class QuizBot: 🎯 Завершенных сессий: {user_stats['sessions_completed'] or 0} 🏆 Лучший результат: {user_stats['best_score'] or 0:.1f}% 📊 Средний балл: {user_stats['average_score'] or 0:.1f}%""" - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")] - ]) - - await message.answer(stats_text, reply_markup=keyboard, parse_mode='HTML') + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🏠 Главное меню", callback_data="back_to_menu" + ) + ] + ] + ) + + await message.answer(stats_text, reply_markup=keyboard, parse_mode="HTML") async def stop_command(self, message: Message): """Остановка текущего теста""" @@ -150,43 +174,61 @@ class QuizBot: await message.answer("❌ Текущий тест остановлен.") else: await message.answer("❌ У вас нет активного теста.") - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")] - ]) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🏠 Главное меню", callback_data="back_to_menu" + ) + ] + ] + ) await message.answer("Что бы вы хотели сделать дальше?", reply_markup=keyboard) async def guest_mode_handler(self, callback: CallbackQuery, state: FSMContext): """Обработка выбора гостевого режима""" - await state.update_data(mode='guest') + await state.update_data(mode="guest") await state.set_state(QuizStates.choosing_category) - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🇰🇷 Корейский язык", callback_data="category_korean")], - [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_menu")] - ]) - + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🇰🇷 Корейский язык", callback_data="category_korean" + ) + ], + [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_menu")], + ] + ) + await callback.message.edit_text( "🎯 Гостевой режим\n\nВыберите категорию для викторины:", reply_markup=keyboard, - parse_mode='HTML' + parse_mode="HTML", ) await callback.answer() async def test_mode_handler(self, callback: CallbackQuery, state: FSMContext): """Обработка выбора режима тестирования""" - await state.update_data(mode='test') + await state.update_data(mode="test") await state.set_state(QuizStates.choosing_category) - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🇰🇷 Корейский язык", callback_data="category_korean")], - [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_menu")] - ]) - + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🇰🇷 Корейский язык", callback_data="category_korean" + ) + ], + [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_menu")], + ] + ) + await callback.message.edit_text( "📚 Режим тестирования\n\nВыберите категорию для серьезного изучения:", reply_markup=keyboard, - parse_mode='HTML' + parse_mode="HTML", ) await callback.answer() @@ -194,20 +236,42 @@ class QuizBot: """Обработка выбора категории""" category = callback.data.split("_")[1] await state.update_data(category=category) - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🥉 Уровень 1 (начальный)", callback_data="level_1")], - [InlineKeyboardButton(text="🥈 Уровень 2 (базовый)", callback_data="level_2")], - [InlineKeyboardButton(text="🥇 Уровень 3 (средний)", callback_data="level_3")], - [InlineKeyboardButton(text="🏆 Уровень 4 (продвинутый)", callback_data="level_4")], - [InlineKeyboardButton(text="💎 Уровень 5 (эксперт)", callback_data="level_5")], - [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_menu")] - ]) - + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🥉 Уровень 1 (начальный)", callback_data="level_1" + ) + ], + [ + InlineKeyboardButton( + text="🥈 Уровень 2 (базовый)", callback_data="level_2" + ) + ], + [ + InlineKeyboardButton( + text="🥇 Уровень 3 (средний)", callback_data="level_3" + ) + ], + [ + InlineKeyboardButton( + text="🏆 Уровень 4 (продвинутый)", callback_data="level_4" + ) + ], + [ + InlineKeyboardButton( + text="💎 Уровень 5 (эксперт)", callback_data="level_5" + ) + ], + [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_menu")], + ] + ) + await callback.message.edit_text( f"🇰🇷 Корейский язык\n\nВыберите уровень сложности:", reply_markup=keyboard, - parse_mode='HTML' + parse_mode="HTML", ) await callback.answer() @@ -215,152 +279,187 @@ class QuizBot: """Обработка выбора уровня""" level = int(callback.data.split("_")[1]) data = await state.get_data() - + # Загружаем вопросы filename = f"{data['category']}_level_{level}.csv" questions = await self.csv_loader.load_questions_from_csv(filename) - + if not questions: await callback.message.edit_text( "❌ Вопросы для этого уровня пока недоступны.", - reply_markup=InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_menu")] - ]) + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🔙 Назад", callback_data="back_to_menu" + ) + ] + ] + ), ) await callback.answer() return - + # Определяем количество вопросов - questions_count = 5 if data['mode'] == 'guest' else 10 - + questions_count = 5 if data["mode"] == "guest" else 10 + # Берем случайные вопросы - selected_questions = random.sample(questions, min(questions_count, len(questions))) - + selected_questions = random.sample( + questions, min(questions_count, len(questions)) + ) + # Создаем тестовую запись в БД test_id = await self.db.add_test( name=f"{data['category'].title()} Level {level}", description=f"Тест по {data['category']} языку, уровень {level}", level=level, - category=data['category'], - csv_file=filename + category=data["category"], + csv_file=filename, ) - + # Начинаем сессию await self.db.start_session( user_id=callback.from_user.id, test_id=test_id or 1, questions=selected_questions, - mode=data['mode'] + mode=data["mode"], ) - + await state.set_state(QuizStates.in_quiz) await self.show_question_safe(callback, callback.from_user.id, 0) await callback.answer() - + def shuffle_answers(self, question_data: dict) -> dict: """Перемешивает варианты ответов и обновляет правильный ответ""" options = [ - question_data['option1'], - question_data['option2'], - question_data['option3'], - question_data['option4'] + question_data["option1"], + question_data["option2"], + question_data["option3"], + question_data["option4"], ] - - correct_answer_text = options[question_data['correct_answer'] - 1] - + + correct_answer_text = options[question_data["correct_answer"] - 1] + # Перемешиваем варианты random.shuffle(options) - + # Находим новую позицию правильного ответа new_correct_position = options.index(correct_answer_text) + 1 - + # Обновляем данные вопроса shuffled_question = question_data.copy() - shuffled_question['option1'] = options[0] - shuffled_question['option2'] = options[1] - shuffled_question['option3'] = options[2] - shuffled_question['option4'] = options[3] - shuffled_question['correct_answer'] = new_correct_position - + shuffled_question["option1"] = options[0] + shuffled_question["option2"] = options[1] + shuffled_question["option3"] = options[2] + shuffled_question["option4"] = options[3] + shuffled_question["correct_answer"] = new_correct_position + return shuffled_question - async def show_question_safe(self, callback: CallbackQuery, user_id: int, question_index: int): + async def show_question_safe( + self, callback: CallbackQuery, user_id: int, question_index: int + ): """Безопасный показ вопроса через callback""" session = await self.db.get_active_session(user_id) - if not session or question_index >= len(session['questions_data']): + if not session or question_index >= len(session["questions_data"]): return - question = session['questions_data'][question_index] - + question = session["questions_data"][question_index] + # Перемешиваем варианты ответов только в тестовом режиме - if session['mode'] == 'test': + if session["mode"] == "test": question = self.shuffle_answers(question) - session['questions_data'][question_index] = question - await self.db.update_session_questions(user_id, session['questions_data']) - - total_questions = len(session['questions_data']) + session["questions_data"][question_index] = question + await self.db.update_session_questions(user_id, session["questions_data"]) + + total_questions = len(session["questions_data"]) # Создаем клавиатуру с ответами keyboard_builder = InlineKeyboardBuilder() for i in range(1, 5): - keyboard_builder.add(InlineKeyboardButton( - text=f"{i}. {question[f'option{i}']}", - callback_data=f"answer_{i}" - )) - + keyboard_builder.add( + InlineKeyboardButton( + text=f"{i}. {question[f'option{i}']}", callback_data=f"answer_{i}" + ) + ) + keyboard_builder.adjust(1) - + question_text = ( f"❓ Вопрос {question_index + 1}/{total_questions}\n\n" f"{question['question']}" ) - + # Безопасная отправка сообщения if callback.message and not isinstance(callback.message, InaccessibleMessage): try: - await callback.message.edit_text(question_text, reply_markup=keyboard_builder.as_markup(), parse_mode='HTML') + await callback.message.edit_text( + question_text, + reply_markup=keyboard_builder.as_markup(), + parse_mode="HTML", + ) except Exception: - await self.bot.send_message(callback.from_user.id, question_text, reply_markup=keyboard_builder.as_markup(), parse_mode='HTML') + await self.bot.send_message( + callback.from_user.id, + question_text, + reply_markup=keyboard_builder.as_markup(), + parse_mode="HTML", + ) else: - await self.bot.send_message(callback.from_user.id, question_text, reply_markup=keyboard_builder.as_markup(), parse_mode='HTML') + await self.bot.send_message( + callback.from_user.id, + question_text, + reply_markup=keyboard_builder.as_markup(), + parse_mode="HTML", + ) async def answer_handler(self, callback: CallbackQuery, state: FSMContext): """Обработка ответа на вопрос""" answer = int(callback.data.split("_")[1]) user_id = callback.from_user.id - + session = await self.db.get_active_session(user_id) if not session: await callback.answer("❌ Сессия не найдена") return - - current_q_index = session['current_question'] - question = session['questions_data'][current_q_index] - is_correct = answer == question['correct_answer'] - mode = session['mode'] - + + current_q_index = session["current_question"] + question = session["questions_data"][current_q_index] + is_correct = answer == question["correct_answer"] + mode = session["mode"] + # Обновляем счетчик правильных ответов if is_correct: - session['correct_count'] += 1 - + session["correct_count"] += 1 + # Обновляем прогресс в базе await self.db.update_session_progress( - user_id, current_q_index + 1, session['correct_count'] + user_id, current_q_index + 1, session["correct_count"] ) - + # Проверяем, есть ли еще вопросы - if current_q_index + 1 >= len(session['questions_data']): + if current_q_index + 1 >= len(session["questions_data"]): # Тест завершен - score = (session['correct_count'] / len(session['questions_data'])) * 100 + score = (session["correct_count"] / len(session["questions_data"])) * 100 await self.db.finish_session(user_id, score) - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - [InlineKeyboardButton(text="📊 Моя статистика", callback_data="stats")] - ]) - + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🏠 Главное меню", callback_data="back_to_menu" + ) + ], + [ + InlineKeyboardButton( + text="📊 Моя статистика", callback_data="stats" + ) + ], + ] + ) + # Разный текст для разных режимов - if mode == 'test': + if mode == "test": final_text = ( f"🎉 Тест завершен!\n\n" f"📊 Результат: {session['correct_count']}/{len(session['questions_data'])}\n" @@ -369,7 +468,11 @@ class QuizBot: f"💡 Результат сохранен в вашей статистике" ) else: - result_text = "✅ Правильно!" if is_correct else f"❌ Неправильно. Правильный ответ: {question['correct_answer']}" + result_text = ( + "✅ Правильно!" + if is_correct + else f"❌ Неправильно. Правильный ответ: {question['correct_answer']}" + ) final_text = ( f"{result_text}\n\n" f"🎉 Викторина завершена!\n\n" @@ -377,62 +480,102 @@ class QuizBot: f"📈 Точность: {score:.1f}%\n" f"🏆 Оценка: {self.get_grade(score)}" ) - + # Безопасная отправка сообщения - if callback.message and not isinstance(callback.message, InaccessibleMessage): + if callback.message and not isinstance( + callback.message, InaccessibleMessage + ): try: - await callback.message.edit_text(final_text, reply_markup=keyboard, parse_mode='HTML') + await callback.message.edit_text( + final_text, reply_markup=keyboard, parse_mode="HTML" + ) except Exception: - await self.bot.send_message(callback.from_user.id, final_text, reply_markup=keyboard, parse_mode='HTML') + await self.bot.send_message( + callback.from_user.id, + final_text, + reply_markup=keyboard, + parse_mode="HTML", + ) else: - await self.bot.send_message(callback.from_user.id, final_text, reply_markup=keyboard, parse_mode='HTML') + await self.bot.send_message( + callback.from_user.id, + final_text, + reply_markup=keyboard, + parse_mode="HTML", + ) else: # Есть еще вопросы - if mode == 'test': + if mode == "test": # В тестовом режиме сразу переходим к следующему вопросу - await self.show_question_safe(callback, callback.from_user.id, current_q_index + 1) + await self.show_question_safe( + callback, callback.from_user.id, current_q_index + 1 + ) else: # В гостевом режиме показываем результат и кнопку "Следующий" - result_text = "✅ Правильно!" if is_correct else f"❌ Неправильно. Правильный ответ: {question['correct_answer']}" - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="➡️ Следующий вопрос", callback_data="next_question")] - ]) - + result_text = ( + "✅ Правильно!" + if is_correct + else f"❌ Неправильно. Правильный ответ: {question['correct_answer']}" + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="➡️ Следующий вопрос", callback_data="next_question" + ) + ] + ] + ) + # Безопасная отправка сообщения - if callback.message and not isinstance(callback.message, InaccessibleMessage): + if callback.message and not isinstance( + callback.message, InaccessibleMessage + ): try: - await callback.message.edit_text(result_text, reply_markup=keyboard) + await callback.message.edit_text( + result_text, reply_markup=keyboard + ) except Exception: - await self.bot.send_message(callback.from_user.id, result_text, reply_markup=keyboard) + await self.bot.send_message( + callback.from_user.id, result_text, reply_markup=keyboard + ) else: - await self.bot.send_message(callback.from_user.id, result_text, reply_markup=keyboard) - + await self.bot.send_message( + callback.from_user.id, result_text, reply_markup=keyboard + ) + await callback.answer() - + async def next_question(self, callback: CallbackQuery): """Переход к следующему вопросу""" session = await self.db.get_active_session(callback.from_user.id) if session: - await self.show_question_safe(callback, callback.from_user.id, session['current_question']) + await self.show_question_safe( + callback, callback.from_user.id, session["current_question"] + ) await callback.answer() - + async def stats_callback_handler(self, callback: CallbackQuery): """Обработчик кнопки статистики через callback""" user_stats = await self.db.get_user_stats(callback.from_user.id) - - if not user_stats or user_stats['total_questions'] == 0: + + if not user_stats or user_stats["total_questions"] == 0: stats_text = "📊 У вас пока нет статистики. Пройдите первый тест!" else: - accuracy = (user_stats['correct_answers'] / user_stats['total_questions']) * 100 if user_stats['total_questions'] > 0 else 0 - + accuracy = ( + (user_stats["correct_answers"] / user_stats["total_questions"]) * 100 + if user_stats["total_questions"] > 0 + else 0 + ) + # Получаем дополнительную статистику recent_results = await self.db.get_recent_results(callback.from_user.id, 3) category_stats = await self.db.get_category_stats(callback.from_user.id) - - best_score = user_stats['best_score'] or 0 - avg_score = user_stats['average_score'] or 0 - + + best_score = user_stats["best_score"] or 0 + avg_score = user_stats["average_score"] or 0 + stats_text = f"""📊 Ваша статистика: 📈 Общие показатели: @@ -451,68 +594,116 @@ class QuizBot: if category_stats: stats_text += "\n\n🏷️ По категориям:" for cat_stat in category_stats[:2]: - cat_accuracy = (cat_stat['correct_answers'] / cat_stat['total_questions']) * 100 if cat_stat['total_questions'] > 0 else 0 + cat_accuracy = ( + (cat_stat["correct_answers"] / cat_stat["total_questions"]) + * 100 + if cat_stat["total_questions"] > 0 + else 0 + ) stats_text += f"\n📖 {cat_stat['category']}: {cat_stat['attempts']} попыток, {cat_accuracy:.1f}% точность" - + # Добавляем последние результаты if recent_results: stats_text += "\n\n📈 Последние результаты:" for result in recent_results: - mode_emoji = "🎯" if result['mode'] == 'guest' else "📚" + mode_emoji = "🎯" if result["mode"] == "guest" else "📚" stats_text += f"\n{mode_emoji} {result['score']:.1f}% ({result['correct_answers']}/{result['questions_asked']})" - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🏠 Главное меню", callback_data="back_to_menu")], - [InlineKeyboardButton(text="🔄 Обновить статистику", callback_data="stats")] - ]) - + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🏠 Главное меню", callback_data="back_to_menu" + ) + ], + [ + InlineKeyboardButton( + text="🔄 Обновить статистику", callback_data="stats" + ) + ], + ] + ) + # Безопасная отправка сообщения if callback.message and not isinstance(callback.message, InaccessibleMessage): try: - await callback.message.edit_text(stats_text, reply_markup=keyboard, parse_mode='HTML') + await callback.message.edit_text( + stats_text, reply_markup=keyboard, parse_mode="HTML" + ) except Exception: - await self.bot.send_message(callback.from_user.id, stats_text, reply_markup=keyboard, parse_mode='HTML') + await self.bot.send_message( + callback.from_user.id, + stats_text, + reply_markup=keyboard, + parse_mode="HTML", + ) else: - await self.bot.send_message(callback.from_user.id, stats_text, reply_markup=keyboard, parse_mode='HTML') + await self.bot.send_message( + callback.from_user.id, + stats_text, + reply_markup=keyboard, + parse_mode="HTML", + ) await callback.answer() - + async def back_to_menu(self, callback: CallbackQuery, state: FSMContext): """Возврат в главное меню""" await state.clear() - + user = callback.from_user - + # Регистрируем пользователя (если еще не зарегистрирован) await self.db.register_user( user_id=user.id, username=user.username, first_name=user.first_name, last_name=user.last_name, - language_code=user.language_code or 'ru' + language_code=user.language_code or "ru", ) - + await state.set_state(QuizStates.choosing_mode) - - keyboard = InlineKeyboardMarkup(inline_keyboard=[ - [InlineKeyboardButton(text="🎯 Гостевой режим (QUIZ)", callback_data="guest_mode")], - [InlineKeyboardButton(text="📚 Тестирование по материалам", callback_data="test_mode")], - [InlineKeyboardButton(text="📊 Моя статистика", callback_data="stats")], - ]) - - text = (f"👋 Добро пожаловать в Quiz Bot, {user.first_name}!\n\n" - "🎯 Гостевой режим - быстрая викторина для развлечения\n" - "📚 Тестирование - серьезное изучение материалов с результатами\n\n" - "Выберите режим работы:") - + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🎯 Гостевой режим (QUIZ)", callback_data="guest_mode" + ) + ], + [ + InlineKeyboardButton( + text="📚 Тестирование по материалам", callback_data="test_mode" + ) + ], + [InlineKeyboardButton(text="📊 Моя статистика", callback_data="stats")], + ] + ) + + text = ( + f"👋 Добро пожаловать в Quiz Bot, {user.first_name}!\n\n" + "🎯 Гостевой режим - быстрая викторина для развлечения\n" + "📚 Тестирование - серьезное изучение материалов с результатами\n\n" + "Выберите режим работы:" + ) + if callback.message and not isinstance(callback.message, InaccessibleMessage): try: - await callback.message.edit_text(text, reply_markup=keyboard, parse_mode='HTML') + await callback.message.edit_text( + text, reply_markup=keyboard, parse_mode="HTML" + ) except Exception: - await self.bot.send_message(callback.from_user.id, text, reply_markup=keyboard, parse_mode='HTML') + await self.bot.send_message( + callback.from_user.id, + text, + reply_markup=keyboard, + parse_mode="HTML", + ) else: - await self.bot.send_message(callback.from_user.id, text, reply_markup=keyboard, parse_mode='HTML') + await self.bot.send_message( + callback.from_user.id, text, reply_markup=keyboard, parse_mode="HTML" + ) await callback.answer() - + def get_grade(self, score: float) -> str: """Получение оценки по проценту правильных ответов""" if score >= 90: @@ -523,31 +714,36 @@ class QuizBot: return "Удовлетворительно 📚" else: return "Нужно подтянуть знания 📖" - + async def start(self): """Запуск бота""" # Проверяем токен - if not config.bot_token or config.bot_token in ['your_bot_token_here', 'test_token_for_demo_purposes']: + if not config.bot_token or config.bot_token in [ + "your_bot_token_here", + "test_token_for_demo_purposes", + ]: print("❌ Ошибка: не настроен BOT_TOKEN в файле .env") return False - + # Инициализируем базу данных await self.db.init_database() - + print("✅ Bot starting...") print(f"🗄️ Database: {config.database_path}") print(f"📁 CSV files: {config.csv_data_path}") - + try: await self.dp.start_polling(self.bot) except Exception as e: logging.error(f"Error starting bot: {e}") return False + async def main(): """Главная функция""" bot = QuizBot() await bot.start() + if __name__ == "__main__": asyncio.run(main()) diff --git a/src/database/database.py b/src/database/database.py index b6059c7..33d9ff1 100644 --- a/src/database/database.py +++ b/src/database/database.py @@ -1,17 +1,20 @@ -import aiosqlite -import logging -from typing import List, Dict, Optional, Tuple, Union import json +import logging +from typing import Dict, List, Optional, Tuple, Union + +import aiosqlite + class DatabaseManager: def __init__(self, db_path: str): self.db_path = db_path - + async def init_database(self): """Инициализация базы данных и создание таблиц""" async with aiosqlite.connect(self.db_path) as db: # Таблица пользователей - await db.execute(""" + await db.execute( + """ CREATE TABLE IF NOT EXISTS users ( user_id INTEGER PRIMARY KEY, username TEXT, @@ -23,10 +26,12 @@ class DatabaseManager: total_questions INTEGER DEFAULT 0, correct_answers INTEGER DEFAULT 0 ) - """) - + """ + ) + # Таблица тестов - await db.execute(""" + await db.execute( + """ CREATE TABLE IF NOT EXISTS tests ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -37,10 +42,12 @@ class DatabaseManager: created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, is_active BOOLEAN DEFAULT TRUE ) - """) - + """ + ) + # Таблица вопросов - await db.execute(""" + await db.execute( + """ CREATE TABLE IF NOT EXISTS questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, test_id INTEGER, @@ -52,10 +59,12 @@ class DatabaseManager: correct_answer INTEGER NOT NULL, FOREIGN KEY (test_id) REFERENCES tests (id) ) - """) - + """ + ) + # Таблица результатов - await db.execute(""" + await db.execute( + """ CREATE TABLE IF NOT EXISTS results ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, @@ -70,10 +79,12 @@ class DatabaseManager: FOREIGN KEY (user_id) REFERENCES users (user_id), FOREIGN KEY (test_id) REFERENCES tests (id) ) - """) - + """ + ) + # Таблица активных сессий - await db.execute(""" + await db.execute( + """ CREATE TABLE IF NOT EXISTS active_sessions ( user_id INTEGER PRIMARY KEY, test_id INTEGER, @@ -85,28 +96,38 @@ class DatabaseManager: FOREIGN KEY (user_id) REFERENCES users (user_id), FOREIGN KEY (test_id) REFERENCES tests (id) ) - """) - + """ + ) + await db.commit() logging.info("Database initialized successfully") - - async def register_user(self, user_id: int, username: Optional[str] = None, - first_name: Optional[str] = None, last_name: Optional[str] = None, - language_code: str = 'ru', is_guest: bool = True) -> bool: + + async def register_user( + self, + user_id: int, + username: Optional[str] = None, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + language_code: str = "ru", + is_guest: bool = True, + ) -> bool: """Регистрация нового пользователя""" try: async with aiosqlite.connect(self.db_path) as db: - await db.execute(""" + await db.execute( + """ INSERT OR REPLACE INTO users (user_id, username, first_name, last_name, language_code, is_guest) VALUES (?, ?, ?, ?, ?, ?) - """, (user_id, username, first_name, last_name, language_code, is_guest)) + """, + (user_id, username, first_name, last_name, language_code, is_guest), + ) await db.commit() return True except Exception as e: logging.error(f"Error registering user {user_id}: {e}") return False - + async def get_user(self, user_id: int) -> Optional[Dict]: """Получение данных пользователя""" try: @@ -122,30 +143,34 @@ class DatabaseManager: except Exception as e: logging.error(f"Error getting user {user_id}: {e}") return None - - async def add_test(self, name: str, description: str, level: int, - category: str, csv_file: str) -> Optional[int]: + + async def add_test( + self, name: str, description: str, level: int, category: str, csv_file: str + ) -> Optional[int]: """Добавление нового теста""" try: async with aiosqlite.connect(self.db_path) as db: - cursor = await db.execute(""" + cursor = await db.execute( + """ INSERT INTO tests (name, description, level, category, csv_file) VALUES (?, ?, ?, ?, ?) - """, (name, description, level, category, csv_file)) + """, + (name, description, level, category, csv_file), + ) await db.commit() return cursor.lastrowid except Exception as e: logging.error(f"Error adding test: {e}") return None - + async def get_tests_by_category(self, category: Optional[str] = None) -> List[Dict]: """Получение тестов по категории""" try: async with aiosqlite.connect(self.db_path) as db: if category: cursor = await db.execute( - "SELECT * FROM tests WHERE category = ? AND is_active = TRUE ORDER BY level", - (category,) + "SELECT * FROM tests WHERE category = ? AND is_active = TRUE ORDER BY level", + (category,), ) else: cursor = await db.execute( @@ -157,56 +182,73 @@ class DatabaseManager: except Exception as e: logging.error(f"Error getting tests: {e}") return [] - + async def add_questions_to_test(self, test_id: int, questions: List[Dict]) -> bool: """Добавление вопросов к тесту""" try: async with aiosqlite.connect(self.db_path) as db: for q in questions: - await db.execute(""" + await db.execute( + """ INSERT INTO questions (test_id, question, option1, option2, option3, option4, correct_answer) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (test_id, q['question'], q['option1'], q['option2'], - q['option3'], q['option4'], q['correct_answer'])) + """, + ( + test_id, + q["question"], + q["option1"], + q["option2"], + q["option3"], + q["option4"], + q["correct_answer"], + ), + ) await db.commit() return True except Exception as e: logging.error(f"Error adding questions to test {test_id}: {e}") return False - + async def get_random_questions(self, test_id: int, count: int = 10) -> List[Dict]: """Получение случайных вопросов из теста""" try: async with aiosqlite.connect(self.db_path) as db: - cursor = await db.execute(""" + cursor = await db.execute( + """ SELECT * FROM questions WHERE test_id = ? ORDER BY RANDOM() LIMIT ? - """, (test_id, count)) + """, + (test_id, count), + ) rows = await cursor.fetchall() columns = [description[0] for description in cursor.description] return [dict(zip(columns, row)) for row in rows] except Exception as e: logging.error(f"Error getting random questions: {e}") return [] - - async def start_session(self, user_id: int, test_id: int, - questions: List[Dict], mode: str) -> bool: + + async def start_session( + self, user_id: int, test_id: int, questions: List[Dict], mode: str + ) -> bool: """Начало новой сессии викторины""" try: async with aiosqlite.connect(self.db_path) as db: questions_json = json.dumps(questions) - await db.execute(""" + await db.execute( + """ INSERT OR REPLACE INTO active_sessions (user_id, test_id, questions_data, mode) VALUES (?, ?, ?, ?) - """, (user_id, test_id, questions_json, mode)) + """, + (user_id, test_id, questions_json, mode), + ) await db.commit() return True except Exception as e: logging.error(f"Error starting session: {e}") return False - + async def get_active_session(self, user_id: int) -> Optional[Dict]: """Получение активной сессии пользователя""" try: @@ -218,45 +260,54 @@ class DatabaseManager: if row: columns = [description[0] for description in cursor.description] session = dict(zip(columns, row)) - session['questions_data'] = json.loads(session['questions_data']) + session["questions_data"] = json.loads(session["questions_data"]) return session return None except Exception as e: logging.error(f"Error getting active session: {e}") return None - - async def update_session_progress(self, user_id: int, question_num: int, - correct_count: int) -> bool: + + async def update_session_progress( + self, user_id: int, question_num: int, correct_count: int + ) -> bool: """Обновление прогресса сессии""" try: async with aiosqlite.connect(self.db_path) as db: - await db.execute(""" + await db.execute( + """ UPDATE active_sessions SET current_question = ?, correct_count = ? WHERE user_id = ? - """, (question_num, correct_count, user_id)) + """, + (question_num, correct_count, user_id), + ) await db.commit() return True except Exception as e: logging.error(f"Error updating session progress: {e}") return False - - async def update_session_questions(self, user_id: int, questions_data: list) -> bool: + + async def update_session_questions( + self, user_id: int, questions_data: list + ) -> bool: """Обновление данных вопросов в сессии (например, после перемешивания)""" try: async with aiosqlite.connect(self.db_path) as db: questions_json = json.dumps(questions_data, ensure_ascii=False) - await db.execute(""" + await db.execute( + """ UPDATE active_sessions SET questions_data = ? WHERE user_id = ? - """, (questions_json, user_id)) + """, + (questions_json, user_id), + ) await db.commit() return True except Exception as e: logging.error(f"Error updating session questions: {e}") return False - + async def finish_session(self, user_id: int, score: float) -> bool: """Завершение сессии и сохранение результатов""" try: @@ -265,37 +316,52 @@ class DatabaseManager: session = await self.get_active_session(user_id) if not session: return False - + # Сохраняем результат - await db.execute(""" + await db.execute( + """ INSERT INTO results (user_id, test_id, mode, questions_asked, correct_answers, score) VALUES (?, ?, ?, ?, ?, ?) - """, (user_id, session['test_id'], session['mode'], - len(session['questions_data']), session['correct_count'], score)) - + """, + ( + user_id, + session["test_id"], + session["mode"], + len(session["questions_data"]), + session["correct_count"], + score, + ), + ) + # Обновляем статистику пользователя - await db.execute(""" + await db.execute( + """ UPDATE users SET total_questions = total_questions + ?, correct_answers = correct_answers + ? WHERE user_id = ? - """, (len(session['questions_data']), session['correct_count'], user_id)) - + """, + (len(session["questions_data"]), session["correct_count"], user_id), + ) + # Удаляем активную сессию - await db.execute("DELETE FROM active_sessions WHERE user_id = ?", (user_id,)) - + await db.execute( + "DELETE FROM active_sessions WHERE user_id = ?", (user_id,) + ) + await db.commit() return True except Exception as e: logging.error(f"Error finishing session: {e}") return False - + async def get_user_stats(self, user_id: int) -> Optional[Dict]: """Получение статистики пользователя""" try: async with aiosqlite.connect(self.db_path) as db: - cursor = await db.execute(""" + cursor = await db.execute( + """ SELECT u.total_questions, u.correct_answers, @@ -308,8 +374,10 @@ class DatabaseManager: LEFT JOIN results r ON u.user_id = r.user_id WHERE u.user_id = ? GROUP BY u.user_id - """, (user_id,)) - + """, + (user_id,), + ) + row = await cursor.fetchone() if row: columns = [description[0] for description in cursor.description] @@ -319,12 +387,13 @@ class DatabaseManager: except Exception as e: logging.error(f"Error getting user stats: {e}") return None - + async def get_recent_results(self, user_id: int, limit: int = 5) -> List[Dict]: """Получение последних результатов пользователя""" try: async with aiosqlite.connect(self.db_path) as db: - cursor = await db.execute(""" + cursor = await db.execute( + """ SELECT r.mode, r.questions_asked, @@ -338,20 +407,23 @@ class DatabaseManager: WHERE r.user_id = ? ORDER BY r.end_time DESC LIMIT ? - """, (user_id, limit)) - + """, + (user_id, limit), + ) + rows = await cursor.fetchall() columns = [description[0] for description in cursor.description] return [dict(zip(columns, row)) for row in rows] except Exception as e: logging.error(f"Error getting recent results: {e}") return [] - + async def get_category_stats(self, user_id: int) -> List[Dict]: """Получение статистики по категориям""" try: async with aiosqlite.connect(self.db_path) as db: - cursor = await db.execute(""" + cursor = await db.execute( + """ SELECT t.category, COUNT(r.id) as attempts, @@ -364,8 +436,10 @@ class DatabaseManager: WHERE r.user_id = ? GROUP BY t.category ORDER BY attempts DESC - """, (user_id,)) - + """, + (user_id,), + ) + rows = await cursor.fetchall() columns = [description[0] for description in cursor.description] return [dict(zip(columns, row)) for row in rows]