Implement user-specific question numbering and update database schema. Added triggers for automatic question numbering and adjustments upon deletion. Enhanced CRUD operations to manage user_question_number effectively.
This commit is contained in:
73
.dockerignore
Normal file
73
.dockerignore
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
env
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
.tox
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.hypothesis
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Examples
|
||||||
|
examples/
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
tests/
|
||||||
|
test_*.py
|
||||||
|
*_test.py
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
63
.env_example
Normal file
63
.env_example
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Пример файла .env для настройки бота
|
||||||
|
# Скопируйте этот файл в .env и заполните своими данными
|
||||||
|
|
||||||
|
# Токен бота от @BotFather
|
||||||
|
BOT_TOKEN=your_bot_token_here
|
||||||
|
|
||||||
|
# ID администраторов через запятую (можно получить у @userinfobot)
|
||||||
|
ADMINS=123456789,987654321
|
||||||
|
|
||||||
|
# Путь к базе данных SQLite
|
||||||
|
DATABASE_PATH=database/anon_qna.db
|
||||||
|
|
||||||
|
# Режим отладки (true/false)
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# Максимальная длина вопроса
|
||||||
|
MAX_QUESTION_LENGTH=1000
|
||||||
|
|
||||||
|
# Максимальная длина ответа
|
||||||
|
MAX_ANSWER_LENGTH=2000
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Настройки Rate Limiting
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Окружение для rate limiting (development/production/strict)
|
||||||
|
RATE_LIMIT_ENV=production
|
||||||
|
|
||||||
|
# Основные настройки rate limiting (основаны на лимитах Telegram API)
|
||||||
|
# Telegram API лимиты: 1 сообщение/сек в личных чатах, 20 сообщений/мин в группах, 30 запросов/сек глобально
|
||||||
|
RATE_LIMIT_MESSAGES_PER_SECOND=0.5
|
||||||
|
RATE_LIMIT_BURST_LIMIT=2
|
||||||
|
RATE_LIMIT_RETRY_MULTIPLIER=1.5
|
||||||
|
RATE_LIMIT_MAX_RETRY_DELAY=30.0
|
||||||
|
RATE_LIMIT_MAX_RETRIES=3
|
||||||
|
|
||||||
|
# Задержки для разных типов сообщений
|
||||||
|
RATE_LIMIT_VOICE_DELAY=2.5
|
||||||
|
RATE_LIMIT_MEDIA_DELAY=2.0
|
||||||
|
RATE_LIMIT_TEXT_DELAY=1.5
|
||||||
|
|
||||||
|
# Множители для разных типов чатов
|
||||||
|
RATE_LIMIT_PRIVATE_MULTIPLIER=1.0
|
||||||
|
RATE_LIMIT_GROUP_MULTIPLIER=0.8
|
||||||
|
RATE_LIMIT_CHANNEL_MULTIPLIER=0.6
|
||||||
|
|
||||||
|
# Глобальные ограничения (консервативные настройки)
|
||||||
|
RATE_LIMIT_GLOBAL_MESSAGES_PER_SECOND=20.0
|
||||||
|
RATE_LIMIT_GLOBAL_BURST_LIMIT=15
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Переменные окружения для Docker
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
# Python настройки
|
||||||
|
PYTHONPATH=/app
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Путь к базе данных в Docker контейнере
|
||||||
|
DATABASE_PATH_DOCKER=/app/database/anon_qna.db
|
||||||
|
|
||||||
|
# Путь к логам в Docker контейнере
|
||||||
|
LOGS_PATH_DOCKER=/app/logs
|
||||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -55,21 +55,6 @@ cover/
|
|||||||
*.mo
|
*.mo
|
||||||
*.pot
|
*.pot
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
.pybuilder/
|
.pybuilder/
|
||||||
@@ -169,3 +154,16 @@ cython_debug/
|
|||||||
|
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
database/*.db
|
||||||
|
database/*.db-shm
|
||||||
|
database/*.db-wal
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|||||||
44
Dockerfile
Normal file
44
Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Используем официальный Python образ
|
||||||
|
FROM python:3.9-slim
|
||||||
|
|
||||||
|
# Устанавливаем рабочую директорию
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Устанавливаем системные зависимости
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Копируем файл зависимостей
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Устанавливаем Python зависимости
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Копируем исходный код приложения
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Создаем директории для данных
|
||||||
|
RUN mkdir -p database logs
|
||||||
|
|
||||||
|
# Создаем пользователя для безопасности
|
||||||
|
RUN groupadd --gid 1001 app && \
|
||||||
|
useradd --create-home --shell /bin/bash --uid 1001 --gid 1001 app && \
|
||||||
|
chown -R 1001:1001 /app
|
||||||
|
USER 1001:1001
|
||||||
|
|
||||||
|
# Открываем порты
|
||||||
|
EXPOSE 8081
|
||||||
|
|
||||||
|
# Устанавливаем переменные окружения
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Добавляем healthcheck
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8081/health || exit 1
|
||||||
|
|
||||||
|
# Команда по умолчанию
|
||||||
|
CMD ["python", "main.py"]
|
||||||
80
bot.py
Normal file
80
bot.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Основной файл для запуска Telegram бота анонимных вопросов
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Добавляем корневую директорию в путь для импортов
|
||||||
|
sys.path.append(str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from loader import loader
|
||||||
|
from services.infrastructure.http_server import start_http_server, stop_http_server
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.infrastructure.pid_manager import get_pid_manager, cleanup_pid_file
|
||||||
|
from config.constants import DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Главная функция для запуска бота"""
|
||||||
|
http_runner = None
|
||||||
|
pid_manager = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("🤖 Запуск бота анонимных вопросов...")
|
||||||
|
logger.info(f"📊 Режим отладки: {'Включен' if config.DEBUG else 'Выключен'}")
|
||||||
|
logger.info(f"💾 База данных: {config.DATABASE_PATH}")
|
||||||
|
logger.info(f"👑 Администраторы: {config.ADMINS}")
|
||||||
|
|
||||||
|
# Создаем PID файл для отслеживания процесса
|
||||||
|
logger.info("📄 Создание PID файла...")
|
||||||
|
pid_manager = get_pid_manager("anon_bot")
|
||||||
|
if not pid_manager.create_pid_file():
|
||||||
|
logger.error("❌ Не удалось создать PID файл, завершаем работу")
|
||||||
|
return
|
||||||
|
logger.info(f"✅ PID файл создан: {pid_manager.get_pid_file_path()}")
|
||||||
|
|
||||||
|
# Запускаем HTTP сервер для метрик и health check
|
||||||
|
logger.info("🌐 Запуск HTTP сервера для метрик...")
|
||||||
|
http_runner = await start_http_server(host=DEFAULT_HTTP_HOST, port=DEFAULT_HTTP_PORT)
|
||||||
|
|
||||||
|
# Запускаем бота
|
||||||
|
await loader.start_polling()
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("⏹️ Получен сигнал остановки (Ctrl+C)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"💥 Критическая ошибка: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
# Останавливаем HTTP сервер
|
||||||
|
if http_runner:
|
||||||
|
logger.info("🛑 Остановка HTTP сервера...")
|
||||||
|
await stop_http_server(http_runner)
|
||||||
|
|
||||||
|
# Очищаем PID файл
|
||||||
|
if pid_manager:
|
||||||
|
logger.info("📄 Очистка PID файла...")
|
||||||
|
pid_manager.cleanup_pid_file()
|
||||||
|
|
||||||
|
logger.info("🛑 Бот остановлен")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
# Проверяем конфигурацию перед запуском
|
||||||
|
config.validate()
|
||||||
|
|
||||||
|
# Запускаем бота
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"❌ Ошибка конфигурации: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"💥 Неожиданная ошибка: {e}")
|
||||||
|
sys.exit(1)
|
||||||
8
config/__init__.py
Normal file
8
config/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Конфигурационный модуль AnonBot
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .config import config, Config
|
||||||
|
from .constants import *
|
||||||
|
|
||||||
|
__all__ = ['config', 'Config']
|
||||||
59
config/config.py
Normal file
59
config/config.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация бота для анонимных вопросов
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Загружаем переменные окружения из .env файла
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Класс конфигурации бота"""
|
||||||
|
|
||||||
|
# Токен бота (обязательно)
|
||||||
|
BOT_TOKEN: str = os.getenv('BOT_TOKEN', '')
|
||||||
|
|
||||||
|
# Список ID администраторов (через запятую)
|
||||||
|
ADMINS: List[int] = [
|
||||||
|
int(admin_id.strip())
|
||||||
|
for admin_id in os.getenv('ADMINS', '').split(',')
|
||||||
|
if admin_id.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Путь к базе данных SQLite
|
||||||
|
DATABASE_PATH: str = os.getenv('DATABASE_PATH', 'database/anon_qna.db')
|
||||||
|
|
||||||
|
# Режим отладки
|
||||||
|
DEBUG: bool = os.getenv('DEBUG', 'False').lower() == 'true'
|
||||||
|
|
||||||
|
|
||||||
|
# Максимальная длина вопроса
|
||||||
|
MAX_QUESTION_LENGTH: int = int(os.getenv('MAX_QUESTION_LENGTH', '1000'))
|
||||||
|
|
||||||
|
# Максимальная длина ответа
|
||||||
|
MAX_ANSWER_LENGTH: int = int(os.getenv('MAX_ANSWER_LENGTH', '2000'))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls) -> bool:
|
||||||
|
"""Проверка корректности конфигурации"""
|
||||||
|
if not cls.BOT_TOKEN:
|
||||||
|
raise ValueError("BOT_TOKEN не установлен в переменных окружения")
|
||||||
|
|
||||||
|
if not cls.ADMINS:
|
||||||
|
print("Предупреждение: ADMINS не установлен")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# Создаем экземпляр конфигурации
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
# Проверяем конфигурацию при импорте
|
||||||
|
try:
|
||||||
|
config.validate()
|
||||||
|
except ValueError as e:
|
||||||
|
print(f"Ошибка конфигурации: {e}")
|
||||||
|
exit(1)
|
||||||
133
config/constants.py
Normal file
133
config/constants.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
Константы для AnonBot
|
||||||
|
"""
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ПАГИНАЦИЯ
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Количество элементов на странице по умолчанию
|
||||||
|
DEFAULT_PAGE_SIZE: Final[int] = 9
|
||||||
|
|
||||||
|
# Максимальное количество элементов на странице
|
||||||
|
MAX_PAGE_SIZE: Final[int] = 50
|
||||||
|
|
||||||
|
# Минимальный номер страницы
|
||||||
|
MIN_PAGE_NUMBER: Final[int] = 0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ТЕКСТОВЫЕ ОГРАНИЧЕНИЯ
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Минимальная длина вопроса
|
||||||
|
MIN_QUESTION_LENGTH: Final[int] = 5
|
||||||
|
|
||||||
|
# Минимальная длина ответа
|
||||||
|
MIN_ANSWER_LENGTH: Final[int] = 5
|
||||||
|
|
||||||
|
# Длина превью вопроса по умолчанию
|
||||||
|
DEFAULT_QUESTION_PREVIEW_LENGTH: Final[int] = 100
|
||||||
|
|
||||||
|
# Длина обрезки текста по умолчанию
|
||||||
|
DEFAULT_TEXT_TRUNCATE_LENGTH: Final[int] = 100
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# СТАТИСТИКА И МЕТРИКИ
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Минимальное количество запросов для адаптации rate limiting
|
||||||
|
MIN_REQUESTS_FOR_ADAPTATION: Final[int] = 100
|
||||||
|
|
||||||
|
# Высокий уровень ошибок для rate limiting (10%)
|
||||||
|
HIGH_ERROR_RATE_THRESHOLD: Final[float] = 0.1
|
||||||
|
|
||||||
|
# Низкий уровень ошибок для rate limiting (1%)
|
||||||
|
LOW_ERROR_RATE_THRESHOLD: Final[float] = 0.01
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HTTP СЕРВЕР
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Порт HTTP сервера по умолчанию
|
||||||
|
DEFAULT_HTTP_PORT: Final[int] = 8081
|
||||||
|
|
||||||
|
# Хост HTTP сервера по умолчанию
|
||||||
|
DEFAULT_HTTP_HOST: Final[str] = "0.0.0.0"
|
||||||
|
|
||||||
|
# Версия приложения
|
||||||
|
APP_VERSION: Final[str] = "1.0.0"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# БАЗА ДАННЫХ
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Размер пула соединений по умолчанию
|
||||||
|
DEFAULT_CONNECTION_POOL_SIZE: Final[int] = 5
|
||||||
|
|
||||||
|
# Timeout для соединения с БД (секунды)
|
||||||
|
DATABASE_TIMEOUT: Final[float] = 30.0
|
||||||
|
|
||||||
|
# Размер кэша SQLite
|
||||||
|
SQLITE_CACHE_SIZE: Final[int] = 10000
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# БЕЗОПАСНОСТЬ
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Длина токена для анонимных пользователей
|
||||||
|
ANONYMOUS_TOKEN_LENGTH: Final[int] = 8
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ФОРМАТИРОВАНИЕ
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Количество символов в разделителе
|
||||||
|
SEPARATOR_LENGTH: Final[int] = 30
|
||||||
|
|
||||||
|
# Количество знаков после запятой для процентов
|
||||||
|
PERCENTAGE_DECIMAL_PLACES: Final[int] = 1
|
||||||
|
|
||||||
|
# Количество знаков после запятой для времени
|
||||||
|
TIME_DECIMAL_PLACES: Final[int] = 2
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# СТАТУСЫ HTTP
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# HTTP статус коды
|
||||||
|
HTTP_STATUS_OK: Final[int] = 200
|
||||||
|
HTTP_STATUS_SERVICE_UNAVAILABLE: Final[int] = 503
|
||||||
|
HTTP_STATUS_INTERNAL_SERVER_ERROR: Final[int] = 500
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ВРЕМЕННЫЕ ИНТЕРВАЛЫ
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Количество дней для статистики "за неделю"
|
||||||
|
WEEK_DAYS: Final[int] = 7
|
||||||
|
|
||||||
|
# Количество дней для статистики "за сегодня"
|
||||||
|
TODAY_DAYS: Final[int] = 1
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# СИМВОЛЫ И ЭМОДЗИ
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Эмодзи для статусов пользователей
|
||||||
|
SUPERUSER_EMOJI: Final[str] = "🔍"
|
||||||
|
REGULAR_USER_EMOJI: Final[str] = "👤"
|
||||||
|
|
||||||
|
# Эмодзи для навигации
|
||||||
|
PREVIOUS_PAGE_EMOJI: Final[str] = "⬅️"
|
||||||
|
NEXT_PAGE_EMOJI: Final[str] = "➡️"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# МАССИВЫ И СПИСКИ
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Пустые значения для проверки
|
||||||
|
EMPTY_VALUES: Final[tuple] = ('0', '')
|
||||||
|
|
||||||
|
# Разрешенные обновления для polling
|
||||||
|
ALLOWED_UPDATES: Final[list] = ["message", "callback_query"]
|
||||||
156
database/crud.py
156
database/crud.py
@@ -331,21 +331,31 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
"""Создание нового вопроса"""
|
"""Создание нового вопроса"""
|
||||||
logger.info(f"❓ Создание вопроса от {question.from_user_id} к {question.to_user_id}")
|
logger.info(f"❓ Создание вопроса от {question.from_user_id} к {question.to_user_id}")
|
||||||
async with self.get_connection() as conn:
|
async with self.get_connection() as conn:
|
||||||
|
# Вычисляем user_question_number для получателя
|
||||||
|
if question.user_question_number is None:
|
||||||
|
cursor = await conn.execute("""
|
||||||
|
SELECT COALESCE(MAX(user_question_number), 0) + 1
|
||||||
|
FROM questions
|
||||||
|
WHERE to_user_id = ? AND status != 'deleted'
|
||||||
|
""", (question.to_user_id,))
|
||||||
|
result = await cursor.fetchone()
|
||||||
|
question.user_question_number = result[0] if result else 1
|
||||||
|
|
||||||
cursor = await conn.execute("""
|
cursor = await conn.execute("""
|
||||||
INSERT INTO questions
|
INSERT INTO questions
|
||||||
(from_user_id, to_user_id, message_text, answer_text, is_anonymous,
|
(from_user_id, to_user_id, message_text, answer_text, is_anonymous,
|
||||||
message_id, created_at, answered_at, is_read, status)
|
message_id, created_at, answered_at, is_read, status, user_question_number)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", (
|
""", (
|
||||||
question.from_user_id, question.to_user_id, question.message_text,
|
question.from_user_id, question.to_user_id, question.message_text,
|
||||||
question.answer_text, question.is_anonymous, question.message_id,
|
question.answer_text, question.is_anonymous, question.message_id,
|
||||||
question.created_at.isoformat() if question.created_at else None,
|
question.created_at.isoformat() if question.created_at else None,
|
||||||
question.answered_at.isoformat() if question.answered_at else None,
|
question.answered_at.isoformat() if question.answered_at else None,
|
||||||
question.is_read, question.status.value
|
question.is_read, question.status.value, question.user_question_number
|
||||||
))
|
))
|
||||||
question.id = cursor.lastrowid
|
question.id = cursor.lastrowid
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
logger.info(f"✅ Вопрос создан с ID: {question.id}")
|
logger.info(f"✅ Вопрос создан с ID: {question.id}, номер для пользователя: {question.user_question_number}")
|
||||||
return question
|
return question
|
||||||
|
|
||||||
async def create_batch(self, questions: List[Question]) -> List[Question]:
|
async def create_batch(self, questions: List[Question]) -> List[Question]:
|
||||||
@@ -356,6 +366,27 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
logger.info(f"📦 Создание {len(questions)} вопросов batch операцией")
|
logger.info(f"📦 Создание {len(questions)} вопросов batch операцией")
|
||||||
async with self.get_connection() as conn:
|
async with self.get_connection() as conn:
|
||||||
try:
|
try:
|
||||||
|
# Группируем вопросы по получателям для вычисления user_question_number
|
||||||
|
questions_by_user = {}
|
||||||
|
for question in questions:
|
||||||
|
if question.to_user_id not in questions_by_user:
|
||||||
|
questions_by_user[question.to_user_id] = []
|
||||||
|
questions_by_user[question.to_user_id].append(question)
|
||||||
|
|
||||||
|
# Вычисляем user_question_number для каждого пользователя
|
||||||
|
for to_user_id, user_questions in questions_by_user.items():
|
||||||
|
cursor = await conn.execute("""
|
||||||
|
SELECT COALESCE(MAX(user_question_number), 0)
|
||||||
|
FROM questions
|
||||||
|
WHERE to_user_id = ? AND status != 'deleted'
|
||||||
|
""", (to_user_id,))
|
||||||
|
result = await cursor.fetchone()
|
||||||
|
start_number = (result[0] if result else 0) + 1
|
||||||
|
|
||||||
|
for i, question in enumerate(user_questions):
|
||||||
|
if question.user_question_number is None:
|
||||||
|
question.user_question_number = start_number + i
|
||||||
|
|
||||||
# Подготавливаем данные для batch вставки
|
# Подготавливаем данные для batch вставки
|
||||||
batch_data = []
|
batch_data = []
|
||||||
for question in questions:
|
for question in questions:
|
||||||
@@ -364,15 +395,15 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
question.answer_text, question.is_anonymous, question.message_id,
|
question.answer_text, question.is_anonymous, question.message_id,
|
||||||
question.created_at.isoformat() if question.created_at else None,
|
question.created_at.isoformat() if question.created_at else None,
|
||||||
question.answered_at.isoformat() if question.answered_at else None,
|
question.answered_at.isoformat() if question.answered_at else None,
|
||||||
question.is_read, question.status.value
|
question.is_read, question.status.value, question.user_question_number
|
||||||
))
|
))
|
||||||
|
|
||||||
# Выполняем batch вставку
|
# Выполняем batch вставку
|
||||||
cursor = await conn.executemany("""
|
cursor = await conn.executemany("""
|
||||||
INSERT INTO questions
|
INSERT INTO questions
|
||||||
(from_user_id, to_user_id, message_text, answer_text, is_anonymous,
|
(from_user_id, to_user_id, message_text, answer_text, is_anonymous,
|
||||||
message_id, created_at, answered_at, is_read, status)
|
message_id, created_at, answered_at, is_read, status, user_question_number)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""", batch_data)
|
""", batch_data)
|
||||||
|
|
||||||
# Обновляем ID для всех созданных вопросов
|
# Обновляем ID для всех созданных вопросов
|
||||||
@@ -393,7 +424,11 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
"""Получение вопроса по ID"""
|
"""Получение вопроса по ID"""
|
||||||
async with self.get_connection() as conn:
|
async with self.get_connection() as conn:
|
||||||
async with conn.execute("""
|
async with conn.execute("""
|
||||||
SELECT * FROM questions WHERE id = ?
|
SELECT
|
||||||
|
id, from_user_id, to_user_id, message_text, answer_text,
|
||||||
|
is_anonymous, message_id, created_at, answered_at,
|
||||||
|
is_read, status, user_question_number
|
||||||
|
FROM questions WHERE id = ?
|
||||||
""", (question_id,)) as cursor:
|
""", (question_id,)) as cursor:
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
@@ -408,7 +443,7 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
SELECT
|
SELECT
|
||||||
q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text,
|
q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text,
|
||||||
q.is_anonymous, q.message_id, q.created_at, q.answered_at,
|
q.is_anonymous, q.message_id, q.created_at, q.answered_at,
|
||||||
q.is_read, q.status
|
q.is_read, q.status, q.user_question_number
|
||||||
FROM questions q
|
FROM questions q
|
||||||
WHERE q.to_user_id = ?
|
WHERE q.to_user_id = ?
|
||||||
"""
|
"""
|
||||||
@@ -418,7 +453,7 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
query += " AND q.status = ?"
|
query += " AND q.status = ?"
|
||||||
params.append(status.value)
|
params.append(status.value)
|
||||||
|
|
||||||
query += " ORDER BY q.created_at DESC LIMIT ? OFFSET ?"
|
query += " ORDER BY q.user_question_number DESC LIMIT ? OFFSET ?"
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
async with conn.execute(query, params) as cursor:
|
async with conn.execute(query, params) as cursor:
|
||||||
@@ -455,7 +490,7 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
query += " AND q.status = ?"
|
query += " AND q.status = ?"
|
||||||
params.append(status.value)
|
params.append(status.value)
|
||||||
|
|
||||||
query += " ORDER BY q.created_at DESC LIMIT ? OFFSET ?"
|
query += " ORDER BY q.user_question_number DESC LIMIT ? OFFSET ?"
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
async with conn.execute(query, params) as cursor:
|
async with conn.execute(query, params) as cursor:
|
||||||
@@ -467,9 +502,6 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
if question is None:
|
if question is None:
|
||||||
print(f"Предупреждение: вопрос не создан для строки {row[:11]}")
|
print(f"Предупреждение: вопрос не создан для строки {row[:11]}")
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка при создании вопроса из строки {row[:11]}: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
author = None
|
author = None
|
||||||
if row[11]: # Если есть author_id
|
if row[11]: # Если есть author_id
|
||||||
@@ -489,6 +521,9 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
ban_reason=row[23]
|
ban_reason=row[23]
|
||||||
)
|
)
|
||||||
result.append((question, author))
|
result.append((question, author))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при создании вопроса из строки {row[:11]}: {e}")
|
||||||
|
continue
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def get_by_to_user_cursor(
|
async def get_by_to_user_cursor(
|
||||||
@@ -506,7 +541,7 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
SELECT
|
SELECT
|
||||||
q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text,
|
q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text,
|
||||||
q.is_anonymous, q.message_id, q.created_at, q.answered_at,
|
q.is_anonymous, q.message_id, q.created_at, q.answered_at,
|
||||||
q.is_read, q.status
|
q.is_read, q.status, q.user_question_number
|
||||||
FROM questions q
|
FROM questions q
|
||||||
WHERE q.to_user_id = ?
|
WHERE q.to_user_id = ?
|
||||||
AND (q.created_at < ? OR (q.created_at = ? AND q.id < ?))
|
AND (q.created_at < ? OR (q.created_at = ? AND q.id < ?))
|
||||||
@@ -519,7 +554,7 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
SELECT
|
SELECT
|
||||||
q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text,
|
q.id, q.from_user_id, q.to_user_id, q.message_text, q.answer_text,
|
||||||
q.is_anonymous, q.message_id, q.created_at, q.answered_at,
|
q.is_anonymous, q.message_id, q.created_at, q.answered_at,
|
||||||
q.is_read, q.status
|
q.is_read, q.status, q.user_question_number
|
||||||
FROM questions q
|
FROM questions q
|
||||||
WHERE q.to_user_id = ?
|
WHERE q.to_user_id = ?
|
||||||
AND (q.created_at > ? OR (q.created_at = ? AND q.id > ?))
|
AND (q.created_at > ? OR (q.created_at = ? AND q.id > ?))
|
||||||
@@ -555,7 +590,7 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
query += " AND q.status = ?"
|
query += " AND q.status = ?"
|
||||||
params.append(status.value)
|
params.append(status.value)
|
||||||
|
|
||||||
query += " ORDER BY q.created_at ASC LIMIT ? OFFSET ?"
|
query += " ORDER BY q.user_question_number ASC LIMIT ? OFFSET ?"
|
||||||
params.extend([limit, offset])
|
params.extend([limit, offset])
|
||||||
|
|
||||||
async with conn.execute(query, params) as cursor:
|
async with conn.execute(query, params) as cursor:
|
||||||
@@ -566,6 +601,46 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
"""Обновление вопроса"""
|
"""Обновление вопроса"""
|
||||||
logger.info(f"📝 Обновление вопроса {question.id} (статус: {question.status.value})")
|
logger.info(f"📝 Обновление вопроса {question.id} (статус: {question.status.value})")
|
||||||
async with self.get_connection() as conn:
|
async with self.get_connection() as conn:
|
||||||
|
# Если вопрос помечается как удаленный, нужно пересчитать номера
|
||||||
|
if question.status.value == 'deleted':
|
||||||
|
# Получаем текущий статус вопроса
|
||||||
|
cursor = await conn.execute("""
|
||||||
|
SELECT status, to_user_id, user_question_number FROM questions WHERE id = ?
|
||||||
|
""", (question.id,))
|
||||||
|
old_info = await cursor.fetchone()
|
||||||
|
|
||||||
|
if old_info and old_info[0] != 'deleted':
|
||||||
|
# Вопрос переходит в статус 'deleted', пересчитываем номера
|
||||||
|
to_user_id, deleted_number = old_info[1], old_info[2]
|
||||||
|
|
||||||
|
# Сначала обновляем вопрос, устанавливая user_question_number в NULL
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE questions SET
|
||||||
|
answer_text = ?, status = ?, answered_at = ?, is_read = ?,
|
||||||
|
user_question_number = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
""", (
|
||||||
|
question.answer_text, question.status.value,
|
||||||
|
question.answered_at.isoformat() if question.answered_at else None,
|
||||||
|
question.is_read, question.id
|
||||||
|
))
|
||||||
|
|
||||||
|
# Обновляем объект question, устанавливая user_question_number в None
|
||||||
|
question.user_question_number = None
|
||||||
|
|
||||||
|
# Пересчитываем номера для всех вопросов пользователя после удаленного
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE questions
|
||||||
|
SET user_question_number = user_question_number - 1
|
||||||
|
WHERE to_user_id = ?
|
||||||
|
AND user_question_number > ?
|
||||||
|
AND status != 'deleted'
|
||||||
|
AND id != ?
|
||||||
|
""", (to_user_id, deleted_number, question.id))
|
||||||
|
|
||||||
|
logger.info(f"🗑️ Вопрос {question.id} помечен как удаленный, пересчитаны номера для пользователя {to_user_id}")
|
||||||
|
else:
|
||||||
|
# Обычное обновление
|
||||||
await conn.execute("""
|
await conn.execute("""
|
||||||
UPDATE questions SET
|
UPDATE questions SET
|
||||||
answer_text = ?, status = ?, answered_at = ?, is_read = ?
|
answer_text = ?, status = ?, answered_at = ?, is_read = ?
|
||||||
@@ -575,18 +650,56 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
question.answered_at.isoformat() if question.answered_at else None,
|
question.answered_at.isoformat() if question.answered_at else None,
|
||||||
question.is_read, question.id
|
question.is_read, question.id
|
||||||
))
|
))
|
||||||
|
else:
|
||||||
|
# Обычное обновление
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE questions SET
|
||||||
|
answer_text = ?, status = ?, answered_at = ?, is_read = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", (
|
||||||
|
question.answer_text, question.status.value,
|
||||||
|
question.answered_at.isoformat() if question.answered_at else None,
|
||||||
|
question.is_read, question.id
|
||||||
|
))
|
||||||
|
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
logger.info(f"✅ Вопрос {question.id} обновлен")
|
logger.info(f"✅ Вопрос {question.id} обновлен")
|
||||||
return question
|
return question
|
||||||
|
|
||||||
async def delete(self, question_id: int) -> bool:
|
async def delete(self, question_id: int) -> bool:
|
||||||
"""Удаление вопроса"""
|
"""Удаление вопроса с пересчетом user_question_number"""
|
||||||
async with self.get_connection() as conn:
|
async with self.get_connection() as conn:
|
||||||
|
# Сначала получаем информацию о вопросе
|
||||||
|
cursor = await conn.execute("""
|
||||||
|
SELECT to_user_id, user_question_number FROM questions WHERE id = ?
|
||||||
|
""", (question_id,))
|
||||||
|
question_info = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not question_info:
|
||||||
|
return False
|
||||||
|
|
||||||
|
to_user_id, deleted_number = question_info
|
||||||
|
|
||||||
|
# Удаляем вопрос
|
||||||
cursor = await conn.execute("""
|
cursor = await conn.execute("""
|
||||||
DELETE FROM questions WHERE id = ?
|
DELETE FROM questions WHERE id = ?
|
||||||
""", (question_id,))
|
""", (question_id,))
|
||||||
|
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Пересчитываем номера для всех вопросов пользователя после удаленного
|
||||||
|
await conn.execute("""
|
||||||
|
UPDATE questions
|
||||||
|
SET user_question_number = user_question_number - 1
|
||||||
|
WHERE to_user_id = ?
|
||||||
|
AND user_question_number > ?
|
||||||
|
AND status != 'deleted'
|
||||||
|
""", (to_user_id, deleted_number))
|
||||||
|
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
return cursor.rowcount > 0
|
logger.info(f"🗑️ Вопрос {question_id} удален, пересчитаны номера для пользователя {to_user_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
async def get_unread_count(self, to_user_id: int) -> int:
|
async def get_unread_count(self, to_user_id: int) -> int:
|
||||||
"""Получение количества непрочитанных вопросов"""
|
"""Получение количества непрочитанных вопросов"""
|
||||||
@@ -641,7 +754,7 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
"""Преобразование строки БД в объект Question"""
|
"""Преобразование строки БД в объект Question"""
|
||||||
# Проверяем, что все необходимые поля присутствуют
|
# Проверяем, что все необходимые поля присутствуют
|
||||||
if len(row) < 11:
|
if len(row) < 11:
|
||||||
raise ValueError(f"Недостаточно данных в строке БД: {len(row)} колонок, ожидается 11")
|
raise ValueError(f"Недостаточно данных в строке БД: {len(row)} колонок, ожидается минимум 11")
|
||||||
|
|
||||||
# Проверяем статус
|
# Проверяем статус
|
||||||
status_value = row[10]
|
status_value = row[10]
|
||||||
@@ -665,7 +778,8 @@ class QuestionCRUD(BaseCRUD):
|
|||||||
created_at=self._parse_datetime(row[7]),
|
created_at=self._parse_datetime(row[7]),
|
||||||
answered_at=self._parse_datetime(row[8]),
|
answered_at=self._parse_datetime(row[8]),
|
||||||
is_read=bool(row[9]),
|
is_read=bool(row[9]),
|
||||||
status=status
|
status=status,
|
||||||
|
user_question_number=row[11] if len(row) > 11 else None
|
||||||
)
|
)
|
||||||
return question
|
return question
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ CREATE TABLE questions (
|
|||||||
answered_at DATETIME,
|
answered_at DATETIME,
|
||||||
is_read BOOLEAN DEFAULT FALSE,
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'answered', 'rejected', 'deleted')),
|
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'answered', 'rejected', 'deleted')),
|
||||||
|
user_question_number INTEGER,
|
||||||
|
|
||||||
-- Внешние ключи
|
-- Внешние ключи
|
||||||
FOREIGN KEY (from_user_id) REFERENCES users(telegram_id) ON DELETE CASCADE,
|
FOREIGN KEY (from_user_id) REFERENCES users(telegram_id) ON DELETE CASCADE,
|
||||||
@@ -76,6 +77,8 @@ CREATE INDEX idx_questions_from_user_id ON questions(from_user_id);
|
|||||||
CREATE INDEX idx_questions_status ON questions(status);
|
CREATE INDEX idx_questions_status ON questions(status);
|
||||||
CREATE INDEX idx_questions_created_at ON questions(created_at);
|
CREATE INDEX idx_questions_created_at ON questions(created_at);
|
||||||
CREATE INDEX idx_questions_is_read ON questions(is_read);
|
CREATE INDEX idx_questions_is_read ON questions(is_read);
|
||||||
|
CREATE INDEX idx_questions_user_question_number ON questions(to_user_id, user_question_number);
|
||||||
|
CREATE UNIQUE INDEX idx_questions_user_number_unique ON questions(to_user_id, user_question_number) WHERE status != 'deleted';
|
||||||
|
|
||||||
CREATE INDEX idx_user_blocks_blocker_id ON user_blocks(blocker_id);
|
CREATE INDEX idx_user_blocks_blocker_id ON user_blocks(blocker_id);
|
||||||
CREATE INDEX idx_user_blocks_blocked_id ON user_blocks(blocked_id);
|
CREATE INDEX idx_user_blocks_blocked_id ON user_blocks(blocked_id);
|
||||||
@@ -106,3 +109,39 @@ FOR EACH ROW
|
|||||||
BEGIN
|
BEGIN
|
||||||
INSERT OR IGNORE INTO user_settings (user_id) VALUES (NEW.telegram_id);
|
INSERT OR IGNORE INTO user_settings (user_id) VALUES (NEW.telegram_id);
|
||||||
END;
|
END;
|
||||||
|
|
||||||
|
-- Триггер для автоматического вычисления user_question_number при вставке
|
||||||
|
CREATE TRIGGER calculate_user_question_number
|
||||||
|
AFTER INSERT ON questions
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN NEW.user_question_number IS NULL
|
||||||
|
BEGIN
|
||||||
|
UPDATE questions
|
||||||
|
SET user_question_number = (
|
||||||
|
SELECT COALESCE(MAX(user_question_number), 0) + 1
|
||||||
|
FROM questions q2
|
||||||
|
WHERE q2.to_user_id = NEW.to_user_id
|
||||||
|
AND q2.status != 'deleted'
|
||||||
|
)
|
||||||
|
WHERE id = NEW.id;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Триггер для пересчета номеров при удалении вопроса
|
||||||
|
CREATE TRIGGER recalculate_user_question_numbers_on_delete
|
||||||
|
AFTER UPDATE ON questions
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN NEW.status = 'deleted' AND OLD.status != 'deleted'
|
||||||
|
BEGIN
|
||||||
|
-- Устанавливаем user_question_number в NULL для удаленного вопроса
|
||||||
|
UPDATE questions
|
||||||
|
SET user_question_number = NULL
|
||||||
|
WHERE id = NEW.id;
|
||||||
|
|
||||||
|
-- Пересчитываем номера для всех вопросов пользователя после удаленного
|
||||||
|
UPDATE questions
|
||||||
|
SET user_question_number = user_question_number - 1
|
||||||
|
WHERE to_user_id = NEW.to_user_id
|
||||||
|
AND user_question_number > OLD.user_question_number
|
||||||
|
AND status != 'deleted'
|
||||||
|
AND id != NEW.id;
|
||||||
|
END;
|
||||||
|
|||||||
387
dependencies.py
Normal file
387
dependencies.py
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
"""
|
||||||
|
Система инъекции зависимостей для AnonBot с использованием MagicData
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.storage.base import BaseStorage
|
||||||
|
from aiogram.types import TelegramObject
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from services.auth.auth_new import AuthService
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.business.message_service import MessageService
|
||||||
|
from services.business.pagination_service import PaginationService
|
||||||
|
from services.permissions import get_permission_checker, init_permission_checker
|
||||||
|
from services.permissions.init_permissions import init_all_permissions
|
||||||
|
from services.business.question_service import QuestionService
|
||||||
|
from services.rate_limiting.rate_limit_service import RateLimitService
|
||||||
|
from services.business.user_service import UserService
|
||||||
|
from services.utils import UtilsService
|
||||||
|
from services.validation import InputValidator
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Dependencies:
|
||||||
|
"""Контейнер зависимостей для инъекции в обработчики"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._database: Optional[DatabaseService] = None
|
||||||
|
self._auth: Optional[AuthService] = None
|
||||||
|
self._utils: Optional[UtilsService] = None
|
||||||
|
self._user_service: Optional[UserService] = None
|
||||||
|
self._question_service: Optional[QuestionService] = None
|
||||||
|
self._message_service: Optional[MessageService] = None
|
||||||
|
self._pagination_service: Optional[PaginationService] = None
|
||||||
|
self._rate_limit_service: Optional[RateLimitService] = None
|
||||||
|
self._validator: Optional[InputValidator] = None
|
||||||
|
self._config = config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database(self) -> DatabaseService:
|
||||||
|
"""Получение экземпляра DatabaseService"""
|
||||||
|
if self._database is None:
|
||||||
|
self._database = DatabaseService(config.DATABASE_PATH)
|
||||||
|
return self._database
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth(self) -> AuthService:
|
||||||
|
"""Получение экземпляра AuthService (с системой разрешений)"""
|
||||||
|
if self._auth is None:
|
||||||
|
self._auth = AuthService(self.database, config)
|
||||||
|
return self._auth
|
||||||
|
|
||||||
|
@property
|
||||||
|
def utils(self) -> UtilsService:
|
||||||
|
"""Получение экземпляра UtilsService"""
|
||||||
|
if self._utils is None:
|
||||||
|
self._utils = UtilsService(self.database)
|
||||||
|
return self._utils
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_service(self) -> UserService:
|
||||||
|
"""Получение экземпляра UserService"""
|
||||||
|
if self._user_service is None:
|
||||||
|
self._user_service = UserService(self.database, self.utils)
|
||||||
|
return self._user_service
|
||||||
|
|
||||||
|
@property
|
||||||
|
def question_service(self) -> QuestionService:
|
||||||
|
"""Получение экземпляра QuestionService"""
|
||||||
|
if self._question_service is None:
|
||||||
|
self._question_service = QuestionService(self.database, self.utils)
|
||||||
|
return self._question_service
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_service(self) -> MessageService:
|
||||||
|
"""Получение экземпляра MessageService"""
|
||||||
|
if self._message_service is None:
|
||||||
|
# Убеждаемся, что rate_limit_service создан первым
|
||||||
|
rate_limit_service = self.rate_limit_service
|
||||||
|
self._message_service = MessageService(rate_limit_service)
|
||||||
|
return self._message_service
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pagination_service(self) -> PaginationService:
|
||||||
|
"""Получение экземпляра PaginationService"""
|
||||||
|
if self._pagination_service is None:
|
||||||
|
self._pagination_service = PaginationService()
|
||||||
|
return self._pagination_service
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rate_limit_service(self) -> RateLimitService:
|
||||||
|
"""Получение экземпляра RateLimitService"""
|
||||||
|
if self._rate_limit_service is None:
|
||||||
|
self._rate_limit_service = RateLimitService()
|
||||||
|
return self._rate_limit_service
|
||||||
|
|
||||||
|
@property
|
||||||
|
def validator(self) -> InputValidator:
|
||||||
|
"""Получение экземпляра InputValidator"""
|
||||||
|
if self._validator is None:
|
||||||
|
self._validator = InputValidator()
|
||||||
|
return self._validator
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self):
|
||||||
|
"""Получение конфигурации"""
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
async def init(self):
|
||||||
|
"""Инициализация всех сервисов"""
|
||||||
|
try:
|
||||||
|
await self.database.init()
|
||||||
|
|
||||||
|
# Инициализируем систему разрешений
|
||||||
|
init_all_permissions()
|
||||||
|
init_permission_checker(self.database, self.config)
|
||||||
|
|
||||||
|
logger.info("✅ Все зависимости инициализированы")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка инициализации зависимостей: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Закрытие всех соединений"""
|
||||||
|
try:
|
||||||
|
if self._database:
|
||||||
|
await self._database.close()
|
||||||
|
logger.info("✅ Все зависимости закрыты")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка закрытия зависимостей: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class DependencyMiddleware(BaseMiddleware):
|
||||||
|
"""Middleware для инъекции зависимостей в обработчики"""
|
||||||
|
|
||||||
|
def __init__(self, dependencies: Dependencies):
|
||||||
|
super().__init__()
|
||||||
|
self.dependencies = dependencies
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler,
|
||||||
|
event: TelegramObject,
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
"""Инъекция зависимостей в контекст обработчика"""
|
||||||
|
# Добавляем зависимости в контекст
|
||||||
|
data['deps'] = self.dependencies
|
||||||
|
data['database'] = self.dependencies.database
|
||||||
|
data['auth'] = self.dependencies.auth
|
||||||
|
data['utils'] = self.dependencies.utils
|
||||||
|
data['user_service'] = self.dependencies.user_service
|
||||||
|
data['question_service'] = self.dependencies.question_service
|
||||||
|
data['message_service'] = self.dependencies.message_service
|
||||||
|
data['pagination_service'] = self.dependencies.pagination_service
|
||||||
|
data['rate_limit_service'] = self.dependencies.rate_limit_service
|
||||||
|
data['validator'] = self.dependencies.validator
|
||||||
|
data['config'] = self.dependencies.config
|
||||||
|
data['permission_checker'] = get_permission_checker()
|
||||||
|
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальный экземпляр зависимостей
|
||||||
|
dependencies = Dependencies()
|
||||||
|
|
||||||
|
|
||||||
|
def get_dependencies() -> Dependencies:
|
||||||
|
"""Получение глобального экземпляра зависимостей"""
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
|
||||||
|
def get_database() -> DatabaseService:
|
||||||
|
"""Получение экземпляра DatabaseService (для обратной совместимости)"""
|
||||||
|
return dependencies.database
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth() -> AuthService:
|
||||||
|
"""Получение экземпляра AuthService (с системой разрешений)"""
|
||||||
|
return dependencies.auth
|
||||||
|
|
||||||
|
|
||||||
|
def get_utils() -> UtilsService:
|
||||||
|
"""Получение экземпляра UtilsService"""
|
||||||
|
return dependencies.utils
|
||||||
|
|
||||||
|
|
||||||
|
# Декораторы для удобства использования
|
||||||
|
def inject_database(func):
|
||||||
|
"""Декоратор для инъекции DatabaseService"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
if 'database' not in kwargs:
|
||||||
|
kwargs['database'] = get_database()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def inject_auth(func):
|
||||||
|
"""Декоратор для инъекции AuthService"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
if 'auth' not in kwargs:
|
||||||
|
kwargs['auth'] = get_auth()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def inject_utils(func):
|
||||||
|
"""Декоратор для инъекции UtilsService"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
if 'utils' not in kwargs:
|
||||||
|
kwargs['utils'] = get_utils()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_service() -> UserService:
|
||||||
|
"""Получение экземпляра UserService"""
|
||||||
|
return dependencies.user_service
|
||||||
|
|
||||||
|
|
||||||
|
def get_question_service() -> QuestionService:
|
||||||
|
"""Получение экземпляра QuestionService"""
|
||||||
|
return dependencies.question_service
|
||||||
|
|
||||||
|
|
||||||
|
def get_message_service() -> MessageService:
|
||||||
|
"""Получение экземпляра MessageService"""
|
||||||
|
return dependencies.message_service
|
||||||
|
|
||||||
|
|
||||||
|
def get_pagination_service() -> PaginationService:
|
||||||
|
"""Получение экземпляра PaginationService"""
|
||||||
|
return dependencies.pagination_service
|
||||||
|
|
||||||
|
|
||||||
|
def get_rate_limit_service() -> RateLimitService:
|
||||||
|
"""Получение экземпляра RateLimitService"""
|
||||||
|
return dependencies.rate_limit_service
|
||||||
|
|
||||||
|
|
||||||
|
def get_validator() -> InputValidator:
|
||||||
|
"""Получение экземпляра InputValidator"""
|
||||||
|
return dependencies.validator
|
||||||
|
|
||||||
|
|
||||||
|
def inject_all(func):
|
||||||
|
"""Декоратор для инъекции всех основных сервисов"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Фильтруем kwargs, убираем лишние параметры
|
||||||
|
import inspect
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
filtered_kwargs = {k: v for k, v in kwargs.items() if k in sig.parameters}
|
||||||
|
|
||||||
|
# Добавляем только те сервисы, которые ожидает функция
|
||||||
|
if 'database' in sig.parameters and 'database' not in filtered_kwargs:
|
||||||
|
filtered_kwargs['database'] = get_database()
|
||||||
|
if 'auth' in sig.parameters and 'auth' not in filtered_kwargs:
|
||||||
|
filtered_kwargs['auth'] = get_auth()
|
||||||
|
if 'utils' in sig.parameters and 'utils' not in filtered_kwargs:
|
||||||
|
filtered_kwargs['utils'] = get_utils()
|
||||||
|
if 'user_service' in sig.parameters and 'user_service' not in filtered_kwargs:
|
||||||
|
filtered_kwargs['user_service'] = get_user_service()
|
||||||
|
if 'question_service' in sig.parameters and 'question_service' not in filtered_kwargs:
|
||||||
|
filtered_kwargs['question_service'] = get_question_service()
|
||||||
|
if 'message_service' in sig.parameters and 'message_service' not in filtered_kwargs:
|
||||||
|
filtered_kwargs['message_service'] = get_message_service()
|
||||||
|
if 'pagination_service' in sig.parameters and 'pagination_service' not in filtered_kwargs:
|
||||||
|
filtered_kwargs['pagination_service'] = get_pagination_service()
|
||||||
|
if 'rate_limit_service' in sig.parameters and 'rate_limit_service' not in filtered_kwargs:
|
||||||
|
filtered_kwargs['rate_limit_service'] = get_rate_limit_service()
|
||||||
|
if 'validator' in sig.parameters and 'validator' not in filtered_kwargs:
|
||||||
|
filtered_kwargs['validator'] = get_validator()
|
||||||
|
if 'config' in sig.parameters and 'config' not in filtered_kwargs:
|
||||||
|
filtered_kwargs['config'] = get_dependencies().config
|
||||||
|
return await func(*args, **filtered_kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_service() -> DatabaseService:
|
||||||
|
"""Получить экземпляр DatabaseService"""
|
||||||
|
return get_dependencies().database
|
||||||
|
|
||||||
|
|
||||||
|
# Специализированные декораторы для конкретных случаев использования
|
||||||
|
def inject_question_services(func):
|
||||||
|
"""Декоратор для инъекции сервисов, нужных для обработки вопросов"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
if 'question_service' not in kwargs:
|
||||||
|
kwargs['question_service'] = get_question_service()
|
||||||
|
if 'user_service' not in kwargs:
|
||||||
|
kwargs['user_service'] = get_user_service()
|
||||||
|
if 'message_service' not in kwargs:
|
||||||
|
kwargs['message_service'] = get_message_service()
|
||||||
|
if 'validator' not in kwargs:
|
||||||
|
kwargs['validator'] = get_validator()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def inject_answer_services(func):
|
||||||
|
"""Декоратор для инъекции сервисов, нужных для обработки ответов"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
if 'validator' not in kwargs:
|
||||||
|
kwargs['validator'] = get_validator()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def inject_validator(func):
|
||||||
|
"""Декоратор для инъекции только валидатора"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
|
||||||
|
if 'validator' not in kwargs:
|
||||||
|
kwargs['validator'] = get_validator()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def inject_message_services(func):
|
||||||
|
"""Декоратор для инъекции сервисов, нужных для отправки сообщений"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
|
||||||
|
if 'message_service' not in kwargs:
|
||||||
|
kwargs['message_service'] = get_message_service()
|
||||||
|
if 'user_service' not in kwargs:
|
||||||
|
kwargs['user_service'] = get_user_service()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def inject_start_services(func):
|
||||||
|
"""Декоратор для инъекции сервисов, нужных для команды /start"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
|
||||||
|
if 'user_service' not in kwargs:
|
||||||
|
kwargs['user_service'] = get_user_service()
|
||||||
|
if 'auth' not in kwargs:
|
||||||
|
kwargs['auth'] = get_auth()
|
||||||
|
if 'utils' not in kwargs:
|
||||||
|
kwargs['utils'] = get_utils()
|
||||||
|
if 'message_service' not in kwargs:
|
||||||
|
kwargs['message_service'] = get_message_service()
|
||||||
|
if 'validator' not in kwargs:
|
||||||
|
kwargs['validator'] = get_validator()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def inject_link_services(func):
|
||||||
|
"""Декоратор для инъекции сервисов, нужных для кнопки 'Моя ссылка'"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
|
||||||
|
if 'user_service' not in kwargs:
|
||||||
|
kwargs['user_service'] = get_user_service()
|
||||||
|
if 'message_service' not in kwargs:
|
||||||
|
kwargs['message_service'] = get_message_service()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def inject_main_menu_services(func):
|
||||||
|
"""Декоратор для инъекции сервисов, нужных для кнопки 'Главное меню'"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
|
||||||
|
if 'auth' not in kwargs:
|
||||||
|
kwargs['auth'] = get_auth()
|
||||||
|
if 'message_service' not in kwargs:
|
||||||
|
kwargs['message_service'] = get_message_service()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def inject_admin_services(func):
|
||||||
|
"""Декоратор для инъекции сервисов, нужных для админ-панели"""
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
if 'rate_limit_service' not in kwargs:
|
||||||
|
kwargs['rate_limit_service'] = get_rate_limit_service()
|
||||||
|
if 'message_service' not in kwargs:
|
||||||
|
kwargs['message_service'] = get_message_service()
|
||||||
|
if 'auth' not in kwargs:
|
||||||
|
kwargs['auth'] = get_auth()
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
7
handlers/__init__.py
Normal file
7
handlers/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Обработчики для бота анонимных вопросов
|
||||||
|
"""
|
||||||
|
|
||||||
|
from . import start, questions, answers, admin, errors
|
||||||
|
|
||||||
|
__all__ = ['start', 'questions', 'answers', 'admin', 'errors']
|
||||||
660
handlers/admin.py
Normal file
660
handlers/admin.py
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
"""
|
||||||
|
Обработчики для администраторов
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import CallbackQuery, Message
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from config.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, MIN_PAGE_NUMBER, PERCENTAGE_DECIMAL_PLACES, TIME_DECIMAL_PLACES
|
||||||
|
from keyboards.inline import (
|
||||||
|
get_admin_keyboard, get_ban_confirm_keyboard, get_ban_duration_keyboard,
|
||||||
|
get_ban_user_keyboard, get_rate_limit_keyboard, get_stats_keyboard,
|
||||||
|
get_superuser_assignment_keyboard, get_superuser_confirm_keyboard, get_unban_keyboard
|
||||||
|
)
|
||||||
|
from services.auth.auth_new import AuthService
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.business.message_service import MessageService
|
||||||
|
from services.business.pagination_service import PaginationService
|
||||||
|
from services.permissions.decorators import require_admin_or_superuser, require_permission
|
||||||
|
from services.rate_limiting.rate_limit_service import RateLimitService
|
||||||
|
from services.business.user_service import UserService
|
||||||
|
from services.utils import format_stats
|
||||||
|
from dependencies import inject_admin_services
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
async def _format_users_list(
|
||||||
|
users: list,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
pagination_service: PaginationService
|
||||||
|
) -> str:
|
||||||
|
"""Форматирование списка пользователей для отображения"""
|
||||||
|
try:
|
||||||
|
# Рассчитываем пагинацию
|
||||||
|
page_users, total_users, current_page, total_pages = pagination_service.calculate_pagination(
|
||||||
|
users, page, per_page
|
||||||
|
)
|
||||||
|
|
||||||
|
# Формируем информацию о пагинации
|
||||||
|
start_idx = current_page * per_page
|
||||||
|
end_idx = min(start_idx + per_page, total_users)
|
||||||
|
pagination_info = pagination_service.format_pagination_info(
|
||||||
|
current_page, total_pages, start_idx, end_idx, total_users
|
||||||
|
)
|
||||||
|
|
||||||
|
# Формируем текст сообщения
|
||||||
|
users_text = f"🔍 <b>Управление суперпользователями</b>\n\n"
|
||||||
|
users_text += pagination_info
|
||||||
|
|
||||||
|
# Добавляем информацию о пользователях
|
||||||
|
for i, user in enumerate(page_users, start_idx + 1):
|
||||||
|
status_emoji = "🔍" if user.is_superuser else "👤"
|
||||||
|
users_text += f"{i}. {status_emoji} {user.display_name}\n"
|
||||||
|
if user.username:
|
||||||
|
users_text += f" @{user.username}\n"
|
||||||
|
created_at_str = user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Неизвестно'
|
||||||
|
users_text += f" 📅 {created_at_str}\n\n"
|
||||||
|
|
||||||
|
users_text += "💡 Нажмите на пользователя для изменения его статуса."
|
||||||
|
|
||||||
|
return users_text
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при форматировании списка пользователей: {e}")
|
||||||
|
return "❌ Ошибка при форматировании списка пользователей."
|
||||||
|
|
||||||
|
|
||||||
|
# Функция is_admin теперь импортируется из services.auth
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("stats"))
|
||||||
|
@require_permission("view_stats", "❌ У вас нет прав для выполнения этой команды.")
|
||||||
|
async def cmd_stats(message: Message, database: DatabaseService = None):
|
||||||
|
"""Обработчик команды /stats"""
|
||||||
|
await show_stats(message, database)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "👑 Админ панель")
|
||||||
|
@require_permission("admin_panel", "❌ У вас нет прав для доступа к админ панели.")
|
||||||
|
async def admin_panel_button(message: Message):
|
||||||
|
"""Обработчик кнопки 'Админ панель'"""
|
||||||
|
admin_text = "👑 <b>Админ панель</b>\n\n"
|
||||||
|
admin_text += "Выберите действие:"
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
admin_text,
|
||||||
|
reply_markup=get_admin_keyboard(),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "📊 Статистика")
|
||||||
|
@require_permission("view_stats", "❌ У вас нет прав для просмотра статистики.")
|
||||||
|
async def stats_button(message: Message, database: DatabaseService = None):
|
||||||
|
"""Обработчик кнопки 'Статистика'"""
|
||||||
|
await show_stats(message, database)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_stats(message: Message, database: DatabaseService = None):
|
||||||
|
"""Показать статистику"""
|
||||||
|
try:
|
||||||
|
if not database:
|
||||||
|
from dependencies import get_database
|
||||||
|
database = get_database()
|
||||||
|
|
||||||
|
# Получаем статистику
|
||||||
|
users_stats = await database.get_users_stats()
|
||||||
|
questions_stats = await database.get_questions_stats()
|
||||||
|
|
||||||
|
# Объединяем статистику
|
||||||
|
all_stats = {**users_stats, **questions_stats}
|
||||||
|
|
||||||
|
# Форматируем и отправляем
|
||||||
|
stats_text = format_stats(all_stats)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
stats_text,
|
||||||
|
reply_markup=get_stats_keyboard(),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении статистики: {e}")
|
||||||
|
await message.answer(
|
||||||
|
"❌ Произошла ошибка при получении статистики. Попробуйте позже."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "📢 Рассылка")
|
||||||
|
@require_permission("broadcast", "❌ У вас нет прав для рассылки.")
|
||||||
|
async def broadcast_button(message: Message):
|
||||||
|
"""Обработчик кнопки 'Рассылка'"""
|
||||||
|
await message.answer(
|
||||||
|
"📢 <b>Рассылка</b>\n\n"
|
||||||
|
"Функция рассылки будет добавлена в следующих версиях.\n\n"
|
||||||
|
"💡 Планируемые возможности:\n"
|
||||||
|
"• Рассылка сообщений всем пользователям\n"
|
||||||
|
"• Рассылка по группам пользователей\n"
|
||||||
|
"• Планирование рассылок\n"
|
||||||
|
"• Статистика доставки",
|
||||||
|
reply_markup=get_admin_keyboard(),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "⚙️ Настройки")
|
||||||
|
@require_permission("admin_panel", "❌ У вас нет прав для изменения настроек.")
|
||||||
|
async def settings_button(message: Message):
|
||||||
|
"""Обработчик кнопки 'Настройки'"""
|
||||||
|
settings_text = "⚙️ <b>Настройки бота</b>\n\n"
|
||||||
|
settings_text += f"🔧 <b>Текущие настройки:</b>\n"
|
||||||
|
settings_text += f"• Режим отладки: {'Включен' if config.DEBUG else 'Выключен'}\n"
|
||||||
|
settings_text += f"• Макс. длина вопроса: {config.MAX_QUESTION_LENGTH} символов\n"
|
||||||
|
settings_text += f"• Макс. длина ответа: {config.MAX_ANSWER_LENGTH} символов\n"
|
||||||
|
settings_text += f"• Путь к БД: {config.DATABASE_PATH}\n\n"
|
||||||
|
settings_text += f"👑 <b>Администраторы:</b>\n"
|
||||||
|
for admin_id in config.ADMINS:
|
||||||
|
settings_text += f"• {admin_id}\n"
|
||||||
|
|
||||||
|
settings_text += "\n💡 Настройки изменяются в файле .env"
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
settings_text,
|
||||||
|
reply_markup=get_admin_keyboard(),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "admin_stats")
|
||||||
|
@require_permission("view_stats", "❌ У вас нет прав")
|
||||||
|
async def admin_stats_callback(callback: CallbackQuery, database: DatabaseService = None):
|
||||||
|
"""Обработчик кнопки 'Статистика' в админ панели"""
|
||||||
|
await show_stats(callback.message, database)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "admin_broadcast")
|
||||||
|
@require_permission("broadcast", "❌ У вас нет прав")
|
||||||
|
async def admin_broadcast_callback(callback: CallbackQuery):
|
||||||
|
"""Обработчик кнопки 'Рассылка' в админ панели"""
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📢 <b>Рассылка</b>\n\n"
|
||||||
|
"Функция рассылки будет добавлена в следующих версиях.\n\n"
|
||||||
|
"💡 Планируемые возможности:\n"
|
||||||
|
"• Рассылка сообщений всем пользователям\n"
|
||||||
|
"• Рассылка по группам пользователей\n"
|
||||||
|
"• Планирование рассылок\n"
|
||||||
|
"• Статистика доставки",
|
||||||
|
reply_markup=get_admin_keyboard(),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "stats_general")
|
||||||
|
@require_permission("view_stats", "❌ У вас нет прав")
|
||||||
|
async def stats_general_callback(callback: CallbackQuery, database: DatabaseService = None):
|
||||||
|
"""Обработчик кнопки 'Общая статистика'"""
|
||||||
|
await show_stats(callback.message, database)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "back_to_admin")
|
||||||
|
@require_permission("admin_panel", "❌ У вас нет прав")
|
||||||
|
async def back_to_admin_callback(callback: CallbackQuery):
|
||||||
|
"""Обработчик кнопки 'Назад' в админ панель"""
|
||||||
|
admin_text = "👑 <b>Админ панель</b>\n\n"
|
||||||
|
admin_text += "Выберите действие:"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
admin_text,
|
||||||
|
reply_markup=get_admin_keyboard(),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "admin_assign_superuser")
|
||||||
|
@require_permission("manage_users", "❌ У вас нет прав для управления пользователями.")
|
||||||
|
async def admin_assign_superuser_callback(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
user_service: UserService,
|
||||||
|
message_service: MessageService
|
||||||
|
):
|
||||||
|
"""Обработчик кнопки 'Назначить суперпользователя'"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Получаем пользователей с пагинацией (оптимизированный запрос)
|
||||||
|
users = await user_service.database.get_all_users(limit=MAX_PAGE_SIZE, offset=MIN_PAGE_NUMBER)
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
await message_service.edit_message(
|
||||||
|
callback,
|
||||||
|
"👥 <b>Управление суперпользователями</b>\n\n"
|
||||||
|
"❌ Пользователи не найдены.",
|
||||||
|
get_admin_keyboard()
|
||||||
|
)
|
||||||
|
await message_service.send_callback_answer(callback)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Показываем первую страницу
|
||||||
|
await show_superuser_assignment_page(
|
||||||
|
callback,
|
||||||
|
users,
|
||||||
|
page=MIN_PAGE_NUMBER,
|
||||||
|
message_service=message_service
|
||||||
|
)
|
||||||
|
await message_service.send_callback_answer(callback)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении списка пользователей: {e}")
|
||||||
|
await message_service.edit_message(
|
||||||
|
callback,
|
||||||
|
"❌ Произошла ошибка при загрузке списка пользователей.",
|
||||||
|
get_admin_keyboard()
|
||||||
|
)
|
||||||
|
await message_service.send_callback_answer(callback)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("superuser_page_"))
|
||||||
|
@require_permission("manage_users", "❌ У вас нет прав для управления пользователями.")
|
||||||
|
async def superuser_page_callback(callback: CallbackQuery, database: DatabaseService = None):
|
||||||
|
"""Обработчик пагинации списка пользователей для назначения суперпользователей"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Извлекаем номер страницы
|
||||||
|
page = int(callback.data.split("_")[-1])
|
||||||
|
|
||||||
|
if not database:
|
||||||
|
from dependencies import get_database
|
||||||
|
database = get_database()
|
||||||
|
|
||||||
|
# Получаем всех пользователей
|
||||||
|
users = await database.get_all_users(limit=MAX_PAGE_SIZE, offset=MIN_PAGE_NUMBER)
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
await callback.answer("❌ Пользователи не найдены", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Показываем нужную страницу
|
||||||
|
await show_superuser_assignment_page(callback, users, page)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await callback.answer("❌ Неверный номер страницы", show_alert=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при пагинации пользователей: {e}")
|
||||||
|
await callback.answer("❌ Ошибка при загрузке страницы", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("assign_superuser_"))
|
||||||
|
@require_permission("manage_users", "❌ У вас нет прав для управления пользователями.")
|
||||||
|
async def assign_superuser_callback(callback: CallbackQuery, database: DatabaseService = None, validator = None):
|
||||||
|
"""Обработчик выбора пользователя для назначения суперпользователем"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Извлекаем ID пользователя
|
||||||
|
user_id_str = callback.data.split("_")[-1]
|
||||||
|
|
||||||
|
# Валидируем callback data
|
||||||
|
if validator:
|
||||||
|
validation_result = validator.validate_callback_data(callback.data)
|
||||||
|
if not validation_result:
|
||||||
|
logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}")
|
||||||
|
await callback.answer("❌ Неверные данные", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Валидируем user_id
|
||||||
|
try:
|
||||||
|
user_id = int(user_id_str)
|
||||||
|
if validator:
|
||||||
|
user_id_validation = validator.validate_telegram_id(user_id)
|
||||||
|
if not user_id_validation:
|
||||||
|
logger.warning(f"⚠️ Невалидный user_id в callback: {user_id}")
|
||||||
|
await callback.answer("❌ Неверный ID пользователя", show_alert=True)
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"⚠️ Неверный формат user_id в callback: {user_id_str}")
|
||||||
|
await callback.answer("❌ Неверный формат ID", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not database:
|
||||||
|
from dependencies import get_database
|
||||||
|
database = get_database()
|
||||||
|
|
||||||
|
# Получаем информацию о пользователе
|
||||||
|
user = await database.get_user(user_id)
|
||||||
|
if not user:
|
||||||
|
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Формируем текст с информацией о пользователе
|
||||||
|
user_text = f"👤 <b>Информация о пользователе</b>\n\n"
|
||||||
|
user_text += f"🆔 ID: {user.telegram_id}\n"
|
||||||
|
user_text += f"👤 Имя: {user.display_name}\n"
|
||||||
|
user_text += f"📝 Полное имя: {user.full_name}\n"
|
||||||
|
user_text += f"🔗 Ссылка: {user.profile_link}\n"
|
||||||
|
created_at_str = user.created_at.strftime('%d.%m.%Y %H:%M') if user.created_at else 'Неизвестно'
|
||||||
|
user_text += f"📅 Регистрация: {created_at_str}\n"
|
||||||
|
user_text += f"✅ Активен: {'Да' if user.is_active else 'Нет'}\n"
|
||||||
|
user_text += f"🔍 Суперпользователь: {'Да' if user.is_superuser else 'Нет'}\n\n"
|
||||||
|
|
||||||
|
if user.is_superuser:
|
||||||
|
user_text += "❓ <b>Хотите снять права суперпользователя?</b>"
|
||||||
|
else:
|
||||||
|
user_text += "❓ <b>Хотите назначить суперпользователем?</b>"
|
||||||
|
|
||||||
|
# Показываем информацию и кнопки подтверждения
|
||||||
|
await callback.message.edit_text(
|
||||||
|
user_text,
|
||||||
|
reply_markup=get_superuser_confirm_keyboard(user_id, user.is_superuser),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await callback.answer("❌ Неверный ID пользователя", show_alert=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении информации о пользователе: {e}")
|
||||||
|
await callback.answer("❌ Ошибка при загрузке информации", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("confirm_superuser_"))
|
||||||
|
@require_permission("manage_users", "❌ У вас нет прав для управления пользователями.")
|
||||||
|
async def confirm_superuser_callback(callback: CallbackQuery, database: DatabaseService = None, validator = None):
|
||||||
|
"""Обработчик подтверждения назначения суперпользователем"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Извлекаем ID пользователя
|
||||||
|
user_id_str = callback.data.split("_")[-1]
|
||||||
|
|
||||||
|
# Валидируем callback data
|
||||||
|
if validator:
|
||||||
|
validation_result = validator.validate_callback_data(callback.data)
|
||||||
|
if not validation_result:
|
||||||
|
logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}")
|
||||||
|
await callback.answer("❌ Неверные данные", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Валидируем user_id
|
||||||
|
try:
|
||||||
|
user_id = int(user_id_str)
|
||||||
|
if validator:
|
||||||
|
user_id_validation = validator.validate_telegram_id(user_id)
|
||||||
|
if not user_id_validation:
|
||||||
|
logger.warning(f"⚠️ Невалидный user_id в callback: {user_id}")
|
||||||
|
await callback.answer("❌ Неверный ID пользователя", show_alert=True)
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"⚠️ Неверный формат user_id в callback: {user_id_str}")
|
||||||
|
await callback.answer("❌ Неверный формат ID", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not database:
|
||||||
|
from dependencies import get_database
|
||||||
|
database = get_database()
|
||||||
|
|
||||||
|
# Получаем пользователя
|
||||||
|
user = await database.get_user(user_id)
|
||||||
|
if not user:
|
||||||
|
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Назначаем суперпользователем
|
||||||
|
user.is_superuser = True
|
||||||
|
await database.update_user(user)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✅ <b>Права назначены!</b>\n\n"
|
||||||
|
f"👤 Пользователь {user.display_name} теперь является суперпользователем.\n\n"
|
||||||
|
f"🔍 Суперпользователи могут видеть информацию об авторах вопросов.",
|
||||||
|
reply_markup=get_superuser_confirm_keyboard(user_id, True),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer("✅ Права суперпользователя назначены!")
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await callback.answer("❌ Неверный ID пользователя", show_alert=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при назначении суперпользователя: {e}")
|
||||||
|
await callback.answer("❌ Ошибка при назначении прав", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("remove_superuser_"))
|
||||||
|
@require_permission("manage_users", "❌ У вас нет прав для управления пользователями.")
|
||||||
|
async def remove_superuser_callback(callback: CallbackQuery, database: DatabaseService = None, validator = None):
|
||||||
|
"""Обработчик снятия прав суперпользователя"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Извлекаем ID пользователя
|
||||||
|
user_id_str = callback.data.split("_")[-1]
|
||||||
|
|
||||||
|
# Валидируем callback data
|
||||||
|
if validator:
|
||||||
|
validation_result = validator.validate_callback_data(callback.data)
|
||||||
|
if not validation_result:
|
||||||
|
logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}")
|
||||||
|
await callback.answer("❌ Неверные данные", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Валидируем user_id
|
||||||
|
try:
|
||||||
|
user_id = int(user_id_str)
|
||||||
|
if validator:
|
||||||
|
user_id_validation = validator.validate_telegram_id(user_id)
|
||||||
|
if not user_id_validation:
|
||||||
|
logger.warning(f"⚠️ Невалидный user_id в callback: {user_id}")
|
||||||
|
await callback.answer("❌ Неверный ID пользователя", show_alert=True)
|
||||||
|
return
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"⚠️ Неверный формат user_id в callback: {user_id_str}")
|
||||||
|
await callback.answer("❌ Неверный формат ID", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not database:
|
||||||
|
from dependencies import get_database
|
||||||
|
database = get_database()
|
||||||
|
|
||||||
|
# Получаем пользователя
|
||||||
|
user = await database.get_user(user_id)
|
||||||
|
if not user:
|
||||||
|
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Снимаем права суперпользователя
|
||||||
|
user.is_superuser = False
|
||||||
|
await database.update_user(user)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"❌ <b>Права сняты!</b>\n\n"
|
||||||
|
f"👤 Пользователь {user.display_name} больше не является суперпользователем.\n\n"
|
||||||
|
f"👤 Теперь он видит анонимные вопросы без информации об авторах.",
|
||||||
|
reply_markup=get_superuser_confirm_keyboard(user_id, False),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer("❌ Права суперпользователя сняты!")
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
await callback.answer("❌ Неверный ID пользователя", show_alert=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при снятии прав суперпользователя: {e}")
|
||||||
|
await callback.answer("❌ Ошибка при снятии прав", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def show_superuser_assignment_page(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
users: list,
|
||||||
|
page: int = MIN_PAGE_NUMBER,
|
||||||
|
per_page: int = DEFAULT_PAGE_SIZE,
|
||||||
|
pagination_service: PaginationService = None,
|
||||||
|
message_service: MessageService = None
|
||||||
|
):
|
||||||
|
"""Показать страницу с пользователями для назначения суперпользователей"""
|
||||||
|
try:
|
||||||
|
# Используем сервисы если они переданы, иначе создаем временные
|
||||||
|
if not pagination_service:
|
||||||
|
from dependencies import get_pagination_service
|
||||||
|
pagination_service = get_pagination_service()
|
||||||
|
|
||||||
|
if not message_service:
|
||||||
|
from dependencies import get_message_service
|
||||||
|
message_service = get_message_service()
|
||||||
|
|
||||||
|
# Формируем текст сообщения
|
||||||
|
users_text = await _format_users_list(users, page, per_page, pagination_service)
|
||||||
|
|
||||||
|
# Создаем клавиатуру
|
||||||
|
keyboard = get_superuser_assignment_keyboard(users, page, per_page)
|
||||||
|
|
||||||
|
# Отправляем или редактируем сообщение
|
||||||
|
await message_service.edit_message(callback, users_text, keyboard)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отображении страницы пользователей: {e}")
|
||||||
|
await message_service.edit_message(
|
||||||
|
callback,
|
||||||
|
"❌ Произошла ошибка при отображении списка пользователей.",
|
||||||
|
get_admin_keyboard()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Rate Limiting callback обработчики
|
||||||
|
# ===========================================
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "admin_rate_limit")
|
||||||
|
async def admin_rate_limit_menu(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
message_service: MessageService,
|
||||||
|
auth: AuthService
|
||||||
|
):
|
||||||
|
"""Показать меню rate limiting"""
|
||||||
|
try:
|
||||||
|
if not auth.is_admin(callback.from_user.id):
|
||||||
|
await callback.answer("❌ Доступ запрещен. Только для администраторов.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = "🚦 <b>Управление Rate Limiting</b>\n\n"
|
||||||
|
text += "Выберите действие для управления системой ограничения скорости отправки сообщений:"
|
||||||
|
|
||||||
|
await message_service.edit_message(callback, text, get_rate_limit_keyboard())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отображении меню rate limiting: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка при отображении меню.", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "rate_limit_stats")
|
||||||
|
@inject_admin_services
|
||||||
|
async def rate_limit_stats_callback(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
rate_limit_service: RateLimitService,
|
||||||
|
message_service: MessageService,
|
||||||
|
auth: AuthService,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""Показать статистику rate limiting"""
|
||||||
|
try:
|
||||||
|
if not auth.is_admin(callback.from_user.id):
|
||||||
|
await callback.answer("❌ Доступ запрещен. Только для администраторов.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = rate_limit_service.get_stats()
|
||||||
|
|
||||||
|
stats_text = "📊 <b>Статистика Rate Limiting</b>\n\n"
|
||||||
|
stats_text += "🔢 <b>Общая статистика:</b>\n"
|
||||||
|
stats_text += f"• Всего запросов: {stats.get('total_requests', 0)}\n"
|
||||||
|
stats_text += f"• Успешных запросов: {stats.get('successful_requests', 0)}\n"
|
||||||
|
stats_text += f"• Неудачных запросов: {stats.get('failed_requests', 0)}\n"
|
||||||
|
stats_text += f"• Процент успеха: {stats.get('success_rate', 0.0):.{PERCENTAGE_DECIMAL_PLACES}%}\n"
|
||||||
|
stats_text += f"• Процент ошибок: {stats.get('error_rate', 0.0):.{PERCENTAGE_DECIMAL_PLACES}%}\n"
|
||||||
|
stats_text += f"• Среднее время ожидания: {stats.get('average_wait_time', 0.0):.{TIME_DECIMAL_PLACES}f}с\n\n"
|
||||||
|
|
||||||
|
stats_text += "🔍 <b>Детальная статистика:</b>\n"
|
||||||
|
stats_text += f"• RetryAfter ошибок: {stats.get('retry_after_errors', 0)}\n"
|
||||||
|
stats_text += f"• Других ошибок: {stats.get('other_errors', 0)}\n"
|
||||||
|
stats_text += f"• Процент RetryAfter: {stats.get('retry_after_rate', 0.0):.{PERCENTAGE_DECIMAL_PLACES}%}\n"
|
||||||
|
stats_text += f"• Процент других ошибок: {stats.get('other_error_rate', 0.0):.{PERCENTAGE_DECIMAL_PLACES}%}\n"
|
||||||
|
|
||||||
|
await message_service.edit_message(callback, stats_text, get_rate_limit_keyboard())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении статистики rate limiting: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка при получении статистики.", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "rate_limit_reset_stats")
|
||||||
|
@inject_admin_services
|
||||||
|
async def reset_rate_limit_stats_callback(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
rate_limit_service: RateLimitService,
|
||||||
|
message_service: MessageService,
|
||||||
|
auth: AuthService,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""Сбросить статистику rate limiting"""
|
||||||
|
try:
|
||||||
|
if not auth.is_admin(callback.from_user.id):
|
||||||
|
await callback.answer("❌ Доступ запрещен. Только для администраторов.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
rate_limit_service.reset_stats()
|
||||||
|
await callback.answer("✅ Статистика rate limiting сброшена.", show_alert=True)
|
||||||
|
|
||||||
|
# Обновляем сообщение
|
||||||
|
text = "🚦 <b>Управление Rate Limiting</b>\n\n"
|
||||||
|
text += "Выберите действие для управления системой ограничения скорости отправки сообщений:"
|
||||||
|
await message_service.edit_message(callback, text, get_rate_limit_keyboard())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при сбросе статистики rate limiting: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка при сбросе статистики.", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "rate_limit_adapt")
|
||||||
|
@inject_admin_services
|
||||||
|
async def adapt_rate_limit_config_callback(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
rate_limit_service: RateLimitService,
|
||||||
|
message_service: MessageService,
|
||||||
|
auth: AuthService,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""Адаптировать конфигурацию rate limiting"""
|
||||||
|
try:
|
||||||
|
if not auth.is_admin(callback.from_user.id):
|
||||||
|
await callback.answer("❌ Доступ запрещен. Только для администраторов.", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if rate_limit_service.should_adapt_config():
|
||||||
|
await rate_limit_service.adapt_config_if_needed()
|
||||||
|
await callback.answer("✅ Конфигурация rate limiting адаптирована на основе текущей производительности.", show_alert=True)
|
||||||
|
else:
|
||||||
|
await callback.answer("ℹ️ Адаптация конфигурации не требуется. Недостаточно данных или производительность в норме.", show_alert=True)
|
||||||
|
|
||||||
|
# Обновляем сообщение
|
||||||
|
text = "🚦 <b>Управление Rate Limiting</b>\n\n"
|
||||||
|
text += "Выберите действие для управления системой ограничения скорости отправки сообщений:"
|
||||||
|
await message_service.edit_message(callback, text, get_rate_limit_keyboard())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при адаптации конфигурации rate limiting: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка при адаптации конфигурации.", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
478
handlers/answers.py
Normal file
478
handlers/answers.py
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
"""
|
||||||
|
Обработчики для работы с ответами на вопросы
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
from aiogram.filters import StateFilter
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from models.question import Question, QuestionStatus
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.auth.auth_new import AuthService
|
||||||
|
from services.utils import is_valid_answer_text, format_question_info, send_answer_to_author, escape_html
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.infrastructure.metrics import get_metrics_service, track_answer_processing
|
||||||
|
from dependencies import inject_answer_services, inject_main_menu_services
|
||||||
|
from keyboards.inline import get_question_view_keyboard
|
||||||
|
from keyboards.reply import get_main_keyboard_for_user, get_cancel_keyboard
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
class AnswerStates(StatesGroup):
|
||||||
|
"""Состояния для работы с ответами"""
|
||||||
|
waiting_for_answer = State()
|
||||||
|
editing_answer = State()
|
||||||
|
confirming_delete = State()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("view_question_"))
|
||||||
|
async def view_question_callback(callback: CallbackQuery):
|
||||||
|
"""Обработчик просмотра конкретного вопроса"""
|
||||||
|
try:
|
||||||
|
question_id = int(callback.data.split("_")[2])
|
||||||
|
|
||||||
|
from dependencies import get_database
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Получаем вопрос
|
||||||
|
question = await db.get_question(question_id)
|
||||||
|
|
||||||
|
if not question:
|
||||||
|
await callback.answer("❌ Вопрос не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if question.to_user_id != callback.from_user.id:
|
||||||
|
await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Формируем текст сообщения
|
||||||
|
question_text = format_question_info(question, show_answer=True)
|
||||||
|
|
||||||
|
# Получаем клавиатуру
|
||||||
|
keyboard = get_question_view_keyboard(question)
|
||||||
|
|
||||||
|
# Обновляем сообщение
|
||||||
|
await callback.message.edit_text(
|
||||||
|
question_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при просмотре вопроса: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("answer_"))
|
||||||
|
async def answer_callback(callback: CallbackQuery, state: FSMContext):
|
||||||
|
"""Обработчик создания нового ответа"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Получен callback для ответа: {callback.data}")
|
||||||
|
question_id = int(callback.data.split("_")[1])
|
||||||
|
logger.info(f"Извлечен question_id: {question_id}")
|
||||||
|
|
||||||
|
# Получаем базу данных
|
||||||
|
from dependencies import get_database
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Получаем вопрос
|
||||||
|
question = await db.get_question(question_id)
|
||||||
|
if not question:
|
||||||
|
await callback.answer("❌ Вопрос не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем права доступа
|
||||||
|
if question.to_user_id != callback.from_user.id:
|
||||||
|
await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем, что вопрос еще не отвечен
|
||||||
|
if question.status == QuestionStatus.ANSWERED:
|
||||||
|
await callback.answer("❌ На этот вопрос уже отвечено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Устанавливаем состояние ожидания ответа
|
||||||
|
logger.info(f"Устанавливаем состояние waiting_for_answer для вопроса {question_id}")
|
||||||
|
await state.set_state(AnswerStates.waiting_for_answer)
|
||||||
|
await state.update_data(question_id=question_id)
|
||||||
|
logger.info(f"Состояние установлено, данные сохранены")
|
||||||
|
|
||||||
|
# Отправляем сообщение с просьбой ввести ответ
|
||||||
|
logger.info(f"Отправляем сообщение с просьбой ввести ответ для вопроса {question_id}")
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✏️ <b>Ответ на вопрос #{question_id}</b>\n\n"
|
||||||
|
f"❓ <b>Вопрос:</b>\n{escape_html(question.message_text)}\n\n"
|
||||||
|
f"💬 <b>Введите ваш ответ:</b>",
|
||||||
|
reply_markup=get_cancel_keyboard(),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
logger.info(f"Callback обработан успешно для вопроса {question_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании ответа: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("edit_answer_"))
|
||||||
|
async def edit_answer_callback(callback: CallbackQuery, state: FSMContext):
|
||||||
|
"""Обработчик редактирования ответа"""
|
||||||
|
try:
|
||||||
|
question_id = int(callback.data.split("_")[2])
|
||||||
|
|
||||||
|
from dependencies import get_database
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Получаем вопрос
|
||||||
|
question = await db.get_question(question_id)
|
||||||
|
|
||||||
|
if not question:
|
||||||
|
await callback.answer("❌ Вопрос не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if question.to_user_id != callback.from_user.id:
|
||||||
|
await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if question.status != QuestionStatus.ANSWERED:
|
||||||
|
await callback.answer("❌ На этот вопрос еще не отвечено", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Устанавливаем состояние редактирования ответа
|
||||||
|
await state.set_state(AnswerStates.editing_answer)
|
||||||
|
await state.update_data(question_id=question_id, current_answer=question.answer_text)
|
||||||
|
|
||||||
|
# Отправляем сообщение с просьбой ввести новый ответ
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"✏️ <b>Редактирование ответа на вопрос #{question_id}</b>\n\n"
|
||||||
|
f"📝 <b>Вопрос:</b>\n{escape_html(question.message_text)}\n\n"
|
||||||
|
f"💬 <b>Текущий ответ:</b>\n{escape_html(question.answer_text)}\n\n"
|
||||||
|
f"✍️ <b>Введите новый ответ:</b>",
|
||||||
|
reply_markup=None,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при редактировании ответа: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(StateFilter(AnswerStates.waiting_for_answer))
|
||||||
|
@inject_answer_services
|
||||||
|
async def process_new_answer(message: Message, state: FSMContext, validator, **kwargs):
|
||||||
|
"""Обработка нового ответа"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Получено сообщение в состоянии waiting_for_answer: {message.text[:50]}...")
|
||||||
|
# Получаем данные из состояния
|
||||||
|
data = await state.get_data()
|
||||||
|
question_id = data.get('question_id')
|
||||||
|
logger.info(f"Получен question_id из состояния: {question_id}")
|
||||||
|
|
||||||
|
if not question_id:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Ошибка: не найден вопрос для ответа.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Валидируем текст ответа
|
||||||
|
validation_result = validator.validate_answer_text(
|
||||||
|
message.text,
|
||||||
|
config.MAX_ANSWER_LENGTH
|
||||||
|
)
|
||||||
|
|
||||||
|
if not validation_result:
|
||||||
|
logger.warning(f"⚠️ Невалидный ответ от пользователя {message.from_user.id}: {validation_result.error_message}")
|
||||||
|
await message.answer(
|
||||||
|
f"❌ {validation_result.error_message}\n\n"
|
||||||
|
"Попробуйте отправить ответ еще раз:",
|
||||||
|
reply_markup=get_cancel_keyboard()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Используем санитизированный текст
|
||||||
|
sanitized_answer_text = validation_result.sanitized_value
|
||||||
|
|
||||||
|
# Сохраняем ответ в БД
|
||||||
|
from dependencies import get_database
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
question = await db.get_question(question_id)
|
||||||
|
if question:
|
||||||
|
question.answer_text = sanitized_answer_text
|
||||||
|
question.answered_at = datetime.now()
|
||||||
|
question.mark_as_answered() # Устанавливаем статус ANSWERED
|
||||||
|
await db.update_question(question)
|
||||||
|
|
||||||
|
# Отправляем ответ автору вопроса
|
||||||
|
logger.info(f"Попытка отправить ответ автору вопроса {question.id}, from_user_id: {question.from_user_id}")
|
||||||
|
await send_answer_to_author(message.bot, question, question.answer_text)
|
||||||
|
|
||||||
|
# Отправляем подтверждение
|
||||||
|
await message.answer(
|
||||||
|
"✅ <b>Ответ отправлен!</b>\n\n"
|
||||||
|
"💬 Ваш ответ был успешно отправлен автору вопроса.\n\n"
|
||||||
|
"📋 Используйте 'Мои вопросы' для просмотра всех вопросов и ответов.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при обработке нового ответа: {e}")
|
||||||
|
await message.answer(
|
||||||
|
"❌ Произошла ошибка при отправке ответа. Попробуйте позже.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(StateFilter(AnswerStates.editing_answer))
|
||||||
|
@inject_answer_services
|
||||||
|
async def process_edited_answer(message: Message, state: FSMContext, validator, **kwargs):
|
||||||
|
"""Обработка отредактированного ответа"""
|
||||||
|
try:
|
||||||
|
# Получаем данные из состояния
|
||||||
|
data = await state.get_data()
|
||||||
|
question_id = data.get('question_id')
|
||||||
|
current_answer = data.get('current_answer')
|
||||||
|
|
||||||
|
if not question_id:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Ошибка: не найден вопрос для редактирования.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Валидируем текст ответа
|
||||||
|
validation_result = validator.validate_answer_text(
|
||||||
|
message.text,
|
||||||
|
config.MAX_ANSWER_LENGTH
|
||||||
|
)
|
||||||
|
|
||||||
|
if not validation_result:
|
||||||
|
logger.warning(f"⚠️ Невалидный отредактированный ответ от пользователя {message.from_user.id}: {validation_result.error_message}")
|
||||||
|
await message.answer(
|
||||||
|
f"❌ {validation_result.error_message}\n\n"
|
||||||
|
"Попробуйте отправить ответ еще раз:",
|
||||||
|
reply_markup=get_cancel_keyboard()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Используем санитизированный текст
|
||||||
|
sanitized_answer_text = validation_result.sanitized_value
|
||||||
|
|
||||||
|
# Сохраняем отредактированный ответ
|
||||||
|
from dependencies import get_database
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
question = await db.get_question(question_id)
|
||||||
|
if question:
|
||||||
|
question.answer_text = sanitized_answer_text
|
||||||
|
question.answered_at = datetime.now() # Обновляем время ответа
|
||||||
|
await db.update_question(question)
|
||||||
|
|
||||||
|
# Отправляем ответ автору вопроса
|
||||||
|
logger.info(f"Попытка отправить ответ автору вопроса {question.id}, from_user_id: {question.from_user_id}")
|
||||||
|
await send_answer_to_author(message.bot, question, question.answer_text)
|
||||||
|
|
||||||
|
# Отправляем подтверждение
|
||||||
|
await message.answer(
|
||||||
|
"✅ <b>Ответ обновлен!</b>\n\n"
|
||||||
|
"💬 Ваш ответ был успешно обновлен.\n\n"
|
||||||
|
"📋 Используйте 'Мои вопросы' для просмотра всех вопросов и ответов.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при обработке отредактированного ответа: {e}")
|
||||||
|
await message.answer(
|
||||||
|
"❌ Произошла ошибка при обновлении ответа. Попробуйте позже.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("confirm_delete_"))
|
||||||
|
async def confirm_delete_callback(callback: CallbackQuery):
|
||||||
|
"""Обработчик подтверждения удаления вопроса"""
|
||||||
|
try:
|
||||||
|
question_id = int(callback.data.split("_")[2])
|
||||||
|
|
||||||
|
from dependencies import get_database
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Получаем вопрос
|
||||||
|
question = await db.get_question(question_id)
|
||||||
|
|
||||||
|
if not question:
|
||||||
|
await callback.answer("❌ Вопрос не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if question.to_user_id != callback.from_user.id:
|
||||||
|
await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Удаляем вопрос
|
||||||
|
question.mark_as_deleted()
|
||||||
|
await db.update_question(question)
|
||||||
|
|
||||||
|
# Обновляем сообщение
|
||||||
|
await callback.message.edit_text(
|
||||||
|
f"🗑️ <b>Вопрос #{question.user_question_number if question.user_question_number is not None else question_id} удален</b>\n\n"
|
||||||
|
f"📅 Удален: {question.answered_at.strftime('%d.%m.%Y %H:%M')}",
|
||||||
|
reply_markup=None,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer("🗑️ Вопрос удален")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при подтверждении удаления: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("cancel_delete_"))
|
||||||
|
async def cancel_delete_callback(callback: CallbackQuery):
|
||||||
|
"""Обработчик отмены удаления вопроса"""
|
||||||
|
try:
|
||||||
|
question_id = int(callback.data.split("_")[2])
|
||||||
|
|
||||||
|
from dependencies import get_database
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Получаем вопрос
|
||||||
|
question = await db.get_question(question_id)
|
||||||
|
|
||||||
|
if not question:
|
||||||
|
await callback.answer("❌ Вопрос не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if question.to_user_id != callback.from_user.id:
|
||||||
|
await callback.answer("❌ У вас нет прав на этот вопрос", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Возвращаемся к просмотру вопроса
|
||||||
|
question_text = format_question_info(question, show_answer=True)
|
||||||
|
keyboard = get_question_view_keyboard(question)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
question_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer("❌ Удаление отменено")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отмене удаления: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "back_to_questions")
|
||||||
|
async def back_to_questions_callback(callback: CallbackQuery):
|
||||||
|
"""Обработчик кнопки 'Назад к списку'"""
|
||||||
|
try:
|
||||||
|
from dependencies import get_database
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Получаем вопросы пользователя
|
||||||
|
questions = await db.get_user_questions(callback.from_user.id, limit=10)
|
||||||
|
|
||||||
|
if not questions:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
"📭 У вас пока нет вопросов.\n\n"
|
||||||
|
"🔗 Поделитесь своей ссылкой, чтобы получать анонимные вопросы!",
|
||||||
|
reply_markup=None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
questions_text = f"📋 <b>Ваши вопросы ({len(questions)}):</b>\n\n"
|
||||||
|
|
||||||
|
for i, question in enumerate(questions, 1):
|
||||||
|
status_emoji = {
|
||||||
|
'pending': '⏳',
|
||||||
|
'answered': '✅',
|
||||||
|
'rejected': '❌',
|
||||||
|
'deleted': '🗑️'
|
||||||
|
}
|
||||||
|
|
||||||
|
emoji = status_emoji.get(question.status.value, '❓')
|
||||||
|
preview = question.get_question_preview(50)
|
||||||
|
|
||||||
|
# Используем user_question_number для отображения, если он есть
|
||||||
|
display_number = question.user_question_number if question.user_question_number is not None else i
|
||||||
|
questions_text += f"{i}. {emoji} <b>#{display_number}</b>\n"
|
||||||
|
questions_text += f" {preview}\n"
|
||||||
|
questions_text += f" 📅 {question.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||||
|
|
||||||
|
questions_text += "💡 Нажмите на номер вопроса для просмотра деталей."
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
questions_text,
|
||||||
|
reply_markup=None,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при возврате к списку вопросов: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(
|
||||||
|
F.data == "back_to_main",
|
||||||
|
)
|
||||||
|
@inject_main_menu_services
|
||||||
|
async def back_to_main_callback(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
auth: AuthService,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""Обработчик кнопки 'Назад' в главное меню"""
|
||||||
|
try:
|
||||||
|
# Используем инжектированную систему авторизации
|
||||||
|
is_admin = auth.is_admin(callback.from_user.id)
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
from keyboards.inline import get_admin_keyboard
|
||||||
|
keyboard = get_admin_keyboard()
|
||||||
|
text = "🏠 <b>Главное меню (Админ)</b>\n\nВыберите действие:"
|
||||||
|
else:
|
||||||
|
keyboard = get_main_keyboard_for_user(callback.from_user.id)
|
||||||
|
text = "🏠 <b>Главное меню</b>\n\nВыберите действие:"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text,
|
||||||
|
reply_markup=None,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Отправляем новое сообщение с клавиатурой
|
||||||
|
await callback.message.answer(
|
||||||
|
text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при возврате в главное меню: {e}")
|
||||||
|
await callback.answer("❌ Произошла ошибка", show_alert=True)
|
||||||
217
handlers/errors.py
Normal file
217
handlers/errors.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"""
|
||||||
|
Глобальная обработка ошибок
|
||||||
|
"""
|
||||||
|
import traceback
|
||||||
|
from aiogram import Router
|
||||||
|
from aiogram.types import ErrorEvent, Message, CallbackQuery
|
||||||
|
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError, TelegramRetryAfter
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.infrastructure.metrics import get_metrics_service
|
||||||
|
from keyboards.reply import get_main_keyboard_for_user
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.error()
|
||||||
|
async def error_handler(event: ErrorEvent):
|
||||||
|
"""Глобальный обработчик ошибок"""
|
||||||
|
error = event.exception
|
||||||
|
update = event.update
|
||||||
|
|
||||||
|
# Записываем метрику ошибки
|
||||||
|
metrics_service = get_metrics_service()
|
||||||
|
metrics_service.increment_errors(type(error).__name__, "global_handler")
|
||||||
|
|
||||||
|
# Логируем ошибку
|
||||||
|
logger.error(f"💥 Ошибка в обработчике: {error}")
|
||||||
|
logger.error(f"🔍 Тип ошибки: {type(error).__name__}")
|
||||||
|
logger.error(f"📋 Детали: {traceback.format_exc()}")
|
||||||
|
|
||||||
|
# Определяем тип обновления
|
||||||
|
if update.message:
|
||||||
|
await handle_message_error(update.message, error)
|
||||||
|
elif update.callback_query:
|
||||||
|
await handle_callback_error(update.callback_query, error)
|
||||||
|
else:
|
||||||
|
logger.error(f"❓ Неизвестный тип обновления: {update}")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_message_error(message: Message, error: Exception):
|
||||||
|
"""Обработка ошибок в сообщениях"""
|
||||||
|
try:
|
||||||
|
# Определяем тип ошибки и отправляем соответствующее сообщение
|
||||||
|
if isinstance(error, TelegramRetryAfter):
|
||||||
|
# Ошибка rate limiting
|
||||||
|
await message.answer(
|
||||||
|
f"⏳ Слишком много запросов. Попробуйте через {error.retry_after} секунд.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
elif isinstance(error, TelegramBadRequest):
|
||||||
|
# Некорректный запрос
|
||||||
|
if "message is not modified" in str(error):
|
||||||
|
# Сообщение не изменилось - это не критическая ошибка
|
||||||
|
logger.warning("Сообщение не изменилось")
|
||||||
|
elif "chat not found" in str(error):
|
||||||
|
# Чат не найден
|
||||||
|
logger.warning(f"Чат не найден: {message.chat.id}")
|
||||||
|
else:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Произошла ошибка при обработке запроса. Попробуйте позже.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
elif isinstance(error, TelegramNetworkError):
|
||||||
|
# Сетевая ошибка
|
||||||
|
await message.answer(
|
||||||
|
"🌐 Проблемы с сетью. Проверьте подключение к интернету.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
elif isinstance(error, ValueError):
|
||||||
|
# Ошибка валидации
|
||||||
|
await message.answer(
|
||||||
|
"❌ Некорректные данные. Проверьте введенную информацию.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
elif isinstance(error, KeyError):
|
||||||
|
# Ошибка ключа (обычно в FSM)
|
||||||
|
await message.answer(
|
||||||
|
"❌ Ошибка состояния. Попробуйте начать заново с команды /start.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Неизвестная ошибка
|
||||||
|
if config.DEBUG:
|
||||||
|
# В режиме отладки показываем детали ошибки
|
||||||
|
error_text = f"🐛 <b>Ошибка отладки:</b>\n\n"
|
||||||
|
error_text += f"<code>{type(error).__name__}: {str(error)}</code>"
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
error_text,
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# В продакшене показываем общее сообщение
|
||||||
|
await message.answer(
|
||||||
|
"❌ Произошла неожиданная ошибка. Попробуйте позже или обратитесь к администратору.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Уведомляем администраторов о критических ошибках
|
||||||
|
if not isinstance(error, (TelegramRetryAfter, TelegramBadRequest)):
|
||||||
|
await notify_admins_about_error(error, message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Если даже обработка ошибки не удалась
|
||||||
|
logger.critical(f"Критическая ошибка в обработчике ошибок: {e}")
|
||||||
|
try:
|
||||||
|
await message.answer(
|
||||||
|
"❌ Критическая ошибка. Бот временно недоступен.",
|
||||||
|
reply_markup=get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass # Если даже это не удалось, ничего не делаем
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_callback_error(callback: CallbackQuery, error: Exception):
|
||||||
|
"""Обработка ошибок в callback запросах"""
|
||||||
|
try:
|
||||||
|
# Определяем тип ошибки и отправляем соответствующее сообщение
|
||||||
|
if isinstance(error, TelegramRetryAfter):
|
||||||
|
# Ошибка rate limiting
|
||||||
|
await callback.answer(
|
||||||
|
f"⏳ Слишком много запросов. Попробуйте через {error.retry_after} секунд.",
|
||||||
|
show_alert=True
|
||||||
|
)
|
||||||
|
elif isinstance(error, TelegramBadRequest):
|
||||||
|
# Некорректный запрос
|
||||||
|
if "message is not modified" in str(error):
|
||||||
|
# Сообщение не изменилось - это не критическая ошибка
|
||||||
|
logger.warning("Сообщение не изменилось в callback")
|
||||||
|
elif "query is too old" in str(error):
|
||||||
|
# Запрос слишком старый
|
||||||
|
await callback.answer(
|
||||||
|
"⏰ Запрос устарел. Обновите страницу и попробуйте снова.",
|
||||||
|
show_alert=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await callback.answer(
|
||||||
|
"❌ Произошла ошибка при обработке запроса.",
|
||||||
|
show_alert=True
|
||||||
|
)
|
||||||
|
elif isinstance(error, TelegramNetworkError):
|
||||||
|
# Сетевая ошибка
|
||||||
|
await callback.answer(
|
||||||
|
"🌐 Проблемы с сетью. Проверьте подключение.",
|
||||||
|
show_alert=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Неизвестная ошибка
|
||||||
|
if config.DEBUG:
|
||||||
|
# В режиме отладки показываем детали ошибки
|
||||||
|
error_text = f"🐛 <b>Ошибка отладки:</b>\n\n"
|
||||||
|
error_text += f"<code>{type(error).__name__}: {str(error)}</code>"
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
error_text,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# В продакшене показываем общее сообщение
|
||||||
|
await callback.answer(
|
||||||
|
"❌ Произошла неожиданная ошибка.",
|
||||||
|
show_alert=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Уведомляем администраторов о критических ошибках
|
||||||
|
if not isinstance(error, (TelegramRetryAfter, TelegramBadRequest)):
|
||||||
|
await notify_admins_about_error(error, callback.message)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Если даже обработка ошибки не удалась
|
||||||
|
logger.critical(f"Критическая ошибка в обработчике callback ошибок: {e}")
|
||||||
|
try:
|
||||||
|
await callback.answer(
|
||||||
|
"❌ Критическая ошибка.",
|
||||||
|
show_alert=True
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass # Если даже это не удалось, ничего не делаем
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_admins_about_error(error: Exception, message: Message):
|
||||||
|
"""Уведомление администраторов об ошибке"""
|
||||||
|
if not config.ADMINS:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
error_text = f"🚨 <b>Ошибка в боте</b>\n\n"
|
||||||
|
error_text += f"🐛 <b>Тип:</b> {type(error).__name__}\n"
|
||||||
|
error_text += f"📝 <b>Сообщение:</b> {str(error)}\n"
|
||||||
|
error_text += f"👤 <b>Пользователь:</b> {message.from_user.id}\n"
|
||||||
|
error_text += f"💬 <b>Чат:</b> {message.chat.id}\n"
|
||||||
|
error_text += f"📅 <b>Время:</b> {message.date.strftime('%d.%m.%Y %H:%M:%S')}\n\n"
|
||||||
|
|
||||||
|
if config.DEBUG:
|
||||||
|
error_text += f"🔍 <b>Трассировка:</b>\n<code>{traceback.format_exc()}</code>"
|
||||||
|
|
||||||
|
# Отправляем уведомление всем администраторам
|
||||||
|
from dependencies import get_message_service
|
||||||
|
message_service = get_message_service()
|
||||||
|
|
||||||
|
for admin_id in config.ADMINS:
|
||||||
|
try:
|
||||||
|
await message_service.send_bot_message(
|
||||||
|
message.bot,
|
||||||
|
admin_id,
|
||||||
|
error_text
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось отправить уведомление админу {admin_id}: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при уведомлении администраторов: {e}")
|
||||||
|
|
||||||
|
|
||||||
1272
handlers/questions.py
Normal file
1272
handlers/questions.py
Normal file
File diff suppressed because it is too large
Load Diff
328
handlers/start.py
Normal file
328
handlers/start.py
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
"""
|
||||||
|
Обработчики команд /start и /help
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from aiogram import Router, F
|
||||||
|
from aiogram.types import Message
|
||||||
|
from aiogram.filters import Command, CommandStart
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from models.user import User
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.auth.auth_new import AuthService
|
||||||
|
from services.utils import UtilsService
|
||||||
|
from services.business.user_service import UserService
|
||||||
|
from services.business.message_service import MessageService
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.infrastructure.metrics import get_metrics_service, track_message_processing
|
||||||
|
from keyboards.reply import get_main_keyboard_for_user, get_admin_reply_keyboard
|
||||||
|
from keyboards.inline import get_admin_keyboard
|
||||||
|
from dependencies import inject_start_services, inject_link_services, inject_main_menu_services
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_welcome_message(user: User, referral_link: str) -> str:
|
||||||
|
"""Создание приветственного сообщения"""
|
||||||
|
welcome_text = f"👋 <b>Добро пожаловать, {user.display_name}!</b>\n\n"
|
||||||
|
welcome_text += "🤖 Я бот для анонимных вопросов.\n\n"
|
||||||
|
welcome_text += "📝 <b>Как это работает:</b>\n"
|
||||||
|
welcome_text += "• Поделитесь своей ссылкой с друзьями\n"
|
||||||
|
welcome_text += "• Они смогут задать вам анонимные вопросы\n"
|
||||||
|
welcome_text += "• Вы получите уведомления и сможете ответить\n\n"
|
||||||
|
welcome_text += f"🔗 <b>Ваша персональная ссылка:</b>\n"
|
||||||
|
welcome_text += f"<code>{referral_link}</code>\n\n"
|
||||||
|
welcome_text += "💡 <b>Совет:</b> Скопируйте ссылку и поделитесь ею в социальных сетях!"
|
||||||
|
|
||||||
|
return welcome_text
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_start_command(
|
||||||
|
message: Message,
|
||||||
|
user_service: UserService,
|
||||||
|
auth: AuthService,
|
||||||
|
utils: UtilsService,
|
||||||
|
message_service: MessageService,
|
||||||
|
validator
|
||||||
|
) -> User:
|
||||||
|
"""Обработка команды /start без аргументов"""
|
||||||
|
# Валидируем Telegram ID пользователя
|
||||||
|
user_id_validation = validator.validate_telegram_id(message.from_user.id)
|
||||||
|
if not user_id_validation:
|
||||||
|
logger.error(f"❌ Невалидный Telegram ID: {message.from_user.id}")
|
||||||
|
await message_service.send_message(
|
||||||
|
message,
|
||||||
|
"❌ Ошибка: недопустимый ID пользователя.",
|
||||||
|
get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
raise ValueError(f"Invalid Telegram ID: {message.from_user.id}")
|
||||||
|
|
||||||
|
# Создаем или обновляем пользователя
|
||||||
|
user = await user_service.create_or_update_user(message.from_user, message.chat.id)
|
||||||
|
|
||||||
|
# Проверяем, является ли пользователь админом
|
||||||
|
is_admin = auth.is_admin(user.telegram_id)
|
||||||
|
|
||||||
|
# Генерируем персональную ссылку
|
||||||
|
bot_info = await message.bot.get_me()
|
||||||
|
referral_link = user_service.generate_referral_link(bot_info.username, user)
|
||||||
|
|
||||||
|
# Создаем приветственное сообщение
|
||||||
|
welcome_text = await _create_welcome_message(user, referral_link)
|
||||||
|
|
||||||
|
# Выбираем клавиатуру в зависимости от роли
|
||||||
|
if is_admin:
|
||||||
|
keyboard = get_admin_reply_keyboard()
|
||||||
|
else:
|
||||||
|
keyboard = get_main_keyboard_for_user(user.telegram_id)
|
||||||
|
logger.info(f"⌨️ Создана клавиатура для пользователя {user.telegram_id}: {type(keyboard).__name__}")
|
||||||
|
|
||||||
|
# Отправляем сообщение
|
||||||
|
await message_service.send_message(message, welcome_text, keyboard)
|
||||||
|
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
@track_message_processing("start_command")
|
||||||
|
@inject_start_services
|
||||||
|
async def cmd_start(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
user_service: UserService,
|
||||||
|
auth: AuthService,
|
||||||
|
utils: UtilsService,
|
||||||
|
message_service: MessageService,
|
||||||
|
validator,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""Обработчик команды /start"""
|
||||||
|
try:
|
||||||
|
logger.info(f"🚀 Команда /start от пользователя {message.from_user.id} ({message.from_user.first_name})")
|
||||||
|
|
||||||
|
# Сбрасываем состояние FSM при команде /start
|
||||||
|
await state.clear()
|
||||||
|
logger.info(f"🔄 Состояние FSM сброшено для пользователя {message.from_user.id}")
|
||||||
|
|
||||||
|
# Получаем аргументы команды
|
||||||
|
args = message.text.split()[1:] if len(message.text.split()) > 1 else []
|
||||||
|
|
||||||
|
# Обрабатываем deep linking если есть аргументы
|
||||||
|
if args:
|
||||||
|
logger.info(f"🔗 Обработка deep link: {args[0]}")
|
||||||
|
await handle_deep_link(message, args[0], user_service, state, message_service, validator)
|
||||||
|
else:
|
||||||
|
# Обрабатываем обычную команду /start
|
||||||
|
await _process_start_command(message, user_service, auth, utils, message_service, validator)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"💥 Ошибка в обработчике /start: {e}")
|
||||||
|
await message_service.send_error_message(
|
||||||
|
message,
|
||||||
|
"❌ Произошла ошибка при запуске бота. Попробуйте позже."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_deep_link(
|
||||||
|
message: Message,
|
||||||
|
ref_code: str,
|
||||||
|
user_service: UserService,
|
||||||
|
state: FSMContext,
|
||||||
|
message_service: MessageService,
|
||||||
|
validator
|
||||||
|
):
|
||||||
|
"""Обработка deep linking для анонимных вопросов"""
|
||||||
|
try:
|
||||||
|
# Валидируем deep link
|
||||||
|
validation_result = validator.validate_deep_link(ref_code)
|
||||||
|
if not validation_result:
|
||||||
|
logger.warning(f"⚠️ Невалидный deep link от пользователя {message.from_user.id}: {ref_code}")
|
||||||
|
await message_service.send_message(
|
||||||
|
message,
|
||||||
|
f"❌ {validation_result.error_message}",
|
||||||
|
get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Используем санитизированное значение
|
||||||
|
ref_code = validation_result.sanitized_value
|
||||||
|
|
||||||
|
if not ref_code.startswith('ref_'):
|
||||||
|
await message_service.send_message(
|
||||||
|
message,
|
||||||
|
"❌ Неверная ссылка.",
|
||||||
|
get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Извлекаем анонимный ID из реферального кода
|
||||||
|
anonymous_id = ref_code[4:] # Убираем 'ref_'
|
||||||
|
|
||||||
|
# Ищем пользователя по profile_link
|
||||||
|
target_user = await user_service.get_user_by_profile_link(anonymous_id)
|
||||||
|
if not target_user:
|
||||||
|
await message_service.send_message(
|
||||||
|
message,
|
||||||
|
"❌ Пользователь, на которого вы перешли, не найден.",
|
||||||
|
get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Отправляем сообщение о переходе по ссылке
|
||||||
|
deep_link_text = (
|
||||||
|
f"👋 Вы перешли по ссылке пользователя {target_user.display_name}!\n\n"
|
||||||
|
f"📝 Теперь вы можете задать анонимный вопрос.\n"
|
||||||
|
f"Просто отправьте ваше сообщение, и оно будет передано получателю."
|
||||||
|
)
|
||||||
|
|
||||||
|
await message_service.send_message(
|
||||||
|
message,
|
||||||
|
deep_link_text,
|
||||||
|
get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Устанавливаем состояние ожидания вопроса
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
class QuestionStates(StatesGroup):
|
||||||
|
waiting_for_question = State()
|
||||||
|
|
||||||
|
await state.set_state(QuestionStates.waiting_for_question)
|
||||||
|
await state.update_data(target_user_id=target_user.telegram_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при обработке deep link: {e}")
|
||||||
|
await message_service.send_error_message(
|
||||||
|
message,
|
||||||
|
"❌ Произошла ошибка при обработке ссылки."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("help"))
|
||||||
|
async def cmd_help(message: Message):
|
||||||
|
"""Обработчик команды /help"""
|
||||||
|
help_text = "📖 <b>Справка по боту</b>\n\n"
|
||||||
|
help_text += "🤖 <b>Основные команды:</b>\n"
|
||||||
|
help_text += "/start - Запуск бота и получение персональной ссылки\n"
|
||||||
|
help_text += "/help - Показать эту справку\n"
|
||||||
|
help_text += "/stats - Показать статистику (только для админов)\n\n"
|
||||||
|
help_text += "📝 <b>Как задать анонимный вопрос:</b>\n"
|
||||||
|
help_text += "1. Перейдите по персональной ссылке пользователя\n"
|
||||||
|
help_text += "2. Отправьте ваш вопрос боту\n"
|
||||||
|
help_text += "3. Вопрос будет передан получателю анонимно\n\n"
|
||||||
|
help_text += "💬 <b>Как ответить на вопрос:</b>\n"
|
||||||
|
help_text += "1. Получите уведомление о новом вопросе\n"
|
||||||
|
help_text += "2. Нажмите кнопку 'Ответить'\n"
|
||||||
|
help_text += "3. Введите ваш ответ\n"
|
||||||
|
help_text += "4. Ответ будет отправлен анонимно\n\n"
|
||||||
|
help_text += "🔗 <b>Ваша персональная ссылка:</b>\n"
|
||||||
|
help_text += "Используйте кнопку 'Моя ссылка' для получения ссылки\n\n"
|
||||||
|
help_text += "❓ <b>Нужна помощь?</b>\n"
|
||||||
|
help_text += "Обратитесь к администратору бота: @Kerrad1"
|
||||||
|
|
||||||
|
await message.answer(help_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "ℹ️ Помощь")
|
||||||
|
async def help_button(message: Message):
|
||||||
|
"""Обработчик кнопки 'Помощь'"""
|
||||||
|
await cmd_help(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "🔗 Моя ссылка")
|
||||||
|
@inject_link_services
|
||||||
|
async def my_link_button(
|
||||||
|
message: Message,
|
||||||
|
user_service: UserService,
|
||||||
|
message_service: MessageService,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""Обработчик кнопки 'Моя ссылка'"""
|
||||||
|
try:
|
||||||
|
# Получаем пользователя из БД
|
||||||
|
user = await user_service.get_user_by_telegram_id(message.from_user.id)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
await message_service.send_message(
|
||||||
|
message,
|
||||||
|
"❌ Пользователь не найден. Используйте /start для регистрации."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем информацию о боте
|
||||||
|
bot_info = await message.bot.get_me()
|
||||||
|
referral_link = user_service.generate_referral_link(bot_info.username, user)
|
||||||
|
|
||||||
|
link_text = "🔗 <b>Ваша персональная ссылка:</b>\n\n"
|
||||||
|
link_text += f"<code>{referral_link}</code>\n\n"
|
||||||
|
link_text += "📝 <b>Как использовать:</b>\n"
|
||||||
|
link_text += "• Скопируйте ссылку\n"
|
||||||
|
link_text += "• Поделитесь ею в социальных сетях\n"
|
||||||
|
link_text += "• Друзья смогут задать вам анонимные вопросы\n\n"
|
||||||
|
link_text += "💡 <b>Совет:</b> Добавьте ссылку в описание профиля или поделитесь в Stories!"
|
||||||
|
|
||||||
|
await message_service.send_message(message, link_text)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении ссылки: {e}")
|
||||||
|
await message_service.send_error_message(
|
||||||
|
message,
|
||||||
|
"❌ Произошла ошибка при получении ссылки. Попробуйте позже."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "⬅️ Главное меню")
|
||||||
|
@inject_main_menu_services
|
||||||
|
async def back_to_main(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
auth: AuthService,
|
||||||
|
message_service: MessageService,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""Обработчик кнопки 'Главное меню'"""
|
||||||
|
# Сбрасываем состояние FSM при возврате в главное меню
|
||||||
|
await state.clear()
|
||||||
|
logger.info(f"🔄 Состояние FSM сброшено для пользователя {message.from_user.id} (кнопка 'Главное меню')")
|
||||||
|
|
||||||
|
is_admin = auth.is_admin(message.from_user.id)
|
||||||
|
if is_admin:
|
||||||
|
keyboard = get_admin_reply_keyboard()
|
||||||
|
else:
|
||||||
|
keyboard = get_main_keyboard_for_user(message.from_user.id)
|
||||||
|
|
||||||
|
await message_service.send_message(
|
||||||
|
message,
|
||||||
|
"🏠 <b>Главное меню</b>\n\nВыберите действие:",
|
||||||
|
keyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.text == "⚙️ Админ панель")
|
||||||
|
@inject_main_menu_services
|
||||||
|
async def admin_panel_button(
|
||||||
|
message: Message,
|
||||||
|
auth: AuthService,
|
||||||
|
message_service: MessageService,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
"""Обработчик кнопки 'Админ панель'"""
|
||||||
|
# Проверяем, является ли пользователь админом
|
||||||
|
if not auth.is_admin(message.from_user.id):
|
||||||
|
await message_service.send_message(
|
||||||
|
message,
|
||||||
|
"❌ У вас нет прав для доступа к админ панели."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем inline клавиатуру для админов
|
||||||
|
admin_keyboard = get_admin_keyboard()
|
||||||
|
|
||||||
|
await message_service.send_message(
|
||||||
|
message,
|
||||||
|
"👑 <b>Админ панель</b>\n\nВыберите действие:",
|
||||||
|
admin_keyboard
|
||||||
|
)
|
||||||
17
keyboards/__init__.py
Normal file
17
keyboards/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
Клавиатуры для бота
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .inline import (
|
||||||
|
get_answer_keyboard,
|
||||||
|
get_admin_keyboard,
|
||||||
|
get_stats_keyboard
|
||||||
|
)
|
||||||
|
from .reply import get_main_keyboard
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'get_answer_keyboard',
|
||||||
|
'get_admin_keyboard',
|
||||||
|
'get_stats_keyboard',
|
||||||
|
'get_main_keyboard'
|
||||||
|
]
|
||||||
615
keyboards/inline.py
Normal file
615
keyboards/inline.py
Normal file
@@ -0,0 +1,615 @@
|
|||||||
|
"""
|
||||||
|
Inline клавиатуры для бота
|
||||||
|
"""
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||||
|
|
||||||
|
from models.question import Question
|
||||||
|
from typing import List
|
||||||
|
from services.utils import escape_html
|
||||||
|
|
||||||
|
|
||||||
|
def get_answer_keyboard(question_id: int, from_user_id: int = None) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для ответа на вопрос
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question_id: ID вопроса
|
||||||
|
from_user_id: ID отправителя вопроса (для блокировки)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="💬 Ответить",
|
||||||
|
callback_data=f"answer_{question_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Отклонить",
|
||||||
|
callback_data=f"reject_{question_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем кнопку блокировки, если есть ID отправителя
|
||||||
|
if from_user_id:
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🚫 Заблокировать",
|
||||||
|
callback_data=f"block_{from_user_id}_{question_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🗑️ Удалить",
|
||||||
|
callback_data=f"delete_{question_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(1) # По одной кнопке в ряд
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для администраторов
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📊 Статистика",
|
||||||
|
callback_data="admin_stats"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📢 Рассылка",
|
||||||
|
callback_data="admin_broadcast"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔍 Назначить суперпользователя",
|
||||||
|
callback_data="admin_assign_superuser"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🚫 Забанить пользователя",
|
||||||
|
callback_data="admin_ban_user"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🚦 Rate Limiting",
|
||||||
|
callback_data="admin_rate_limit"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(2) # По две кнопки в ряд
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_superuser_assignment_keyboard(users: List, page: int = 0, per_page: int = 10) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для назначения суперпользователей
|
||||||
|
|
||||||
|
Args:
|
||||||
|
users: Список пользователей
|
||||||
|
page: Номер страницы
|
||||||
|
per_page: Количество пользователей на странице
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура с пользователями для назначения
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
# Вычисляем диапазон пользователей для текущей страницы
|
||||||
|
start_idx = page * per_page
|
||||||
|
end_idx = min(start_idx + per_page, len(users))
|
||||||
|
page_users = users[start_idx:end_idx]
|
||||||
|
|
||||||
|
# Добавляем кнопки для пользователей
|
||||||
|
for user in page_users:
|
||||||
|
# Определяем статус пользователя
|
||||||
|
status_emoji = "🔍" if user.is_superuser else "👤"
|
||||||
|
button_text = f"{status_emoji} {user.display_name}"
|
||||||
|
|
||||||
|
# Обрезаем текст если слишком длинный
|
||||||
|
if len(button_text) > 30:
|
||||||
|
button_text = button_text[:27] + "..."
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=button_text,
|
||||||
|
callback_data=f"assign_superuser_{user.telegram_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Настраиваем расположение кнопок: 1 кнопка в ряд для лучшей читаемости
|
||||||
|
builder.adjust(1)
|
||||||
|
|
||||||
|
# Добавляем управляющие кнопки
|
||||||
|
control_buttons = []
|
||||||
|
|
||||||
|
# Кнопка "Предыдущая" (если есть предыдущие страницы)
|
||||||
|
if page > 0:
|
||||||
|
control_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Предыдущая",
|
||||||
|
callback_data=f"superuser_page_{page - 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Кнопка "Следующая" (если есть следующие страницы)
|
||||||
|
total_pages = (len(users) + per_page - 1) // per_page
|
||||||
|
if page < total_pages - 1:
|
||||||
|
control_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Следующая ➡️",
|
||||||
|
callback_data=f"superuser_page_{page + 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем управляющие кнопки
|
||||||
|
if control_buttons:
|
||||||
|
builder.row(*control_buttons)
|
||||||
|
|
||||||
|
# Кнопка "Назад"
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔙 Назад к админ панели",
|
||||||
|
callback_data="back_to_admin"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_superuser_confirm_keyboard(user_id: int, is_superuser: bool) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для подтверждения назначения/снятия суперпользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя
|
||||||
|
is_superuser: Текущий статус суперпользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура с кнопками подтверждения
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
if is_superuser:
|
||||||
|
# Если уже суперпользователь, предлагаем снять права
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Снять права суперпользователя",
|
||||||
|
callback_data=f"remove_superuser_{user_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Если не суперпользователь, предлагаем назначить
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✅ Назначить суперпользователем",
|
||||||
|
callback_data=f"confirm_superuser_{user_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔙 Назад к списку",
|
||||||
|
callback_data="admin_assign_superuser"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_ban_user_keyboard(users: List, page: int = 0, per_page: int = 10) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для выбора пользователя для бана
|
||||||
|
|
||||||
|
Args:
|
||||||
|
users: Список пользователей
|
||||||
|
page: Номер страницы
|
||||||
|
per_page: Количество пользователей на странице
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура с пользователями для бана
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
# Вычисляем диапазон пользователей для текущей страницы
|
||||||
|
start_idx = page * per_page
|
||||||
|
end_idx = min(start_idx + per_page, len(users))
|
||||||
|
page_users = users[start_idx:end_idx]
|
||||||
|
|
||||||
|
# Добавляем кнопки для пользователей
|
||||||
|
for user in page_users:
|
||||||
|
# Определяем статус пользователя
|
||||||
|
status_emoji = "🚫" if user.is_banned else "👤"
|
||||||
|
button_text = f"{status_emoji} {user.display_name}"
|
||||||
|
|
||||||
|
# Обрезаем текст если слишком длинный
|
||||||
|
if len(button_text) > 30:
|
||||||
|
button_text = button_text[:27] + "..."
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=button_text,
|
||||||
|
callback_data=f"ban_user_select_{user.telegram_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Настраиваем расположение кнопок: 1 кнопка в ряд для лучшей читаемости
|
||||||
|
builder.adjust(1)
|
||||||
|
|
||||||
|
# Добавляем управляющие кнопки
|
||||||
|
control_buttons = []
|
||||||
|
|
||||||
|
# Кнопка "Предыдущая" (если есть предыдущие страницы)
|
||||||
|
if page > 0:
|
||||||
|
control_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Предыдущая",
|
||||||
|
callback_data=f"ban_user_page_{page - 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Кнопка "Следующая" (если есть следующие страницы)
|
||||||
|
total_pages = (len(users) + per_page - 1) // per_page
|
||||||
|
if page < total_pages - 1:
|
||||||
|
control_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Следующая ➡️",
|
||||||
|
callback_data=f"ban_user_page_{page + 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем управляющие кнопки
|
||||||
|
if control_buttons:
|
||||||
|
builder.row(*control_buttons)
|
||||||
|
|
||||||
|
# Кнопка "Назад"
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔙 Назад к админ панели",
|
||||||
|
callback_data="back_to_admin"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_ban_duration_keyboard(user_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для выбора срока бана
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя для бана
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура с вариантами срока бана
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
# Варианты срока бана
|
||||||
|
ban_options = [
|
||||||
|
("1 час", "ban_1h"),
|
||||||
|
("1 день", "ban_1d"),
|
||||||
|
("3 дня", "ban_3d"),
|
||||||
|
("1 неделя", "ban_1w"),
|
||||||
|
("1 месяц", "ban_1m"),
|
||||||
|
("Навсегда", "ban_forever")
|
||||||
|
]
|
||||||
|
|
||||||
|
for text, callback_data in ban_options:
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=text,
|
||||||
|
callback_data=f"{callback_data}_{user_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(2) # По две кнопки в ряд
|
||||||
|
|
||||||
|
# Кнопка "Назад"
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔙 Назад к списку",
|
||||||
|
callback_data="admin_ban_user"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_ban_confirm_keyboard(user_id: int, duration: str, reason: str = None) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для подтверждения бана пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя
|
||||||
|
duration: Срок бана
|
||||||
|
reason: Причина бана
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура с кнопками подтверждения
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✅ Подтвердить бан",
|
||||||
|
callback_data=f"confirm_ban_{user_id}_{duration}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Отменить",
|
||||||
|
callback_data=f"ban_user_select_{user_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_unban_keyboard(user_id: int) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для разбана пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура с кнопкой разбана
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✅ Разбанить пользователя",
|
||||||
|
callback_data=f"unban_user_{user_id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔙 Назад к списку",
|
||||||
|
callback_data="admin_ban_user"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_stats_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для статистики
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📈 Общая статистика",
|
||||||
|
callback_data="stats_general"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад",
|
||||||
|
callback_data="back_to_admin"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(2)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_questions_keyboard(questions: list, page: int = 0, per_page: int = 9, total_questions: int = None) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура со списком вопросов пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
questions: Список вопросов для текущей страницы
|
||||||
|
page: Номер страницы
|
||||||
|
per_page: Количество вопросов на странице
|
||||||
|
total_questions: Общее количество вопросов (для расчета пагинации)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
# Если не передано общее количество вопросов, используем длину списка
|
||||||
|
if total_questions is None:
|
||||||
|
total_questions = len(questions)
|
||||||
|
|
||||||
|
# Вычисляем общее количество страниц
|
||||||
|
total_pages = (total_questions + per_page - 1) // per_page if total_questions > 0 else 1
|
||||||
|
|
||||||
|
# Добавляем кнопки для вопросов
|
||||||
|
for i, question_data in enumerate(questions):
|
||||||
|
# Проверяем, является ли элемент кортежем (question, author_user) или просто question
|
||||||
|
if isinstance(question_data, tuple):
|
||||||
|
question, author_user = question_data
|
||||||
|
else:
|
||||||
|
question = question_data
|
||||||
|
|
||||||
|
status_emoji = {
|
||||||
|
'pending': '⏳',
|
||||||
|
'answered': '✅',
|
||||||
|
'rejected': '❌',
|
||||||
|
'deleted': '🗑️'
|
||||||
|
}
|
||||||
|
|
||||||
|
emoji = status_emoji.get(question.status.value, '❓')
|
||||||
|
# Используем user_question_number для отображения
|
||||||
|
display_number = question.user_question_number if question.user_question_number is not None else (page * per_page + i + 1)
|
||||||
|
text = f"{emoji} Вопрос #{display_number}"
|
||||||
|
|
||||||
|
# Обрезаем текст если слишком длинный
|
||||||
|
if len(text) > 30:
|
||||||
|
text = text[:27] + "..."
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=text,
|
||||||
|
callback_data=f"view_question_{question.id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Настраиваем расположение кнопок: 3 кнопки в ряд для вопросов
|
||||||
|
builder.adjust(3)
|
||||||
|
|
||||||
|
# Добавляем управляющие кнопки только если есть больше одной страницы
|
||||||
|
if total_pages > 1:
|
||||||
|
control_buttons = []
|
||||||
|
|
||||||
|
# Кнопка "Предыдущая" (только если не первая страница)
|
||||||
|
if page > 0:
|
||||||
|
control_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Предыдущая",
|
||||||
|
callback_data=f"questions_page_{page - 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Кнопка "В меню"
|
||||||
|
control_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🏠 В меню",
|
||||||
|
callback_data="back_to_main"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Кнопка "Следующая" (только если не последняя страница)
|
||||||
|
if page < total_pages - 1:
|
||||||
|
control_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Следующая ➡️",
|
||||||
|
callback_data=f"questions_page_{page + 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем управляющие кнопки в отдельный ряд
|
||||||
|
builder.row(*control_buttons)
|
||||||
|
else:
|
||||||
|
# Если только одна страница, добавляем только кнопку "В меню"
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🏠 В меню",
|
||||||
|
callback_data="back_to_main"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_question_view_keyboard(question: Question) -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для просмотра конкретного вопроса
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question: Объект вопроса
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
if question.status.value == 'pending':
|
||||||
|
# Если вопрос ожидает ответа
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="💬 Ответить",
|
||||||
|
callback_data=f"answer_{question.id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="❌ Отклонить",
|
||||||
|
callback_data=f"reject_{question.id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif question.status.value == 'answered':
|
||||||
|
# Если вопрос уже отвечен
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="✏️ Редактировать ответ",
|
||||||
|
callback_data=f"edit_answer_{question.id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Общие действия
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🗑️ Удалить",
|
||||||
|
callback_data=f"delete_{question.id}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ К списку вопросов",
|
||||||
|
callback_data="back_to_questions"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_rate_limit_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура для управления rate limiting
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Inline клавиатура с кнопками rate limiting
|
||||||
|
"""
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="📊 Статистика Rate Limiting",
|
||||||
|
callback_data="rate_limit_stats"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🔄 Сбросить статистику",
|
||||||
|
callback_data="rate_limit_reset_stats"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⚙️ Адаптировать конфигурацию",
|
||||||
|
callback_data="rate_limit_adapt"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="⬅️ Назад к админке",
|
||||||
|
callback_data="back_to_admin"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(1)
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
118
keyboards/reply.py
Normal file
118
keyboards/reply.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Reply клавиатуры для бота
|
||||||
|
"""
|
||||||
|
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
||||||
|
from aiogram.utils.keyboard import ReplyKeyboardBuilder
|
||||||
|
from services.auth.auth_new import AuthService
|
||||||
|
from dependencies import get_auth
|
||||||
|
|
||||||
|
|
||||||
|
def get_main_keyboard(user_id: int = None, auth: AuthService = None) -> ReplyKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Основная клавиатура бота
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя для проверки роли
|
||||||
|
auth: Сервис авторизации для проверки роли
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reply клавиатура
|
||||||
|
"""
|
||||||
|
builder = ReplyKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
KeyboardButton(text="📋 Мои вопросы"),
|
||||||
|
KeyboardButton(text="🔗 Моя ссылка")
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
KeyboardButton(text="🚫 Заблокированные"),
|
||||||
|
KeyboardButton(text="ℹ️ Помощь")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем кнопку статистики только для администраторов
|
||||||
|
if user_id and auth and auth.is_admin(user_id):
|
||||||
|
builder.add(KeyboardButton(text="📊 Статистика"))
|
||||||
|
builder.adjust(2, 2, 1) # 2 кнопки в первом ряду, 2 во втором, 1 в третьем
|
||||||
|
else:
|
||||||
|
builder.adjust(2, 2) # 2 кнопки в первом ряду, 2 во втором
|
||||||
|
|
||||||
|
return builder.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=False,
|
||||||
|
input_field_placeholder="Выберите действие или отправьте вопрос..."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_main_keyboard_for_user(user_id: int, auth: AuthService = None) -> ReplyKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Основная клавиатура бота для конкретного пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя для проверки роли
|
||||||
|
auth: Сервис авторизации для проверки роли (опционально, если не передан, будет получен через DI)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reply клавиатура
|
||||||
|
"""
|
||||||
|
if auth is None:
|
||||||
|
auth = get_auth()
|
||||||
|
return get_main_keyboard(user_id, auth)
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_reply_keyboard() -> ReplyKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Reply клавиатура для администраторов
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reply клавиатура для админов
|
||||||
|
"""
|
||||||
|
builder = ReplyKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
KeyboardButton(text="📋 Мои вопросы"),
|
||||||
|
KeyboardButton(text="🔗 Моя ссылка")
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
KeyboardButton(text="🚫 Заблокированные"),
|
||||||
|
KeyboardButton(text="ℹ️ Помощь")
|
||||||
|
)
|
||||||
|
builder.add(
|
||||||
|
KeyboardButton(text="📊 Статистика"),
|
||||||
|
KeyboardButton(text="⚙️ Админ панель")
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.adjust(2, 2, 2) # 2 кнопки в каждом ряду
|
||||||
|
|
||||||
|
return builder.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=False,
|
||||||
|
input_field_placeholder="Выберите действие или отправьте вопрос..."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_cancel_keyboard() -> ReplyKeyboardMarkup:
|
||||||
|
"""
|
||||||
|
Клавиатура с кнопкой отмены
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Reply клавиатура
|
||||||
|
"""
|
||||||
|
builder = ReplyKeyboardBuilder()
|
||||||
|
|
||||||
|
builder.add(
|
||||||
|
KeyboardButton(text="❌ Отмена")
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup(
|
||||||
|
resize_keyboard=True,
|
||||||
|
one_time_keyboard=True,
|
||||||
|
input_field_placeholder="Отправьте текст или нажмите 'Отмена'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
174
loader.py
Normal file
174
loader.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Инициализация бота, диспетчера и базы данных
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from config.constants import ALLOWED_UPDATES
|
||||||
|
from dependencies import DependencyMiddleware, get_dependencies
|
||||||
|
from handlers import admin, answers, errors, questions, start
|
||||||
|
from middlewares.rate_limit_middleware import RateLimitMiddleware
|
||||||
|
from middlewares.validation_middleware import ValidationMiddleware
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
|
||||||
|
# Настройка логирования
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BotLoader:
|
||||||
|
"""Класс для инициализации и запуска бота"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.bot: Bot = None
|
||||||
|
self.dp: Dispatcher = None
|
||||||
|
self.db: DatabaseService = None
|
||||||
|
|
||||||
|
async def init_bot(self) -> Bot:
|
||||||
|
"""Инициализация бота"""
|
||||||
|
logger.info("🤖 Инициализация Telegram бота")
|
||||||
|
self.bot = Bot(token=config.BOT_TOKEN)
|
||||||
|
# Устанавливаем parse_mode по умолчанию для aiogram 3.3.0
|
||||||
|
self.bot.parse_mode = ParseMode.HTML
|
||||||
|
logger.info("✅ Бот успешно инициализирован")
|
||||||
|
return self.bot
|
||||||
|
|
||||||
|
async def init_dispatcher(self) -> Dispatcher:
|
||||||
|
"""Инициализация диспетчера"""
|
||||||
|
logger.info("📡 Инициализация диспетчера")
|
||||||
|
# Используем MemoryStorage для FSM
|
||||||
|
storage = MemoryStorage()
|
||||||
|
self.dp = Dispatcher(storage=storage)
|
||||||
|
|
||||||
|
# Инициализируем зависимости
|
||||||
|
logger.info("🏗️ Инициализация системы инъекции зависимостей")
|
||||||
|
deps = get_dependencies()
|
||||||
|
await deps.init()
|
||||||
|
|
||||||
|
# Добавляем middleware для инъекции зависимостей
|
||||||
|
self.dp.update.middleware(DependencyMiddleware(deps))
|
||||||
|
logger.info("✅ DependencyMiddleware зарегистрирован")
|
||||||
|
|
||||||
|
# Добавляем middleware для rate limiting
|
||||||
|
self.dp.update.middleware(RateLimitMiddleware())
|
||||||
|
logger.info("✅ RateLimitMiddleware зарегистрирован")
|
||||||
|
|
||||||
|
# Добавляем middleware для валидации
|
||||||
|
validation_middleware = ValidationMiddleware(deps.validator)
|
||||||
|
self.dp.update.middleware(validation_middleware)
|
||||||
|
logger.info("✅ ValidationMiddleware зарегистрирован")
|
||||||
|
|
||||||
|
# Регистрируем обработчики
|
||||||
|
self._register_handlers()
|
||||||
|
logger.info("✅ Диспетчер успешно инициализирован")
|
||||||
|
|
||||||
|
return self.dp
|
||||||
|
|
||||||
|
async def init_database(self) -> DatabaseService:
|
||||||
|
"""Инициализация базы данных"""
|
||||||
|
logger.info(f"💾 Инициализация базы данных: {config.DATABASE_PATH}")
|
||||||
|
self.db = DatabaseService(config.DATABASE_PATH)
|
||||||
|
await self.db.init()
|
||||||
|
logger.info("✅ База данных успешно инициализирована")
|
||||||
|
return self.db
|
||||||
|
|
||||||
|
def _register_handlers(self):
|
||||||
|
"""Регистрация всех обработчиков"""
|
||||||
|
logger.info("🔧 Регистрация обработчиков")
|
||||||
|
# Обработчики команд
|
||||||
|
self.dp.include_router(start.router)
|
||||||
|
self.dp.include_router(questions.router)
|
||||||
|
self.dp.include_router(answers.router)
|
||||||
|
self.dp.include_router(admin.router)
|
||||||
|
|
||||||
|
# Обработчик ошибок (должен быть последним)
|
||||||
|
self.dp.include_router(errors.router)
|
||||||
|
logger.info("✅ Все обработчики зарегистрированы")
|
||||||
|
|
||||||
|
async def start_polling(self):
|
||||||
|
"""Запуск бота в режиме polling"""
|
||||||
|
try:
|
||||||
|
logger.info("🚀 Запуск бота в режиме polling")
|
||||||
|
|
||||||
|
# Инициализируем компоненты
|
||||||
|
await self.init_bot()
|
||||||
|
await self.init_dispatcher()
|
||||||
|
await self.init_database()
|
||||||
|
|
||||||
|
# Уведомляем администраторов о запуске
|
||||||
|
await self._notify_admins_startup()
|
||||||
|
|
||||||
|
# Запускаем polling
|
||||||
|
logger.info("🔄 Начинаем polling...")
|
||||||
|
await self.dp.start_polling(
|
||||||
|
self.bot,
|
||||||
|
allowed_updates=ALLOWED_UPDATES
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"💥 Ошибка при запуске бота: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await self.cleanup()
|
||||||
|
|
||||||
|
async def _notify_admins_startup(self):
|
||||||
|
"""Уведомление администраторов о запуске бота"""
|
||||||
|
if not config.ADMINS:
|
||||||
|
logger.warning("⚠️ Список администраторов пуст")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"📢 Уведомление {len(config.ADMINS)} администраторов о запуске")
|
||||||
|
message = "🤖 <b>Бот запущен!</b>\n\n" \
|
||||||
|
"Анонимный бот для вопросов готов к работе."
|
||||||
|
|
||||||
|
for admin_id in config.ADMINS:
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(admin_id, message)
|
||||||
|
logger.info(f"✅ Уведомление отправлено админу {admin_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Не удалось отправить уведомление админу {admin_id}: {e}")
|
||||||
|
|
||||||
|
async def cleanup(self):
|
||||||
|
"""Очистка ресурсов при завершении"""
|
||||||
|
logger.info("🧹 Очистка ресурсов")
|
||||||
|
|
||||||
|
# Закрываем зависимости
|
||||||
|
try:
|
||||||
|
deps = get_dependencies()
|
||||||
|
await deps.close()
|
||||||
|
logger.info("✅ Зависимости закрыты")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Ошибка при закрытии зависимостей: {e}")
|
||||||
|
|
||||||
|
if self.bot:
|
||||||
|
await self.bot.session.close()
|
||||||
|
logger.info("✅ Сессия бота закрыта")
|
||||||
|
|
||||||
|
if self.db:
|
||||||
|
await self.db.close()
|
||||||
|
logger.info("✅ Соединение с БД закрыто")
|
||||||
|
|
||||||
|
logger.info("🛑 Бот остановлен")
|
||||||
|
|
||||||
|
|
||||||
|
# Создаем глобальный экземпляр загрузчика
|
||||||
|
loader = BotLoader()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Главная функция для запуска бота"""
|
||||||
|
await loader.start_polling()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Получен сигнал остановки")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Критическая ошибка: {e}")
|
||||||
|
raise
|
||||||
11
main.py
11
main.py
@@ -1,2 +1,9 @@
|
|||||||
if __name__ == '__main__':
|
"""
|
||||||
print("Hello World")
|
Точка входа для запуска бота анонимных вопросов
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from bot import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
8
middlewares/__init__.py
Normal file
8
middlewares/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Middleware для бота
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .rate_limit_middleware import RateLimitMiddleware
|
||||||
|
from .validation_middleware import ValidationMiddleware, ValidationError
|
||||||
|
|
||||||
|
__all__ = ['RateLimitMiddleware', 'ValidationMiddleware', 'ValidationError']
|
||||||
62
middlewares/rate_limit_middleware.py
Normal file
62
middlewares/rate_limit_middleware.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Middleware для автоматического применения rate limiting ко всем входящим сообщениям
|
||||||
|
"""
|
||||||
|
from typing import Callable, Dict, Any, Awaitable, Union
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import Message, CallbackQuery, InlineQuery, ChatMemberUpdated, Update
|
||||||
|
from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
|
||||||
|
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.rate_limiting.rate_limiter import telegram_rate_limiter
|
||||||
|
from services.infrastructure.logging_decorators import log_middleware
|
||||||
|
from services.infrastructure.logging_utils import log_user_action
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseMiddleware):
|
||||||
|
"""Middleware для автоматического rate limiting входящих сообщений"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.rate_limiter = telegram_rate_limiter
|
||||||
|
|
||||||
|
@log_middleware(log_params=True, log_result=False)
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: Union[Update, Message, CallbackQuery, InlineQuery, ChatMemberUpdated],
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
"""Обрабатывает событие с rate limiting"""
|
||||||
|
|
||||||
|
# Извлекаем сообщение из Update
|
||||||
|
message = None
|
||||||
|
if isinstance(event, Update):
|
||||||
|
message = event.message
|
||||||
|
elif isinstance(event, Message):
|
||||||
|
message = event
|
||||||
|
|
||||||
|
# Применяем rate limiting только к сообщениям
|
||||||
|
if message is not None:
|
||||||
|
chat_id = message.chat.id
|
||||||
|
|
||||||
|
# Обертываем handler в rate limiting
|
||||||
|
async def rate_limited_handler():
|
||||||
|
try:
|
||||||
|
return await handler(event, data)
|
||||||
|
except (TelegramRetryAfter, TelegramAPIError) as e:
|
||||||
|
logger.warning(f"Rate limit error in middleware: {e}")
|
||||||
|
# Middleware не должен перехватывать эти ошибки,
|
||||||
|
# пусть их обрабатывает rate_limiter в функциях отправки
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Применяем rate limiting к handler
|
||||||
|
result, wait_time = await self.rate_limiter.execute_with_rate_limit(
|
||||||
|
rate_limited_handler,
|
||||||
|
chat_id
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
# Для других типов событий просто вызываем handler
|
||||||
|
return await handler(event, data)
|
||||||
133
middlewares/validation_middleware.py
Normal file
133
middlewares/validation_middleware.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
Middleware для валидации входных данных
|
||||||
|
"""
|
||||||
|
from typing import Any, Dict, Callable, Awaitable
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import TelegramObject, CallbackQuery, Message
|
||||||
|
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.validation import InputValidator
|
||||||
|
from services.infrastructure.logging_decorators import log_middleware
|
||||||
|
from services.infrastructure.logging_utils import log_user_action
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationMiddleware(BaseMiddleware):
|
||||||
|
"""Middleware для валидации входных данных"""
|
||||||
|
|
||||||
|
def __init__(self, validator: InputValidator):
|
||||||
|
super().__init__()
|
||||||
|
self.validator = validator
|
||||||
|
logger.info("🔍 ValidationMiddleware инициализирован")
|
||||||
|
|
||||||
|
@log_middleware(log_params=True, log_result=False)
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: TelegramObject,
|
||||||
|
data: Dict[str, Any]
|
||||||
|
) -> Any:
|
||||||
|
"""Валидация входных данных перед обработкой"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Валидация callback queries
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
await self._validate_callback_query(event, data)
|
||||||
|
|
||||||
|
# Валидация сообщений
|
||||||
|
elif isinstance(event, Message):
|
||||||
|
await self._validate_message(event, data)
|
||||||
|
|
||||||
|
# Продолжаем обработку
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.warning(f"⚠️ Ошибка валидации: {e}")
|
||||||
|
await self._handle_validation_error(event, str(e))
|
||||||
|
return
|
||||||
|
|
||||||
|
@log_middleware(log_params=True, log_result=False)
|
||||||
|
async def _validate_callback_query(self, callback: CallbackQuery, data: Dict[str, Any]) -> None:
|
||||||
|
"""Валидация callback query"""
|
||||||
|
try:
|
||||||
|
# Валидируем callback data
|
||||||
|
validation_result = self.validator.validate_callback_data(callback.data)
|
||||||
|
if not validation_result:
|
||||||
|
logger.warning(f"⚠️ Невалидный callback data от пользователя {callback.from_user.id}: {callback.data}")
|
||||||
|
await callback.answer("❌ Неверные данные", show_alert=True)
|
||||||
|
raise ValidationError(f"Invalid callback data: {validation_result.error_message}")
|
||||||
|
|
||||||
|
# Валидируем Telegram ID пользователя
|
||||||
|
user_id_validation = self.validator.validate_telegram_id(callback.from_user.id)
|
||||||
|
if not user_id_validation:
|
||||||
|
logger.warning(f"⚠️ Невалидный Telegram ID в callback: {callback.from_user.id}")
|
||||||
|
await callback.answer("❌ Ошибка: недопустимый ID пользователя", show_alert=True)
|
||||||
|
raise ValidationError(f"Invalid Telegram ID: {user_id_validation.error_message}")
|
||||||
|
|
||||||
|
# Валидируем username если есть
|
||||||
|
if callback.from_user.username:
|
||||||
|
username_validation = self.validator.validate_username(callback.from_user.username)
|
||||||
|
if not username_validation:
|
||||||
|
logger.warning(f"⚠️ Невалидный username в callback: {callback.from_user.username}")
|
||||||
|
# Username не критичен, только логируем
|
||||||
|
|
||||||
|
logger.debug(f"✅ Callback query от пользователя {callback.from_user.id} прошел валидацию")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if not isinstance(e, ValidationError):
|
||||||
|
logger.error(f"❌ Ошибка валидации callback query: {e}")
|
||||||
|
await callback.answer("❌ Ошибка валидации", show_alert=True)
|
||||||
|
raise ValidationError(f"Callback validation error: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@log_middleware(log_params=True, log_result=False)
|
||||||
|
async def _validate_message(self, message: Message, data: Dict[str, Any]) -> None:
|
||||||
|
"""Валидация сообщения"""
|
||||||
|
try:
|
||||||
|
# Валидируем Telegram ID пользователя
|
||||||
|
user_id_validation = self.validator.validate_telegram_id(message.from_user.id)
|
||||||
|
if not user_id_validation:
|
||||||
|
logger.warning(f"⚠️ Невалидный Telegram ID в сообщении: {message.from_user.id}")
|
||||||
|
await message.answer("❌ Ошибка: недопустимый ID пользователя")
|
||||||
|
raise ValidationError(f"Invalid Telegram ID: {user_id_validation.error_message}")
|
||||||
|
|
||||||
|
# Валидируем username если есть
|
||||||
|
if message.from_user.username:
|
||||||
|
username_validation = self.validator.validate_username(message.from_user.username)
|
||||||
|
if not username_validation:
|
||||||
|
logger.warning(f"⚠️ Невалидный username в сообщении: {message.from_user.username}")
|
||||||
|
# Username не критичен, только логируем
|
||||||
|
|
||||||
|
# Валидируем chat ID
|
||||||
|
chat_id_validation = self.validator.validate_telegram_id(message.chat.id)
|
||||||
|
if not chat_id_validation:
|
||||||
|
logger.warning(f"⚠️ Невалидный chat ID в сообщении: {message.chat.id}")
|
||||||
|
await message.answer("❌ Ошибка: недопустимый ID чата")
|
||||||
|
raise ValidationError(f"Invalid chat ID: {chat_id_validation.error_message}")
|
||||||
|
|
||||||
|
logger.debug(f"✅ Сообщение от пользователя {message.from_user.id} прошло валидацию")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if not isinstance(e, ValidationError):
|
||||||
|
logger.error(f"❌ Ошибка валидации сообщения: {e}")
|
||||||
|
await message.answer("❌ Ошибка валидации")
|
||||||
|
raise ValidationError(f"Message validation error: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@log_middleware(log_params=True, log_result=False)
|
||||||
|
async def _handle_validation_error(self, event: TelegramObject, error_message: str) -> None:
|
||||||
|
"""Обработка ошибок валидации"""
|
||||||
|
try:
|
||||||
|
if isinstance(event, CallbackQuery):
|
||||||
|
await event.answer(f"❌ {error_message}", show_alert=True)
|
||||||
|
elif isinstance(event, Message):
|
||||||
|
await event.answer(f"❌ {error_message}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка при отправке сообщения об ошибке валидации: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(Exception):
|
||||||
|
"""Исключение для ошибок валидации"""
|
||||||
|
pass
|
||||||
10
models/__init__.py
Normal file
10
models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Модели данных для бота анонимных вопросов
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .user import User
|
||||||
|
from .question import Question, QuestionStatus
|
||||||
|
from .user_block import UserBlock
|
||||||
|
from .user_settings import UserSettings
|
||||||
|
|
||||||
|
__all__ = ['User', 'Question', 'QuestionStatus', 'UserBlock', 'UserSettings']
|
||||||
118
models/question.py
Normal file
118
models/question.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Модель вопроса
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from config.constants import DEFAULT_QUESTION_PREVIEW_LENGTH, EMPTY_VALUES
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionStatus(Enum):
|
||||||
|
"""Статусы вопроса"""
|
||||||
|
PENDING = "pending" # Ожидает ответа
|
||||||
|
ANSWERED = "answered" # Отвечен
|
||||||
|
REJECTED = "rejected" # Отклонен
|
||||||
|
DELETED = "deleted" # Удален
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Question:
|
||||||
|
"""Модель вопроса"""
|
||||||
|
|
||||||
|
id: Optional[int] = None
|
||||||
|
from_user_id: Optional[int] = None # ID отправителя (может быть None для анонимных)
|
||||||
|
to_user_id: int = None # ID получателя
|
||||||
|
message_text: str = "" # Текст вопроса
|
||||||
|
answer_text: Optional[str] = None # Текст ответа
|
||||||
|
is_anonymous: bool = True # Анонимный ли вопрос
|
||||||
|
message_id: Optional[int] = None # ID сообщения в Telegram
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
answered_at: Optional[datetime] = None
|
||||||
|
is_read: bool = False # Прочитан ли вопрос
|
||||||
|
status: QuestionStatus = QuestionStatus.PENDING
|
||||||
|
user_question_number: Optional[int] = None # Локальный номер вопроса для пользователя
|
||||||
|
|
||||||
|
# Lazy loading атрибуты
|
||||||
|
_from_user: Optional['User'] = None
|
||||||
|
_to_user: Optional['User'] = None
|
||||||
|
_user_loader: Optional[callable] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_answered(self) -> bool:
|
||||||
|
"""Проверка, отвечен ли вопрос"""
|
||||||
|
return self.status == QuestionStatus.ANSWERED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_pending(self) -> bool:
|
||||||
|
"""Проверка, ожидает ли вопрос ответа"""
|
||||||
|
return self.status == QuestionStatus.PENDING
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_datetime(cls, date_str) -> Optional[datetime]:
|
||||||
|
"""Безопасный парсинг datetime из строки"""
|
||||||
|
if not date_str or date_str in EMPTY_VALUES:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(date_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_datetime_async(cls, date_str) -> Optional[datetime]:
|
||||||
|
"""Асинхронный безопасный парсинг datetime из строки"""
|
||||||
|
if not date_str or date_str in EMPTY_VALUES:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, datetime.fromisoformat, date_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_as_answered(self, answer_text: str):
|
||||||
|
"""Отметить вопрос как отвеченный"""
|
||||||
|
self.answer_text = answer_text
|
||||||
|
self.status = QuestionStatus.ANSWERED
|
||||||
|
self.answered_at = datetime.now()
|
||||||
|
|
||||||
|
def mark_as_rejected(self):
|
||||||
|
"""Отметить вопрос как отклоненный"""
|
||||||
|
self.status = QuestionStatus.REJECTED
|
||||||
|
self.answered_at = datetime.now()
|
||||||
|
|
||||||
|
def mark_as_deleted(self):
|
||||||
|
"""Отметить вопрос как удаленный"""
|
||||||
|
self.status = QuestionStatus.DELETED
|
||||||
|
self.answered_at = datetime.now()
|
||||||
|
self.user_question_number = None # Удаленные вопросы не имеют номера
|
||||||
|
|
||||||
|
def set_user_loader(self, loader_func: callable):
|
||||||
|
"""Установка функции для загрузки пользователей"""
|
||||||
|
self._user_loader = loader_func
|
||||||
|
|
||||||
|
async def get_from_user(self) -> Optional['User']:
|
||||||
|
"""Lazy loading автора вопроса"""
|
||||||
|
if self._from_user is None and self.from_user_id and self._user_loader:
|
||||||
|
self._from_user = await self._user_loader(self.from_user_id)
|
||||||
|
return self._from_user
|
||||||
|
|
||||||
|
async def get_to_user(self) -> Optional['User']:
|
||||||
|
"""Lazy loading получателя вопроса"""
|
||||||
|
if self._to_user is None and self.to_user_id and self._user_loader:
|
||||||
|
self._to_user = await self._user_loader(self.to_user_id)
|
||||||
|
return self._to_user
|
||||||
|
|
||||||
|
def get_question_preview(self, max_length: int = DEFAULT_QUESTION_PREVIEW_LENGTH) -> str:
|
||||||
|
"""Получить превью вопроса"""
|
||||||
|
if len(self.message_text) <= max_length:
|
||||||
|
return self.message_text
|
||||||
|
return self.message_text[:max_length] + "..."
|
||||||
|
|
||||||
|
def get_display_number(self) -> int:
|
||||||
|
"""Получить номер вопроса для отображения (приоритет user_question_number)"""
|
||||||
|
return self.user_question_number if self.user_question_number is not None else self.id
|
||||||
|
|
||||||
92
models/user.py
Normal file
92
models/user.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
Модель пользователя
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from config.constants import EMPTY_VALUES
|
||||||
|
|
||||||
|
|
||||||
|
def escape_html(text: str) -> str:
|
||||||
|
"""Экранирование HTML символов"""
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return (text
|
||||||
|
.replace('&', '&')
|
||||||
|
.replace('<', '<')
|
||||||
|
.replace('>', '>')
|
||||||
|
.replace('"', '"')
|
||||||
|
.replace("'", '''))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class User:
|
||||||
|
"""Модель пользователя бота"""
|
||||||
|
|
||||||
|
id: Optional[int] = None
|
||||||
|
telegram_id: int = None
|
||||||
|
username: Optional[str] = None
|
||||||
|
first_name: str = ""
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
chat_id: int = None
|
||||||
|
profile_link: str = ""
|
||||||
|
is_active: bool = True
|
||||||
|
is_superuser: bool = False
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
banned_until: Optional[datetime] = None
|
||||||
|
ban_reason: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self) -> str:
|
||||||
|
"""Полное имя пользователя"""
|
||||||
|
parts = []
|
||||||
|
if self.first_name:
|
||||||
|
parts.append(escape_html(self.first_name))
|
||||||
|
if self.last_name:
|
||||||
|
parts.append(escape_html(self.last_name))
|
||||||
|
return ' '.join(parts) if parts else 'Неизвестно'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Отображаемое имя пользователя"""
|
||||||
|
if self.username:
|
||||||
|
return f"@{escape_html(self.username)}"
|
||||||
|
return escape_html(self.full_name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_banned(self) -> bool:
|
||||||
|
"""Проверка, забанен ли пользователь"""
|
||||||
|
if not self.banned_until:
|
||||||
|
return False
|
||||||
|
return datetime.now() < self.banned_until
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_datetime(cls, date_str) -> Optional[datetime]:
|
||||||
|
"""Безопасный парсинг datetime из строки"""
|
||||||
|
if not date_str or date_str in EMPTY_VALUES:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(date_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _parse_datetime_async(cls, date_str) -> Optional[datetime]:
|
||||||
|
"""Асинхронный безопасный парсинг datetime из строки"""
|
||||||
|
if not date_str or date_str in EMPTY_VALUES:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, datetime.fromisoformat, date_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_timestamp(self):
|
||||||
|
"""Обновление времени последнего обновления"""
|
||||||
|
self.updated_at = datetime.now()
|
||||||
|
|
||||||
18
models/user_block.py
Normal file
18
models/user_block.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""
|
||||||
|
Модель блокировки пользователя
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserBlock:
|
||||||
|
"""Модель блокировки пользователя"""
|
||||||
|
|
||||||
|
id: Optional[int] = None
|
||||||
|
blocker_id: int = None # ID пользователя, который заблокировал
|
||||||
|
blocked_id: int = None # ID заблокированного пользователя
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
37
models/user_settings.py
Normal file
37
models/user_settings.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Модель настроек пользователя
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserSettings:
|
||||||
|
"""Модель настроек пользователя"""
|
||||||
|
|
||||||
|
id: Optional[int] = None
|
||||||
|
user_id: int = None
|
||||||
|
allow_questions: bool = True # Разрешить вопросы
|
||||||
|
notify_new_questions: bool = True # Уведомления о новых вопросах
|
||||||
|
notify_answers: bool = True # Уведомления об ответах
|
||||||
|
language: str = 'ru' # Язык интерфейса
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _parse_datetime(cls, date_str) -> Optional[datetime]:
|
||||||
|
"""Безопасный парсинг datetime из строки"""
|
||||||
|
if not date_str or date_str in ['0', '']:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(date_str)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_timestamp(self):
|
||||||
|
"""Обновление времени последнего обновления"""
|
||||||
|
self.updated_at = datetime.now()
|
||||||
|
|
||||||
22
prometheus.yml
Normal file
22
prometheus.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Конфигурация Prometheus для AnonBot
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
rule_files:
|
||||||
|
# - "first_rules.yml"
|
||||||
|
# - "second_rules.yml"
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
# AnonBot метрики
|
||||||
|
- job_name: 'anon-bot'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:8081']
|
||||||
|
metrics_path: '/metrics'
|
||||||
|
scrape_interval: 30s
|
||||||
|
scrape_timeout: 10s
|
||||||
|
|
||||||
|
# Prometheus сам по себе
|
||||||
|
- job_name: 'prometheus'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9090']
|
||||||
29
pytest.ini
Normal file
29
pytest.ini
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[tool:pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
addopts =
|
||||||
|
-v
|
||||||
|
--tb=short
|
||||||
|
--strict-markers
|
||||||
|
--disable-warnings
|
||||||
|
--cov=.
|
||||||
|
--cov-report=html
|
||||||
|
--cov-report=term-missing
|
||||||
|
--cov-fail-under=80
|
||||||
|
markers =
|
||||||
|
unit: Unit tests
|
||||||
|
integration: Integration tests
|
||||||
|
slow: Slow tests
|
||||||
|
database: Database tests
|
||||||
|
bot: Bot tests
|
||||||
|
auth: Authentication tests
|
||||||
|
validation: Validation tests
|
||||||
|
crud: CRUD tests
|
||||||
|
middleware: Middleware tests
|
||||||
|
services: Services tests
|
||||||
|
handlers: Handlers tests
|
||||||
|
models: Models tests
|
||||||
|
config: Configuration tests
|
||||||
|
asyncio_mode = auto
|
||||||
28
requirements.txt
Normal file
28
requirements.txt
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Основные зависимости для Telegram бота
|
||||||
|
aiogram==3.3.0
|
||||||
|
aiohttp==3.9.1
|
||||||
|
aiosqlite==0.19.0
|
||||||
|
|
||||||
|
# Для работы с переменными окружения
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
|
||||||
|
# Для работы с датами
|
||||||
|
python-dateutil==2.8.2
|
||||||
|
|
||||||
|
# Для валидации данных
|
||||||
|
pydantic==2.5.2
|
||||||
|
|
||||||
|
# Для логирования
|
||||||
|
loguru==0.7.2
|
||||||
|
|
||||||
|
# Для работы с JSON
|
||||||
|
orjson==3.9.10
|
||||||
|
|
||||||
|
# Дополнительные утилиты
|
||||||
|
cryptography>=42.0.0
|
||||||
|
|
||||||
|
# Для Prometheus метрик
|
||||||
|
prometheus-client==0.19.0
|
||||||
|
|
||||||
|
# Для работы с процессами и системной информацией
|
||||||
|
psutil==5.9.8
|
||||||
9
services/__init__.py
Normal file
9
services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""
|
||||||
|
Сервисы для работы с данными и утилитами
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Импорты для обратной совместимости
|
||||||
|
from .infrastructure.database import DatabaseService
|
||||||
|
from .utils import generate_referral_link, format_question_info
|
||||||
|
|
||||||
|
__all__ = ['DatabaseService', 'generate_referral_link', 'format_question_info']
|
||||||
7
services/auth/__init__.py
Normal file
7
services/auth/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Модуль авторизации и разрешений
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .auth_new import AuthService
|
||||||
|
|
||||||
|
__all__ = ['AuthService']
|
||||||
146
services/auth/auth_new.py
Normal file
146
services/auth/auth_new.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
Новый сервис авторизации с использованием системы разрешений
|
||||||
|
Соблюдает принцип открытости/закрытости (OCP)
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.permissions import get_permission_checker
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
"""
|
||||||
|
Сервис авторизации, использующий систему разрешений.
|
||||||
|
Соблюдает принцип открытости/закрытости (OCP).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, database: DatabaseService, config_provider):
|
||||||
|
self.database = database
|
||||||
|
self.config = config_provider
|
||||||
|
|
||||||
|
def is_admin(self, user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка, является ли пользователь администратором
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если пользователь администратор, False иначе
|
||||||
|
"""
|
||||||
|
return user_id in self.config.ADMINS
|
||||||
|
|
||||||
|
async def is_superuser(self, user_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка, является ли пользователь суперпользователем
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если пользователь суперпользователь, False иначе
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = await self.database.get_user(user_id)
|
||||||
|
return user.is_superuser if user else False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_user_role(self, user_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Получение роли пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Роль пользователя: 'admin', 'superuser' или 'user'
|
||||||
|
"""
|
||||||
|
if self.is_admin(user_id):
|
||||||
|
return 'admin'
|
||||||
|
elif await self.is_superuser(user_id):
|
||||||
|
return 'superuser'
|
||||||
|
else:
|
||||||
|
return 'user'
|
||||||
|
|
||||||
|
async def has_permission(self, user_id: int, permission: str) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка наличия разрешения у пользователя через систему разрешений
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
permission: Название разрешения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если у пользователя есть разрешение, False иначе
|
||||||
|
"""
|
||||||
|
checker = get_permission_checker()
|
||||||
|
if not checker:
|
||||||
|
logger.error("Проверщик разрешений не инициализирован")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await checker.has_permission(user_id, permission)
|
||||||
|
|
||||||
|
async def has_any_permission(self, user_id: int, permissions: list[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка наличия хотя бы одного из разрешений у пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
permissions: Список названий разрешений
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если у пользователя есть хотя бы одно разрешение, False иначе
|
||||||
|
"""
|
||||||
|
checker = get_permission_checker()
|
||||||
|
if not checker:
|
||||||
|
logger.error("Проверщик разрешений не инициализирован")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await checker.has_any_permission(user_id, permissions)
|
||||||
|
|
||||||
|
async def has_all_permissions(self, user_id: int, permissions: list[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка наличия всех разрешений у пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
permissions: Список названий разрешений
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если у пользователя есть все разрешения, False иначе
|
||||||
|
"""
|
||||||
|
checker = get_permission_checker()
|
||||||
|
if not checker:
|
||||||
|
logger.error("Проверщик разрешений не инициализирован")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return await checker.has_all_permissions(user_id, permissions)
|
||||||
|
|
||||||
|
async def get_user_permissions(self, user_id: int) -> list[str]:
|
||||||
|
"""
|
||||||
|
Получение списка всех разрешений пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список названий разрешений пользователя
|
||||||
|
"""
|
||||||
|
checker = get_permission_checker()
|
||||||
|
if not checker:
|
||||||
|
logger.error("Проверщик разрешений не инициализирован")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Получаем все доступные разрешения
|
||||||
|
registry = checker.registry
|
||||||
|
all_permissions = registry.get_all()
|
||||||
|
|
||||||
|
user_permissions = []
|
||||||
|
for permission_name in all_permissions.keys():
|
||||||
|
if await checker.has_permission(user_id, permission_name):
|
||||||
|
user_permissions.append(permission_name)
|
||||||
|
|
||||||
|
return user_permissions
|
||||||
10
services/business/__init__.py
Normal file
10
services/business/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Бизнес-логика сервисов
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .user_service import UserService
|
||||||
|
from .question_service import QuestionService
|
||||||
|
from .message_service import MessageService
|
||||||
|
from .pagination_service import PaginationService
|
||||||
|
|
||||||
|
__all__ = ['UserService', 'QuestionService', 'MessageService', 'PaginationService']
|
||||||
237
services/business/message_service.py
Normal file
237
services/business/message_service.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"""
|
||||||
|
Сервис для отправки сообщений
|
||||||
|
"""
|
||||||
|
from typing import Optional, Union
|
||||||
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, ReplyKeyboardMarkup
|
||||||
|
from aiogram import Bot
|
||||||
|
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.rate_limiting.rate_limit_service import RateLimitService
|
||||||
|
from services.infrastructure.logging_decorators import log_function_call, log_business_event
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageService:
|
||||||
|
"""Сервис для отправки сообщений"""
|
||||||
|
|
||||||
|
def __init__(self, rate_limit_service: Optional[RateLimitService] = None):
|
||||||
|
self.rate_limit_service = rate_limit_service
|
||||||
|
|
||||||
|
@log_business_event("send_message", log_params=True, log_result=True)
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
target: Union[Message, CallbackQuery, Bot],
|
||||||
|
text: str,
|
||||||
|
reply_markup: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None,
|
||||||
|
parse_mode: str = "HTML"
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Отправка сообщения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: Целевой объект (Message, CallbackQuery или Bot)
|
||||||
|
text: Текст сообщения
|
||||||
|
reply_markup: Клавиатура
|
||||||
|
parse_mode: Режим парсинга
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно отправлено
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"📤 Отправка сообщения с клавиатурой: {type(reply_markup).__name__ if reply_markup else 'None'}")
|
||||||
|
|
||||||
|
if isinstance(target, Message):
|
||||||
|
await target.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||||
|
elif isinstance(target, CallbackQuery):
|
||||||
|
await target.message.answer(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||||
|
elif isinstance(target, Bot):
|
||||||
|
# Для Bot нужен chat_id, который должен быть передан отдельно
|
||||||
|
logger.error("Для Bot нужен chat_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке сообщения: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@log_business_event("edit_message", log_params=True, log_result=True)
|
||||||
|
async def edit_message(
|
||||||
|
self,
|
||||||
|
target: Union[Message, CallbackQuery],
|
||||||
|
text: str,
|
||||||
|
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||||
|
parse_mode: str = "HTML"
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Редактирование сообщения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: Целевой объект (Message или CallbackQuery)
|
||||||
|
text: Новый текст сообщения
|
||||||
|
reply_markup: Новая клавиатура
|
||||||
|
parse_mode: Режим парсинга
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно отредактировано
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if isinstance(target, Message):
|
||||||
|
await target.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||||
|
elif isinstance(target, CallbackQuery):
|
||||||
|
await target.message.edit_text(text, reply_markup=reply_markup, parse_mode=parse_mode)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при редактировании сообщения: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
async def send_callback_answer(
|
||||||
|
self,
|
||||||
|
callback: CallbackQuery,
|
||||||
|
text: Optional[str] = None,
|
||||||
|
show_alert: bool = False
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Отправка ответа на callback query
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: CallbackQuery объект
|
||||||
|
text: Текст ответа
|
||||||
|
show_alert: Показывать ли alert
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно отправлено
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await callback.answer(text, show_alert=show_alert)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке callback answer: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@log_business_event("send_bot_message", log_params=True, log_result=True)
|
||||||
|
async def send_bot_message(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
chat_id: int,
|
||||||
|
text: str,
|
||||||
|
reply_markup: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None,
|
||||||
|
parse_mode: str = "HTML"
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Отправка сообщения через бота с rate limiting
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Экземпляр бота
|
||||||
|
chat_id: ID чата
|
||||||
|
text: Текст сообщения
|
||||||
|
reply_markup: Клавиатура
|
||||||
|
parse_mode: Режим парсинга
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно отправлено
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self.rate_limit_service:
|
||||||
|
# Используем rate limiting
|
||||||
|
await self.rate_limit_service.send_with_rate_limit(
|
||||||
|
bot.send_message,
|
||||||
|
chat_id,
|
||||||
|
text=text,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
parse_mode=parse_mode
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Отправляем без rate limiting
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=text,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
parse_mode=parse_mode
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке сообщения через бота в чат {chat_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@log_business_event("send_notification", log_params=True, log_result=True)
|
||||||
|
async def send_notification(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
user_id: int,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
reply_markup: Optional[InlineKeyboardMarkup] = None
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Отправка уведомления пользователю с rate limiting
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Экземпляр бота
|
||||||
|
user_id: ID пользователя
|
||||||
|
title: Заголовок уведомления
|
||||||
|
message: Текст уведомления
|
||||||
|
reply_markup: Клавиатура
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно отправлено
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
notification_text = f"🔔 <b>{title}</b>\n\n{message}"
|
||||||
|
|
||||||
|
if self.rate_limit_service:
|
||||||
|
# Используем rate limiting
|
||||||
|
await self.rate_limit_service.send_with_rate_limit(
|
||||||
|
bot.send_message,
|
||||||
|
user_id,
|
||||||
|
text=notification_text,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Отправляем без rate limiting
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=user_id,
|
||||||
|
text=notification_text,
|
||||||
|
reply_markup=reply_markup,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Уведомление отправлено пользователю {user_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке уведомления пользователю {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@log_business_event("send_error_message", log_params=True, log_result=True)
|
||||||
|
async def send_error_message(
|
||||||
|
self,
|
||||||
|
target: Union[Message, CallbackQuery],
|
||||||
|
error_text: str = "❌ Произошла ошибка. Попробуйте позже."
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Отправка сообщения об ошибке
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target: Целевой объект
|
||||||
|
error_text: Текст ошибки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно отправлено
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if isinstance(target, CallbackQuery):
|
||||||
|
await target.answer(error_text, show_alert=True)
|
||||||
|
else:
|
||||||
|
await self.send_message(target, error_text)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке сообщения об ошибке: {e}")
|
||||||
|
return False
|
||||||
185
services/business/pagination_service.py
Normal file
185
services/business/pagination_service.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Сервис для работы с пагинацией
|
||||||
|
"""
|
||||||
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
|
from aiogram.types import InlineKeyboardMarkup
|
||||||
|
|
||||||
|
from config.constants import DEFAULT_PAGE_SIZE, MIN_PAGE_NUMBER
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationService:
|
||||||
|
"""Сервис для работы с пагинацией"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def calculate_pagination_from_db(
|
||||||
|
self,
|
||||||
|
total_count: int,
|
||||||
|
page: int,
|
||||||
|
per_page: int = DEFAULT_PAGE_SIZE
|
||||||
|
) -> Tuple[int, int, int, int]:
|
||||||
|
"""
|
||||||
|
Расчет пагинации на основе общего количества записей в БД
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_count: Общее количество записей в БД
|
||||||
|
page: Номер страницы (начиная с 0)
|
||||||
|
per_page: Количество элементов на странице
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Кортеж (общее_количество, текущая_страница, общее_количество_страниц, offset)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
total_pages = (total_count + per_page - 1) // per_page # Округление вверх
|
||||||
|
|
||||||
|
# Проверяем корректность номера страницы
|
||||||
|
if page < MIN_PAGE_NUMBER:
|
||||||
|
page = MIN_PAGE_NUMBER
|
||||||
|
elif page >= total_pages and total_pages > 0:
|
||||||
|
page = total_pages - 1
|
||||||
|
|
||||||
|
# Вычисляем offset для БД
|
||||||
|
offset = page * per_page
|
||||||
|
|
||||||
|
return total_count, page, total_pages, offset
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при расчете пагинации из БД: {e}")
|
||||||
|
return MIN_PAGE_NUMBER, MIN_PAGE_NUMBER, MIN_PAGE_NUMBER, MIN_PAGE_NUMBER
|
||||||
|
|
||||||
|
def calculate_pagination(
|
||||||
|
self,
|
||||||
|
items: List[Any],
|
||||||
|
page: int,
|
||||||
|
per_page: int = DEFAULT_PAGE_SIZE
|
||||||
|
) -> Tuple[List[Any], int, int, int]:
|
||||||
|
"""
|
||||||
|
Расчет пагинации для списка элементов
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: Список элементов
|
||||||
|
page: Номер страницы (начиная с 0)
|
||||||
|
per_page: Количество элементов на странице
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Кортеж (элементы_страницы, общее_количество, текущая_страница, общее_количество_страниц)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
total_items = len(items)
|
||||||
|
total_pages = (total_items + per_page - 1) // per_page # Округление вверх
|
||||||
|
|
||||||
|
# Проверяем корректность номера страницы
|
||||||
|
if page < MIN_PAGE_NUMBER:
|
||||||
|
page = MIN_PAGE_NUMBER
|
||||||
|
elif page >= total_pages and total_pages > 0:
|
||||||
|
page = total_pages - 1
|
||||||
|
|
||||||
|
# Вычисляем диапазон элементов для текущей страницы
|
||||||
|
start_idx = page * per_page
|
||||||
|
end_idx = min(start_idx + per_page, total_items)
|
||||||
|
page_items = items[start_idx:end_idx]
|
||||||
|
|
||||||
|
return page_items, total_items, page, total_pages
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при расчете пагинации: {e}")
|
||||||
|
return [], MIN_PAGE_NUMBER, MIN_PAGE_NUMBER, MIN_PAGE_NUMBER
|
||||||
|
|
||||||
|
def format_pagination_info(
|
||||||
|
self,
|
||||||
|
current_page: int,
|
||||||
|
total_pages: int,
|
||||||
|
start_idx: int,
|
||||||
|
end_idx: int,
|
||||||
|
total_items: int
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Форматирование информации о пагинации
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_page: Текущая страница
|
||||||
|
total_pages: Общее количество страниц
|
||||||
|
start_idx: Начальный индекс
|
||||||
|
end_idx: Конечный индекс
|
||||||
|
total_items: Общее количество элементов
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отформатированная строка с информацией о пагинации
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
info = f"📊 Показано {start_idx + 1}-{end_idx} из {total_items}\n"
|
||||||
|
info += f"📄 Страница {current_page + 1} из {total_pages}\n\n"
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при форматировании информации о пагинации: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def get_pagination_buttons(
|
||||||
|
self,
|
||||||
|
current_page: int,
|
||||||
|
total_pages: int,
|
||||||
|
callback_prefix: str,
|
||||||
|
additional_buttons: Optional[List[Tuple[str, str]]] = None
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
|
"""
|
||||||
|
Получение кнопок пагинации
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_page: Текущая страница
|
||||||
|
total_pages: Общее количество страниц
|
||||||
|
callback_prefix: Префикс для callback_data
|
||||||
|
additional_buttons: Дополнительные кнопки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список кортежей (текст_кнопки, callback_data)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
buttons = []
|
||||||
|
|
||||||
|
# Кнопка "Предыдущая"
|
||||||
|
if current_page > MIN_PAGE_NUMBER:
|
||||||
|
buttons.append(("⬅️", f"{callback_prefix}_page_{current_page - 1}"))
|
||||||
|
|
||||||
|
# Кнопка "Следующая"
|
||||||
|
if current_page < total_pages - 1:
|
||||||
|
buttons.append(("➡️", f"{callback_prefix}_page_{current_page + 1}"))
|
||||||
|
|
||||||
|
# Дополнительные кнопки
|
||||||
|
if additional_buttons:
|
||||||
|
buttons.extend(additional_buttons)
|
||||||
|
|
||||||
|
return buttons
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании кнопок пагинации: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def validate_page_number(self, page: int, total_pages: int) -> int:
|
||||||
|
"""
|
||||||
|
Валидация номера страницы
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: Номер страницы
|
||||||
|
total_pages: Общее количество страниц
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Валидный номер страницы
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if page < 0:
|
||||||
|
return 0
|
||||||
|
elif page >= total_pages and total_pages > 0:
|
||||||
|
return total_pages - 1
|
||||||
|
else:
|
||||||
|
return page
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при валидации номера страницы: {e}")
|
||||||
|
return MIN_PAGE_NUMBER
|
||||||
280
services/business/question_service.py
Normal file
280
services/business/question_service.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""
|
||||||
|
Сервис для управления вопросами
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
from aiogram import Bot
|
||||||
|
|
||||||
|
from models.question import Question, QuestionStatus
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.utils import UtilsService
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.infrastructure.metrics import get_metrics_service
|
||||||
|
from services.infrastructure.logging_decorators import log_function_call, log_business_event
|
||||||
|
from services.infrastructure.logging_utils import log_question_created, log_question_answered
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionService:
|
||||||
|
"""Сервис для управления вопросами"""
|
||||||
|
|
||||||
|
def __init__(self, database: DatabaseService, utils: UtilsService):
|
||||||
|
self.database = database
|
||||||
|
self.utils = utils
|
||||||
|
self.metrics = get_metrics_service()
|
||||||
|
|
||||||
|
@log_business_event("create_question", log_params=True, log_result=True)
|
||||||
|
async def create_question(self, from_user_id: int, to_user_id: int, message_text: str) -> Question:
|
||||||
|
"""
|
||||||
|
Создание нового вопроса
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_user_id: ID автора вопроса
|
||||||
|
to_user_id: ID получателя вопроса
|
||||||
|
message_text: Текст вопроса
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Созданный объект вопроса
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
question = Question(
|
||||||
|
from_user_id=from_user_id,
|
||||||
|
to_user_id=to_user_id,
|
||||||
|
message_text=message_text.strip(),
|
||||||
|
status=QuestionStatus.PENDING,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
is_anonymous=True
|
||||||
|
)
|
||||||
|
|
||||||
|
question = await self.database.create_question(question)
|
||||||
|
self.metrics.increment_questions("created")
|
||||||
|
return question
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании вопроса от {from_user_id} к {to_user_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
async def get_question(self, question_id: int) -> Optional[Question]:
|
||||||
|
"""
|
||||||
|
Получение вопроса по ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question_id: ID вопроса
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Объект вопроса или None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self.database.get_question(question_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении вопроса {question_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
async def get_user_questions(self, user_id: int, limit: int = 50, offset: int = 0) -> List[Question]:
|
||||||
|
"""
|
||||||
|
Получение вопросов пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя
|
||||||
|
limit: Лимит вопросов
|
||||||
|
offset: Смещение
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список вопросов
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self.database.get_user_questions(user_id, limit=limit, offset=offset)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении вопросов пользователя {user_id}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
@log_business_event("answer_question", log_params=True, log_result=True)
|
||||||
|
async def answer_question(self, question_id: int, answer_text: str) -> Optional[Question]:
|
||||||
|
"""
|
||||||
|
Ответ на вопрос
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question_id: ID вопроса
|
||||||
|
answer_text: Текст ответа
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Обновленный объект вопроса или None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
question = await self.database.get_question(question_id)
|
||||||
|
if not question:
|
||||||
|
return None
|
||||||
|
|
||||||
|
question.mark_as_answered(answer_text.strip())
|
||||||
|
question.answered_at = datetime.now()
|
||||||
|
|
||||||
|
question = await self.database.update_question(question)
|
||||||
|
self.metrics.increment_answers("sent")
|
||||||
|
return question
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при ответе на вопрос {question_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@log_business_event("reject_question", log_params=True, log_result=True)
|
||||||
|
async def reject_question(self, question_id: int) -> Optional[Question]:
|
||||||
|
"""
|
||||||
|
Отклонение вопроса
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question_id: ID вопроса
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Обновленный объект вопроса или None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
question = await self.database.get_question(question_id)
|
||||||
|
if not question:
|
||||||
|
return None
|
||||||
|
|
||||||
|
question.mark_as_rejected()
|
||||||
|
question.answered_at = datetime.now()
|
||||||
|
|
||||||
|
question = await self.database.update_question(question)
|
||||||
|
self.metrics.increment_questions("rejected")
|
||||||
|
return question
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отклонении вопроса {question_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@log_business_event("delete_question", log_params=True, log_result=True)
|
||||||
|
async def delete_question(self, question_id: int) -> Optional[Question]:
|
||||||
|
"""
|
||||||
|
Удаление вопроса
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question_id: ID вопроса
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Обновленный объект вопроса или None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
question = await self.database.get_question(question_id)
|
||||||
|
if not question:
|
||||||
|
return None
|
||||||
|
|
||||||
|
question.mark_as_deleted()
|
||||||
|
question.answered_at = datetime.now()
|
||||||
|
|
||||||
|
question = await self.database.update_question(question)
|
||||||
|
self.metrics.increment_questions("deleted")
|
||||||
|
return question
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении вопроса {question_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@log_business_event("edit_answer", log_params=True, log_result=True)
|
||||||
|
async def edit_answer(self, question_id: int, new_answer_text: str) -> Optional[Question]:
|
||||||
|
"""
|
||||||
|
Редактирование ответа на вопрос
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question_id: ID вопроса
|
||||||
|
new_answer_text: Новый текст ответа
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Обновленный объект вопроса или None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
question = await self.database.get_question(question_id)
|
||||||
|
if not question:
|
||||||
|
return None
|
||||||
|
|
||||||
|
question.answer_text = new_answer_text.strip()
|
||||||
|
question.answered_at = datetime.now()
|
||||||
|
|
||||||
|
question = await self.database.update_question(question)
|
||||||
|
self.metrics.increment_answers("edited")
|
||||||
|
return question
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при редактировании ответа на вопрос {question_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
def validate_question_text(self, text: str, max_length: int = 1000) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Валидация текста вопроса
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст вопроса
|
||||||
|
max_length: Максимальная длина
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Кортеж (валидность, сообщение об ошибке)
|
||||||
|
"""
|
||||||
|
return self.utils.is_valid_question_text(text, max_length)
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
def validate_answer_text(self, text: str, max_length: int = 2000) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Валидация текста ответа
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст ответа
|
||||||
|
max_length: Максимальная длина
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Кортеж (валидность, сообщение об ошибке)
|
||||||
|
"""
|
||||||
|
return self.utils.is_valid_answer_text(text, max_length)
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
async def send_answer_to_author(self, bot: Bot, question: Question, answer_text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Отправка ответа автору вопроса
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Экземпляр бота
|
||||||
|
question: Объект вопроса
|
||||||
|
answer_text: Текст ответа
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно отправлено
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self.utils.send_answer_to_author(bot, question, answer_text)
|
||||||
|
self.metrics.increment_answers("delivered")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке ответа автору вопроса {question.id}: {e}")
|
||||||
|
self.metrics.increment_answers("delivery_failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
def format_question_info(self, question: Question, show_answer: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Форматирование информации о вопросе
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question: Объект вопроса
|
||||||
|
show_answer: Показывать ли ответ
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отформатированная строка
|
||||||
|
"""
|
||||||
|
return self.utils.format_question_info(question, show_answer)
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
def get_question_preview(self, question: Question, max_length: int = 50) -> str:
|
||||||
|
"""
|
||||||
|
Получение превью вопроса
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question: Объект вопроса
|
||||||
|
max_length: Максимальная длина превью
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Превью вопроса
|
||||||
|
"""
|
||||||
|
return question.get_question_preview(max_length)
|
||||||
208
services/business/user_service.py
Normal file
208
services/business/user_service.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
Сервис для управления пользователями
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from aiogram.types import User as TelegramUser
|
||||||
|
|
||||||
|
from models.user import User
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.utils import UtilsService
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from services.infrastructure.metrics import get_metrics_service
|
||||||
|
from services.infrastructure.logging_decorators import log_function_call, log_business_event
|
||||||
|
from services.infrastructure.logging_utils import log_user_created, log_user_blocked
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UserService:
|
||||||
|
"""Сервис для управления пользователями"""
|
||||||
|
|
||||||
|
def __init__(self, database: DatabaseService, utils: UtilsService):
|
||||||
|
self.database = database
|
||||||
|
self.utils = utils
|
||||||
|
self.metrics = get_metrics_service()
|
||||||
|
|
||||||
|
@log_business_event("create_or_update_user", log_params=True, log_result=True)
|
||||||
|
async def create_or_update_user(self, telegram_user: TelegramUser, chat_id: int) -> User:
|
||||||
|
"""
|
||||||
|
Создание или обновление пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telegram_user: Объект пользователя из Telegram
|
||||||
|
chat_id: ID чата
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Объект пользователя
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Проверяем, существует ли пользователь
|
||||||
|
existing_user = await self.database.get_user(telegram_user.id)
|
||||||
|
|
||||||
|
if existing_user:
|
||||||
|
# Обновляем существующего пользователя
|
||||||
|
logger.info(f"👤 Обновление существующего пользователя {telegram_user.id}")
|
||||||
|
self.metrics.increment_users("updated")
|
||||||
|
return await self._update_existing_user(existing_user, telegram_user, chat_id)
|
||||||
|
else:
|
||||||
|
# Создаем нового пользователя
|
||||||
|
logger.info(f"👤 Создание нового пользователя {telegram_user.id}")
|
||||||
|
self.metrics.increment_users("created")
|
||||||
|
return await self._create_new_user(telegram_user, chat_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании/обновлении пользователя {telegram_user.id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
async def _update_existing_user(self, existing_user: User, telegram_user: TelegramUser, chat_id: int) -> User:
|
||||||
|
"""Обновление существующего пользователя"""
|
||||||
|
existing_user.username = telegram_user.username
|
||||||
|
existing_user.first_name = telegram_user.first_name or "Пользователь"
|
||||||
|
existing_user.last_name = telegram_user.last_name
|
||||||
|
existing_user.chat_id = chat_id
|
||||||
|
existing_user.update_timestamp()
|
||||||
|
|
||||||
|
return await self.database.update_user(existing_user)
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
async def _create_new_user(self, telegram_user: TelegramUser, chat_id: int) -> User:
|
||||||
|
"""Создание нового пользователя"""
|
||||||
|
user = User(
|
||||||
|
telegram_id=telegram_user.id,
|
||||||
|
username=telegram_user.username,
|
||||||
|
first_name=telegram_user.first_name or "Пользователь",
|
||||||
|
last_name=telegram_user.last_name,
|
||||||
|
chat_id=chat_id,
|
||||||
|
profile_link=self.utils.generate_anonymous_id(),
|
||||||
|
is_active=True,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self.database.create_user(user)
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
async def get_user_by_profile_link(self, profile_link: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Получение пользователя по ссылке профиля
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile_link: Ссылка профиля
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Объект пользователя или None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self.database.get_user_by_profile_link(profile_link)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении пользователя по ссылке {profile_link}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Получение пользователя по Telegram ID
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telegram_id: ID пользователя в Telegram
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Объект пользователя или None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self.database.get_user(telegram_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении пользователя {telegram_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
def generate_referral_link(self, bot_username: str, user: User) -> str:
|
||||||
|
"""
|
||||||
|
Генерация реферальной ссылки для пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot_username: Имя бота
|
||||||
|
user: Объект пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Реферальная ссылка
|
||||||
|
"""
|
||||||
|
return self.utils.generate_referral_link(bot_username, user.profile_link)
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
async def is_user_blocked(self, blocker_id: int, blocked_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка, заблокирован ли пользователь
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blocker_id: ID пользователя, который блокирует
|
||||||
|
blocked_id: ID пользователя, которого блокируют
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если заблокирован, False иначе
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self.database.is_user_blocked(blocker_id, blocked_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при проверке блокировки {blocker_id} -> {blocked_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@log_business_event("block_user", log_params=True, log_result=True)
|
||||||
|
async def block_user(self, blocker_id: int, blocked_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Блокировка пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blocker_id: ID пользователя, который блокирует
|
||||||
|
blocked_id: ID пользователя, которого блокируют
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно заблокирован
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await self.database.block_user(blocker_id, blocked_id)
|
||||||
|
logger.info(f"Пользователь {blocked_id} заблокирован пользователем {blocker_id}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при блокировке пользователя {blocked_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@log_business_event("unblock_user", log_params=True, log_result=True)
|
||||||
|
async def unblock_user(self, blocker_id: int, blocked_id: int) -> bool:
|
||||||
|
"""
|
||||||
|
Разблокировка пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
blocker_id: ID пользователя, который разблокирует
|
||||||
|
blocked_id: ID пользователя, которого разблокируют
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если успешно разблокирован
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await self.database.unblock_user(blocker_id, blocked_id)
|
||||||
|
if result:
|
||||||
|
logger.info(f"Пользователь {blocked_id} разблокирован пользователем {blocker_id}")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при разблокировке пользователя {blocked_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@log_function_call(log_params=True, log_result=True)
|
||||||
|
async def get_blocked_users(self, user_id: int) -> list:
|
||||||
|
"""
|
||||||
|
Получение списка заблокированных пользователей
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список ID заблокированных пользователей
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await self.database.user_blocks.get_blocked_users(user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при получении заблокированных пользователей для {user_id}: {e}")
|
||||||
|
return []
|
||||||
31
services/infrastructure/__init__.py
Normal file
31
services/infrastructure/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Инфраструктурные сервисы
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .database import DatabaseService
|
||||||
|
from .logger import get_logger, setup_logging
|
||||||
|
from .metrics import MetricsService, get_metrics_service
|
||||||
|
from .pid_manager import PIDManager, get_pid_manager, cleanup_pid_file
|
||||||
|
from .logging_decorators import (
|
||||||
|
log_function_call, log_business_event, log_fsm_transition,
|
||||||
|
log_handler, log_service, log_business, log_fsm,
|
||||||
|
log_quiet, log_middleware, log_utility
|
||||||
|
)
|
||||||
|
from .logging_utils import (
|
||||||
|
LoggingContext, get_logging_context,
|
||||||
|
log_user_action, log_business_operation, log_fsm_event, log_performance,
|
||||||
|
log_question_created, log_question_answered, log_user_created, log_user_blocked
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'DatabaseService',
|
||||||
|
'get_logger', 'setup_logging',
|
||||||
|
'MetricsService', 'get_metrics_service',
|
||||||
|
'PIDManager', 'get_pid_manager', 'cleanup_pid_file',
|
||||||
|
'log_function_call', 'log_business_event', 'log_fsm_transition',
|
||||||
|
'log_handler', 'log_service', 'log_business', 'log_fsm',
|
||||||
|
'log_quiet', 'log_middleware', 'log_utility',
|
||||||
|
'LoggingContext', 'get_logging_context',
|
||||||
|
'log_user_action', 'log_business_operation', 'log_fsm_event', 'log_performance',
|
||||||
|
'log_question_created', 'log_question_answered', 'log_user_created', 'log_user_blocked'
|
||||||
|
]
|
||||||
255
services/infrastructure/database.py
Normal file
255
services/infrastructure/database.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""
|
||||||
|
Сервис для работы с базой данных SQLite
|
||||||
|
"""
|
||||||
|
import aiosqlite
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional, Dict, Any, Tuple
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from models.user import User
|
||||||
|
from models.question import Question, QuestionStatus
|
||||||
|
from models.user_block import UserBlock
|
||||||
|
from models.user_settings import UserSettings
|
||||||
|
from database.crud import UserCRUD, QuestionCRUD, UserBlockCRUD, UserSettingsCRUD
|
||||||
|
from .logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseService:
|
||||||
|
"""Сервис для работы с базой данных"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str):
|
||||||
|
self.db_path = db_path
|
||||||
|
# Инициализируем CRUD операции
|
||||||
|
self.users = UserCRUD(db_path)
|
||||||
|
self.questions = QuestionCRUD(db_path)
|
||||||
|
self.user_blocks = UserBlockCRUD(db_path)
|
||||||
|
self.user_settings = UserSettingsCRUD(db_path)
|
||||||
|
|
||||||
|
async def init(self):
|
||||||
|
"""Инициализация базы данных и создание таблиц"""
|
||||||
|
logger.info(f"💾 Инициализация базы данных: {self.db_path}")
|
||||||
|
# Создаем директорию для базы данных если её нет
|
||||||
|
db_path = Path(self.db_path)
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
async with self.get_connection() as conn:
|
||||||
|
await self._create_tables(conn)
|
||||||
|
logger.info("✅ База данных инициализирована")
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def get_connection(self):
|
||||||
|
"""Контекстный менеджер для подключения к БД с использованием пула"""
|
||||||
|
from database.crud import get_connection_pool
|
||||||
|
pool = get_connection_pool(self.db_path)
|
||||||
|
conn = await pool.get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
await pool.return_connection(conn)
|
||||||
|
|
||||||
|
async def _create_tables(self, conn: aiosqlite.Connection):
|
||||||
|
"""Создание таблиц в базе данных"""
|
||||||
|
# Проверяем, существуют ли уже таблицы
|
||||||
|
cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users';")
|
||||||
|
if await cursor.fetchone():
|
||||||
|
logger.info("📋 Таблицы уже существуют, пропускаем создание")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Читаем схему из файла
|
||||||
|
schema_path = Path(__file__).parent.parent / "database" / "schema.sql"
|
||||||
|
|
||||||
|
if schema_path.exists():
|
||||||
|
logger.info("📄 Создание таблиц из схемы")
|
||||||
|
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||||
|
schema_sql = f.read()
|
||||||
|
|
||||||
|
# Выполняем SQL схему
|
||||||
|
await conn.executescript(schema_sql)
|
||||||
|
await conn.commit()
|
||||||
|
logger.info("✅ Таблицы созданы из схемы")
|
||||||
|
else:
|
||||||
|
logger.warning("⚠️ Файл схемы не найден, создаем таблицы вручную")
|
||||||
|
await self._create_tables_manual(conn)
|
||||||
|
|
||||||
|
async def _create_tables_manual(self, conn: aiosqlite.Connection):
|
||||||
|
"""Создание таблиц вручную если схема не найдена"""
|
||||||
|
# Простая схема для совместимости
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
telegram_id INTEGER UNIQUE NOT NULL,
|
||||||
|
username TEXT,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT,
|
||||||
|
chat_id INTEGER NOT NULL,
|
||||||
|
profile_link TEXT UNIQUE NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
banned_until DATETIME,
|
||||||
|
ban_reason TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
await conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
from_user_id INTEGER,
|
||||||
|
to_user_id INTEGER NOT NULL,
|
||||||
|
message_text TEXT NOT NULL,
|
||||||
|
answer_text TEXT,
|
||||||
|
is_anonymous BOOLEAN DEFAULT TRUE,
|
||||||
|
message_id INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
answered_at DATETIME,
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
status TEXT DEFAULT 'pending'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
# Обертки для CRUD операций (для совместимости)
|
||||||
|
|
||||||
|
# Пользователи
|
||||||
|
async def create_user(self, user: User) -> User:
|
||||||
|
"""Создание нового пользователя"""
|
||||||
|
return await self.users.create(user)
|
||||||
|
|
||||||
|
async def create_users_batch(self, users: List[User]) -> List[User]:
|
||||||
|
"""Создание нескольких пользователей за одну транзакцию (batch операция)"""
|
||||||
|
return await self.users.create_batch(users)
|
||||||
|
|
||||||
|
async def get_user(self, telegram_id: int) -> Optional[User]:
|
||||||
|
"""Получение пользователя по Telegram ID"""
|
||||||
|
return await self.users.get_by_telegram_id(telegram_id)
|
||||||
|
|
||||||
|
async def get_user_by_profile_link(self, profile_link: str) -> Optional[User]:
|
||||||
|
"""Получение пользователя по ссылке профиля"""
|
||||||
|
return await self.users.get_by_profile_link(profile_link)
|
||||||
|
|
||||||
|
async def update_user(self, user: User) -> User:
|
||||||
|
"""Обновление пользователя"""
|
||||||
|
return await self.users.update(user)
|
||||||
|
|
||||||
|
async def get_all_users(self, limit: int = 100, offset: int = 0) -> List[User]:
|
||||||
|
"""Получение всех пользователей"""
|
||||||
|
return await self.users.get_all(limit, offset)
|
||||||
|
|
||||||
|
async def get_all_users_cursor(self, last_id: int, last_created_at: str,
|
||||||
|
limit: int, direction: str = "desc") -> List[User]:
|
||||||
|
"""Получение пользователей с cursor-based пагинацией"""
|
||||||
|
return await self.users.get_all_users_cursor(last_id, last_created_at, limit, direction)
|
||||||
|
|
||||||
|
async def get_all_users_asc(self, limit: int = 100, offset: int = 0) -> List[User]:
|
||||||
|
"""Получение всех пользователей в порядке возрастания"""
|
||||||
|
return await self.users.get_all_users_asc(limit, offset)
|
||||||
|
|
||||||
|
async def get_users_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Получение статистики пользователей"""
|
||||||
|
return await self.users.get_stats()
|
||||||
|
|
||||||
|
# Вопросы
|
||||||
|
async def create_question(self, question: Question) -> Question:
|
||||||
|
"""Создание нового вопроса"""
|
||||||
|
return await self.questions.create(question)
|
||||||
|
|
||||||
|
async def create_questions_batch(self, questions: List[Question]) -> List[Question]:
|
||||||
|
"""Создание нескольких вопросов за одну транзакцию (batch операция)"""
|
||||||
|
return await self.questions.create_batch(questions)
|
||||||
|
|
||||||
|
async def get_question(self, question_id: int) -> Optional[Question]:
|
||||||
|
"""Получение вопроса по ID"""
|
||||||
|
return await self.questions.get_by_id(question_id)
|
||||||
|
|
||||||
|
async def get_user_questions(self, user_id: int, status: Optional[QuestionStatus] = None,
|
||||||
|
limit: int = 50, offset: int = 0) -> List[Question]:
|
||||||
|
"""Получение вопросов пользователя"""
|
||||||
|
return await self.questions.get_by_to_user(user_id, status, limit, offset)
|
||||||
|
|
||||||
|
async def get_user_questions_with_authors(self, user_id: int, status: Optional[QuestionStatus] = None,
|
||||||
|
limit: int = 50, offset: int = 0) -> List[Tuple[Question, Optional[User]]]:
|
||||||
|
"""Получение вопросов пользователя с информацией об авторах (оптимизированный запрос)"""
|
||||||
|
return await self.questions.get_by_to_user_with_authors(user_id, status, limit, offset)
|
||||||
|
|
||||||
|
async def get_user_questions_cursor(self, user_id: int, last_id: int, last_created_at: str,
|
||||||
|
limit: int, direction: str = "desc") -> List[Question]:
|
||||||
|
"""Получение вопросов пользователя с cursor-based пагинацией"""
|
||||||
|
return await self.questions.get_by_to_user_cursor(user_id, last_id, last_created_at, limit, direction)
|
||||||
|
|
||||||
|
async def get_user_questions_asc(self, user_id: int, status: Optional[QuestionStatus] = None,
|
||||||
|
limit: int = 50, offset: int = 0) -> List[Question]:
|
||||||
|
"""Получение вопросов пользователя в порядке возрастания"""
|
||||||
|
return await self.questions.get_by_to_user_asc(user_id, status, limit, offset)
|
||||||
|
|
||||||
|
async def update_question(self, question: Question) -> Question:
|
||||||
|
"""Обновление вопроса"""
|
||||||
|
return await self.questions.update(question)
|
||||||
|
|
||||||
|
async def get_questions_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Получение статистики вопросов"""
|
||||||
|
return await self.questions.get_stats()
|
||||||
|
|
||||||
|
async def get_unread_questions_count(self, user_id: int) -> int:
|
||||||
|
"""Получение количества непрочитанных вопросов"""
|
||||||
|
return await self.questions.get_unread_count(user_id)
|
||||||
|
|
||||||
|
async def get_user_questions_count(self, user_id: int, status: Optional[QuestionStatus] = None) -> int:
|
||||||
|
"""Получение общего количества вопросов пользователя"""
|
||||||
|
return await self.questions.get_count_by_to_user(user_id, status)
|
||||||
|
|
||||||
|
# Блокировки
|
||||||
|
async def block_user(self, blocker_id: int, blocked_id: int) -> UserBlock:
|
||||||
|
"""Блокировка пользователя"""
|
||||||
|
user_block = UserBlock(
|
||||||
|
blocker_id=blocker_id,
|
||||||
|
blocked_id=blocked_id,
|
||||||
|
created_at=datetime.now()
|
||||||
|
)
|
||||||
|
return await self.user_blocks.create(user_block)
|
||||||
|
|
||||||
|
async def unblock_user(self, blocker_id: int, blocked_id: int) -> bool:
|
||||||
|
"""Разблокировка пользователя"""
|
||||||
|
return await self.user_blocks.delete(blocker_id, blocked_id)
|
||||||
|
|
||||||
|
async def is_user_blocked(self, blocker_id: int, blocked_id: int) -> bool:
|
||||||
|
"""Проверка, заблокирован ли пользователь"""
|
||||||
|
return await self.user_blocks.is_blocked(blocker_id, blocked_id)
|
||||||
|
|
||||||
|
# Настройки
|
||||||
|
async def get_user_settings(self, user_id: int) -> Optional[UserSettings]:
|
||||||
|
"""Получение настроек пользователя"""
|
||||||
|
return await self.user_settings.get_by_user_id(user_id)
|
||||||
|
|
||||||
|
async def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||||
|
"""Получение пользователя по ID (для получения информации об авторах вопросов)"""
|
||||||
|
return await self.users.get_by_telegram_id(user_id)
|
||||||
|
|
||||||
|
async def update_user_settings(self, settings: UserSettings) -> UserSettings:
|
||||||
|
"""Обновление настроек пользователя"""
|
||||||
|
return await self.user_settings.update(settings)
|
||||||
|
|
||||||
|
async def create_user_settings(self, settings: UserSettings) -> UserSettings:
|
||||||
|
"""Создание настроек пользователя"""
|
||||||
|
return await self.user_settings.create(settings)
|
||||||
|
|
||||||
|
async def check_connection(self):
|
||||||
|
"""Проверка соединения с базой данных"""
|
||||||
|
try:
|
||||||
|
async with self.get_connection() as conn:
|
||||||
|
# Выполняем простой запрос для проверки соединения
|
||||||
|
cursor = await conn.execute("SELECT 1")
|
||||||
|
await cursor.fetchone()
|
||||||
|
logger.debug("Database connection check successful")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Database connection check failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Закрытие соединения с БД"""
|
||||||
|
from database.crud import get_connection_pool
|
||||||
|
pool = get_connection_pool(self.db_path)
|
||||||
|
await pool.close_all()
|
||||||
350
services/infrastructure/http_server.py
Normal file
350
services/infrastructure/http_server.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""
|
||||||
|
HTTP сервер для эндпоинтов метрик и health check
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from aiohttp import ClientSession, web
|
||||||
|
from aiohttp.web import Request, Response
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from config.constants import DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT, APP_VERSION, HTTP_STATUS_OK, HTTP_STATUS_SERVICE_UNAVAILABLE, HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||||
|
from dependencies import get_database_service
|
||||||
|
from .metrics import get_metrics_service
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPServer:
|
||||||
|
"""HTTP сервер для метрик и health check"""
|
||||||
|
|
||||||
|
def __init__(self, host: str = DEFAULT_HTTP_HOST, port: int = DEFAULT_HTTP_PORT):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.app = web.Application()
|
||||||
|
self.metrics_service = get_metrics_service()
|
||||||
|
self.database_service = get_database_service()
|
||||||
|
self.start_time = time.time()
|
||||||
|
self._setup_routes()
|
||||||
|
|
||||||
|
def _setup_routes(self):
|
||||||
|
"""Настройка маршрутов"""
|
||||||
|
self.app.router.add_get('/metrics', self.metrics_handler)
|
||||||
|
self.app.router.add_get('/health', self.health_handler)
|
||||||
|
self.app.router.add_get('/ready', self.ready_handler)
|
||||||
|
self.app.router.add_get('/status', self.status_handler)
|
||||||
|
self.app.router.add_get('/', self.root_handler)
|
||||||
|
|
||||||
|
async def metrics_handler(self, request: Request) -> Response:
|
||||||
|
"""Обработчик эндпоинта /metrics"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Получаем метрики
|
||||||
|
metrics_data = self.metrics_service.get_metrics()
|
||||||
|
content_type = self.metrics_service.get_content_type()
|
||||||
|
|
||||||
|
# Записываем метрику HTTP запроса
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.metrics_service.record_http_request_duration("GET", "/metrics", duration)
|
||||||
|
self.metrics_service.increment_http_requests("GET", "/metrics", HTTP_STATUS_OK)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
text=metrics_data,
|
||||||
|
content_type=content_type,
|
||||||
|
status=HTTP_STATUS_OK
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in metrics handler: {e}")
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.metrics_service.record_http_request_duration("GET", "/metrics", duration)
|
||||||
|
self.metrics_service.increment_http_requests("GET", "/metrics", HTTP_STATUS_INTERNAL_SERVER_ERROR)
|
||||||
|
self.metrics_service.increment_errors(type(e).__name__, "metrics_handler")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
text="Internal Server Error",
|
||||||
|
status=HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_handler(self, request: Request) -> Response:
|
||||||
|
"""Обработчик эндпоинта /health"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Проверяем состояние сервисов
|
||||||
|
health_status = {
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"uptime": time.time() - self.start_time,
|
||||||
|
"version": APP_VERSION,
|
||||||
|
"services": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверяем базу данных
|
||||||
|
try:
|
||||||
|
await self.database_service.check_connection()
|
||||||
|
health_status["services"]["database"] = "healthy"
|
||||||
|
except Exception as e:
|
||||||
|
health_status["services"]["database"] = f"unhealthy: {str(e)}"
|
||||||
|
health_status["status"] = "unhealthy"
|
||||||
|
|
||||||
|
# Проверяем метрики
|
||||||
|
try:
|
||||||
|
self.metrics_service.get_metrics()
|
||||||
|
health_status["services"]["metrics"] = "healthy"
|
||||||
|
except Exception as e:
|
||||||
|
health_status["services"]["metrics"] = f"unhealthy: {str(e)}"
|
||||||
|
health_status["status"] = "unhealthy"
|
||||||
|
|
||||||
|
# Определяем HTTP статус
|
||||||
|
http_status = HTTP_STATUS_OK if health_status["status"] == "healthy" else HTTP_STATUS_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
# Записываем метрику HTTP запроса
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.metrics_service.record_http_request_duration("GET", "/health", duration)
|
||||||
|
self.metrics_service.increment_http_requests("GET", "/health", http_status)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
json=health_status,
|
||||||
|
status=http_status
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in health handler: {e}")
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.metrics_service.record_http_request_duration("GET", "/health", duration)
|
||||||
|
self.metrics_service.increment_http_requests("GET", "/health", 500)
|
||||||
|
self.metrics_service.increment_errors(type(e).__name__, "health_handler")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
json={"status": "error", "message": str(e)},
|
||||||
|
status=HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
async def ready_handler(self, request: Request) -> Response:
|
||||||
|
"""Обработчик эндпоинта /ready (readiness probe)"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Проверяем готовность сервисов
|
||||||
|
ready_status = {
|
||||||
|
"status": "ready",
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"services": {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Проверяем базу данных
|
||||||
|
try:
|
||||||
|
await self.database_service.check_connection()
|
||||||
|
ready_status["services"]["database"] = "ready"
|
||||||
|
except Exception as e:
|
||||||
|
ready_status["services"]["database"] = f"not_ready: {str(e)}"
|
||||||
|
ready_status["status"] = "not_ready"
|
||||||
|
|
||||||
|
# Определяем HTTP статус
|
||||||
|
http_status = HTTP_STATUS_OK if ready_status["status"] == "ready" else HTTP_STATUS_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
# Записываем метрику HTTP запроса
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.metrics_service.record_http_request_duration("GET", "/ready", duration)
|
||||||
|
self.metrics_service.increment_http_requests("GET", "/ready", http_status)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
json=ready_status,
|
||||||
|
status=http_status
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in ready handler: {e}")
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.metrics_service.record_http_request_duration("GET", "/ready", duration)
|
||||||
|
self.metrics_service.increment_http_requests("GET", "/ready", 500)
|
||||||
|
self.metrics_service.increment_errors(type(e).__name__, "ready_handler")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
json={"status": "error", "message": str(e)},
|
||||||
|
status=HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
async def status_handler(self, request: Request) -> Response:
|
||||||
|
"""Handle /status endpoint for process status information."""
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
# Получаем PID текущего процесса
|
||||||
|
current_pid = os.getpid()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Получаем информацию о процессе
|
||||||
|
process = psutil.Process(current_pid)
|
||||||
|
create_time = process.create_time()
|
||||||
|
uptime_seconds = time.time() - create_time
|
||||||
|
|
||||||
|
# Логируем для диагностики
|
||||||
|
import datetime
|
||||||
|
create_time_str = datetime.datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
current_time_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
logger.info(f"Process PID {current_pid}: created at {create_time_str}, current time {current_time_str}, uptime {uptime_seconds:.1f}s")
|
||||||
|
|
||||||
|
# Форматируем uptime
|
||||||
|
if uptime_seconds < 60:
|
||||||
|
uptime_str = f"{int(uptime_seconds)}с"
|
||||||
|
elif uptime_seconds < 3600:
|
||||||
|
minutes = int(uptime_seconds // 60)
|
||||||
|
uptime_str = f"{minutes}м"
|
||||||
|
elif uptime_seconds < 86400:
|
||||||
|
hours = int(uptime_seconds // 3600)
|
||||||
|
minutes = int((uptime_seconds % 3600) // 60)
|
||||||
|
uptime_str = f"{hours}ч {minutes}м"
|
||||||
|
else:
|
||||||
|
days = int(uptime_seconds // 86400)
|
||||||
|
hours = int((uptime_seconds % 86400) // 3600)
|
||||||
|
uptime_str = f"{days}д {hours}ч"
|
||||||
|
|
||||||
|
# Проверяем, что процесс активен
|
||||||
|
if process.is_running():
|
||||||
|
status = "running"
|
||||||
|
else:
|
||||||
|
status = "stopped"
|
||||||
|
|
||||||
|
# Формируем ответ
|
||||||
|
response_data = {
|
||||||
|
"status": status,
|
||||||
|
"pid": current_pid,
|
||||||
|
"uptime": uptime_str,
|
||||||
|
"memory_usage_mb": round(process.memory_info().rss / 1024 / 1024, 2),
|
||||||
|
"cpu_percent": process.cpu_percent(),
|
||||||
|
"timestamp": time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
import json
|
||||||
|
return Response(
|
||||||
|
text=json.dumps(response_data, ensure_ascii=False),
|
||||||
|
content_type='application/json',
|
||||||
|
status=200
|
||||||
|
)
|
||||||
|
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
# Процесс не найден
|
||||||
|
response_data = {
|
||||||
|
"status": "not_found",
|
||||||
|
"error": "Process not found",
|
||||||
|
"timestamp": time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
import json
|
||||||
|
return Response(
|
||||||
|
text=json.dumps(response_data, ensure_ascii=False),
|
||||||
|
content_type='application/json',
|
||||||
|
status=404
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Status check failed: {e}")
|
||||||
|
import json
|
||||||
|
response_data = {
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e),
|
||||||
|
"timestamp": time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
text=json.dumps(response_data, ensure_ascii=False),
|
||||||
|
content_type='application/json',
|
||||||
|
status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
async def root_handler(self, request: Request) -> Response:
|
||||||
|
"""Обработчик корневого эндпоинта"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = {
|
||||||
|
"service": "AnonBot",
|
||||||
|
"version": APP_VERSION,
|
||||||
|
"endpoints": {
|
||||||
|
"metrics": "/metrics",
|
||||||
|
"health": "/health",
|
||||||
|
"ready": "/ready",
|
||||||
|
"status": "/status"
|
||||||
|
},
|
||||||
|
"uptime": time.time() - self.start_time
|
||||||
|
}
|
||||||
|
|
||||||
|
# Записываем метрику HTTP запроса
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.metrics_service.record_http_request_duration("GET", "/", duration)
|
||||||
|
self.metrics_service.increment_http_requests("GET", "/", 200)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
json=info,
|
||||||
|
status=HTTP_STATUS_OK
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in root handler: {e}")
|
||||||
|
duration = time.time() - start_time
|
||||||
|
self.metrics_service.record_http_request_duration("GET", "/", duration)
|
||||||
|
self.metrics_service.increment_http_requests("GET", "/", 500)
|
||||||
|
self.metrics_service.increment_errors(type(e).__name__, "root_handler")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
json={"error": str(e)},
|
||||||
|
status=HTTP_STATUS_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Запуск HTTP сервера"""
|
||||||
|
try:
|
||||||
|
runner = web.AppRunner(self.app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, self.host, self.port)
|
||||||
|
await site.start()
|
||||||
|
|
||||||
|
logger.info(f"HTTP server started on {self.host}:{self.port}")
|
||||||
|
logger.info(f"Metrics endpoint: http://{self.host}:{self.port}/metrics")
|
||||||
|
logger.info(f"Health endpoint: http://{self.host}:{self.port}/health")
|
||||||
|
logger.info(f"Ready endpoint: http://{self.host}:{self.port}/ready")
|
||||||
|
logger.info(f"Status endpoint: http://{self.host}:{self.port}/status")
|
||||||
|
|
||||||
|
return runner
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start HTTP server: {e}")
|
||||||
|
self.metrics_service.increment_errors(type(e).__name__, "http_server")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def stop(self, runner: web.AppRunner):
|
||||||
|
"""Остановка HTTP сервера"""
|
||||||
|
try:
|
||||||
|
await runner.cleanup()
|
||||||
|
logger.info("HTTP server stopped")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping HTTP server: {e}")
|
||||||
|
self.metrics_service.increment_errors(type(e).__name__, "http_server")
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальный экземпляр HTTP сервера
|
||||||
|
_http_server: Optional[HTTPServer] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_http_server(host: str = DEFAULT_HTTP_HOST, port: int = DEFAULT_HTTP_PORT) -> HTTPServer:
|
||||||
|
"""Получить экземпляр HTTP сервера"""
|
||||||
|
global _http_server
|
||||||
|
if _http_server is None:
|
||||||
|
_http_server = HTTPServer(host, port)
|
||||||
|
return _http_server
|
||||||
|
|
||||||
|
|
||||||
|
async def start_http_server(host: str = DEFAULT_HTTP_HOST, port: int = DEFAULT_HTTP_PORT) -> web.AppRunner:
|
||||||
|
"""Запустить HTTP сервер"""
|
||||||
|
server = get_http_server(host, port)
|
||||||
|
return await server.start()
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_http_server(runner: web.AppRunner):
|
||||||
|
"""Остановить HTTP сервер"""
|
||||||
|
server = get_http_server()
|
||||||
|
await server.stop(runner)
|
||||||
83
services/infrastructure/logger.py
Normal file
83
services/infrastructure/logger.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
Настройка системы логирования с использованием loguru
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from loguru import logger
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
"""Настройка системы логирования"""
|
||||||
|
# Удаляем стандартный обработчик loguru
|
||||||
|
logger.remove()
|
||||||
|
|
||||||
|
# Настраиваем логирование в stderr для Docker
|
||||||
|
log_level = "DEBUG" if config.DEBUG else "INFO"
|
||||||
|
|
||||||
|
# Основной обработчик для stderr (для Docker)
|
||||||
|
logger.add(
|
||||||
|
sys.stderr,
|
||||||
|
level=log_level,
|
||||||
|
format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
||||||
|
colorize=True,
|
||||||
|
backtrace=True,
|
||||||
|
diagnose=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дополнительный обработчик для файла (опционально)
|
||||||
|
if config.DEBUG:
|
||||||
|
logger.add(
|
||||||
|
"logs/bot.log",
|
||||||
|
level="DEBUG",
|
||||||
|
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
||||||
|
rotation="10 MB",
|
||||||
|
retention="7 days",
|
||||||
|
compression="zip",
|
||||||
|
backtrace=True,
|
||||||
|
diagnose=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Настраиваем логирование для внешних библиотек
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Отключаем логирование aiogram по умолчанию
|
||||||
|
logging.getLogger("aiogram").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("aiohttp").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("aiosqlite").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
# Перенаправляем стандартное логирование в loguru
|
||||||
|
class InterceptHandler(logging.Handler):
|
||||||
|
def emit(self, record):
|
||||||
|
# Получаем соответствующий уровень loguru
|
||||||
|
try:
|
||||||
|
level = logger.level(record.levelname).name
|
||||||
|
except ValueError:
|
||||||
|
level = record.levelno
|
||||||
|
|
||||||
|
# Находим caller из логов
|
||||||
|
frame, depth = logging.currentframe(), 2
|
||||||
|
while frame.f_code.co_filename == logging.__file__:
|
||||||
|
frame = frame.f_back
|
||||||
|
depth += 1
|
||||||
|
|
||||||
|
logger.opt(depth=depth, exception=record.exc_info).log(
|
||||||
|
level, record.getMessage()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Подключаем перехватчик к корневому логгеру
|
||||||
|
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
|
||||||
|
|
||||||
|
logger.info("🔧 Система логирования loguru настроена")
|
||||||
|
logger.info(f"📊 Уровень логирования: {log_level}")
|
||||||
|
logger.info(f"🐳 Логи выводятся в stderr для Docker")
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str = None):
|
||||||
|
"""Получить логгер для модуля"""
|
||||||
|
if name:
|
||||||
|
return logger.bind(name=name)
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
# Инициализируем логирование при импорте
|
||||||
|
setup_logging()
|
||||||
274
services/infrastructure/logging_decorators.py
Normal file
274
services/infrastructure/logging_decorators.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
Декораторы для автоматического логирования функций
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Callable, Any, Optional, Dict, Union
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
def log_function_call(
|
||||||
|
function_name: Optional[str] = None,
|
||||||
|
log_params: bool = True,
|
||||||
|
log_result: bool = False,
|
||||||
|
log_level: str = "info",
|
||||||
|
quiet: bool = False
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Декоратор для автоматического логирования входа/выхода из функций
|
||||||
|
|
||||||
|
Args:
|
||||||
|
function_name: Кастомное имя функции для логов (по умолчанию берется из func.__name__)
|
||||||
|
log_params: Логировать ли параметры вызова
|
||||||
|
log_result: Логировать ли результат выполнения
|
||||||
|
log_level: Уровень логирования ('info', 'debug', 'warning')
|
||||||
|
quiet: Тихое логирование (только ошибки)
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
async def async_wrapper(*args, **kwargs):
|
||||||
|
logger = get_logger(func.__module__)
|
||||||
|
name = function_name or func.__name__
|
||||||
|
|
||||||
|
# Формируем контекстную информацию
|
||||||
|
context_info = _build_context_info(args, kwargs, log_params)
|
||||||
|
|
||||||
|
# Логируем вход в функцию (только если не тихий режим)
|
||||||
|
if not quiet:
|
||||||
|
log_method = getattr(logger, log_level)
|
||||||
|
log_method(f"🚀 Начало выполнения {name}{context_info}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Логируем успешное завершение (только если не тихий режим)
|
||||||
|
if not quiet:
|
||||||
|
result_info = ""
|
||||||
|
if log_result and result is not None:
|
||||||
|
result_info = f" | Результат: {_format_result(result)}"
|
||||||
|
|
||||||
|
log_method(f"✅ Успешное завершение {name}{result_info}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Логируем ошибку (всегда, даже в тихом режиме)
|
||||||
|
logger.error(f"❌ Ошибка в {name}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def sync_wrapper(*args, **kwargs):
|
||||||
|
logger = get_logger(func.__module__)
|
||||||
|
name = function_name or func.__name__
|
||||||
|
|
||||||
|
# Формируем контекстную информацию
|
||||||
|
context_info = _build_context_info(args, kwargs, log_params)
|
||||||
|
|
||||||
|
# Логируем вход в функцию (только если не тихий режим)
|
||||||
|
if not quiet:
|
||||||
|
log_method = getattr(logger, log_level)
|
||||||
|
log_method(f"🚀 Начало выполнения {name}{context_info}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Логируем успешное завершение (только если не тихий режим)
|
||||||
|
if not quiet:
|
||||||
|
result_info = ""
|
||||||
|
if log_result and result is not None:
|
||||||
|
result_info = f" | Результат: {_format_result(result)}"
|
||||||
|
|
||||||
|
log_method(f"✅ Успешное завершение {name}{result_info}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Логируем ошибку (всегда, даже в тихом режиме)
|
||||||
|
logger.error(f"❌ Ошибка в {name}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Возвращаем правильный wrapper в зависимости от типа функции
|
||||||
|
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def log_business_event(
|
||||||
|
event_name: str,
|
||||||
|
log_params: bool = True,
|
||||||
|
log_result: bool = True
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Декоратор для логирования бизнес-событий
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_name: Название бизнес-события
|
||||||
|
log_params: Логировать ли параметры
|
||||||
|
log_result: Логировать ли результат
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
async def async_wrapper(*args, **kwargs):
|
||||||
|
logger = get_logger(func.__module__)
|
||||||
|
|
||||||
|
# Формируем контекстную информацию
|
||||||
|
context_info = _build_context_info(args, kwargs, log_params)
|
||||||
|
|
||||||
|
# Логируем бизнес-событие
|
||||||
|
logger.info(f"📊 Бизнес-событие: {event_name}{context_info}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Логируем результат бизнес-события
|
||||||
|
if log_result and result is not None:
|
||||||
|
result_info = _format_result(result)
|
||||||
|
logger.info(f"📈 Результат {event_name}: {result_info}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"💥 Ошибка в бизнес-событии {event_name}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def sync_wrapper(*args, **kwargs):
|
||||||
|
logger = get_logger(func.__module__)
|
||||||
|
|
||||||
|
# Формируем контекстную информацию
|
||||||
|
context_info = _build_context_info(args, kwargs, log_params)
|
||||||
|
|
||||||
|
# Логируем бизнес-событие
|
||||||
|
logger.info(f"📊 Бизнес-событие: {event_name}{context_info}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Логируем результат бизнес-события
|
||||||
|
if log_result and result is not None:
|
||||||
|
result_info = _format_result(result)
|
||||||
|
logger.info(f"📈 Результат {event_name}: {result_info}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"💥 Ошибка в бизнес-событии {event_name}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def log_fsm_transition(
|
||||||
|
from_state: Optional[str] = None,
|
||||||
|
to_state: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Декоратор для логирования переходов FSM состояний
|
||||||
|
|
||||||
|
Args:
|
||||||
|
from_state: Исходное состояние (если None, будет определено автоматически)
|
||||||
|
to_state: Целевое состояние (если None, будет определено автоматически)
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
async def async_wrapper(*args, **kwargs):
|
||||||
|
logger = get_logger(func.__module__)
|
||||||
|
|
||||||
|
# Извлекаем FSM context из аргументов
|
||||||
|
fsm_context = None
|
||||||
|
for arg in args:
|
||||||
|
if hasattr(arg, 'get_state') and hasattr(arg, 'set_state'):
|
||||||
|
fsm_context = arg
|
||||||
|
break
|
||||||
|
|
||||||
|
# Логируем переход состояния
|
||||||
|
if fsm_context:
|
||||||
|
current_state = await fsm_context.get_state()
|
||||||
|
logger.info(f"🔄 FSM переход: {current_state} -> {to_state or 'новое состояние'}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Логируем успешный переход
|
||||||
|
if fsm_context:
|
||||||
|
new_state = await fsm_context.get_state()
|
||||||
|
logger.info(f"✅ FSM переход завершен: {from_state or 'предыдущее состояние'} -> {new_state}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка в FSM переходе: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return async_wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _build_context_info(args: tuple, kwargs: dict, log_params: bool) -> str:
|
||||||
|
"""Построение контекстной информации для логов"""
|
||||||
|
if not log_params:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
context_parts = []
|
||||||
|
|
||||||
|
# Извлекаем информацию о пользователе из аргументов
|
||||||
|
user_id = None
|
||||||
|
for arg in args:
|
||||||
|
if isinstance(arg, (Message, CallbackQuery)):
|
||||||
|
user_id = arg.from_user.id
|
||||||
|
context_parts.append(f"user_id={user_id}")
|
||||||
|
break
|
||||||
|
elif hasattr(arg, 'from_user_id'):
|
||||||
|
user_id = arg.from_user_id
|
||||||
|
context_parts.append(f"from_user_id={user_id}")
|
||||||
|
elif hasattr(arg, 'to_user_id'):
|
||||||
|
context_parts.append(f"to_user_id={arg.to_user_id}")
|
||||||
|
elif hasattr(arg, 'id') and isinstance(arg.id, int):
|
||||||
|
context_parts.append(f"id={arg.id}")
|
||||||
|
|
||||||
|
# Добавляем важные параметры из kwargs
|
||||||
|
important_params = ['question_id', 'user_id', 'page', 'limit', 'status']
|
||||||
|
for param in important_params:
|
||||||
|
if param in kwargs and kwargs[param] is not None:
|
||||||
|
context_parts.append(f"{param}={kwargs[param]}")
|
||||||
|
|
||||||
|
return f" | {', '.join(context_parts)}" if context_parts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _format_result(result: Any) -> str:
|
||||||
|
"""Форматирование результата для логов"""
|
||||||
|
if result is None:
|
||||||
|
return "None"
|
||||||
|
|
||||||
|
if isinstance(result, (str, int, float, bool)):
|
||||||
|
return str(result)
|
||||||
|
|
||||||
|
if hasattr(result, 'id'):
|
||||||
|
return f"id={result.id}"
|
||||||
|
|
||||||
|
if isinstance(result, (list, tuple)):
|
||||||
|
return f"count={len(result)}"
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return f"keys={list(result.keys())}"
|
||||||
|
|
||||||
|
return str(type(result).__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Удобные алиасы для часто используемых декораторов
|
||||||
|
log_handler = log_function_call
|
||||||
|
log_service = log_function_call
|
||||||
|
log_business = log_business_event
|
||||||
|
log_fsm = log_fsm_transition
|
||||||
|
|
||||||
|
# Тихие декораторы для middleware и служебных функций
|
||||||
|
log_quiet = lambda **kwargs: log_function_call(quiet=True, **kwargs)
|
||||||
|
log_middleware = lambda **kwargs: log_function_call(quiet=True, log_level="debug", **kwargs)
|
||||||
|
|
||||||
|
# Декоратор для служебных функций (только ошибки)
|
||||||
|
def log_utility(func: Callable) -> Callable:
|
||||||
|
"""Декоратор для служебных функций - логирует только ошибки"""
|
||||||
|
return log_function_call(quiet=True, log_params=False, log_result=False)(func)
|
||||||
227
services/infrastructure/logging_utils.py
Normal file
227
services/infrastructure/logging_utils.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
Утилиты для контекстного логирования
|
||||||
|
"""
|
||||||
|
from typing import Any, Optional, Dict, Union
|
||||||
|
from aiogram.types import Message, CallbackQuery, User
|
||||||
|
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingContext:
|
||||||
|
"""Контекст для логирования с дополнительной информацией"""
|
||||||
|
|
||||||
|
def __init__(self, module_name: str):
|
||||||
|
self.logger = get_logger(module_name)
|
||||||
|
self.context_data = {}
|
||||||
|
|
||||||
|
def add_context(self, key: str, value: Any) -> 'LoggingContext':
|
||||||
|
"""Добавить данные в контекст"""
|
||||||
|
self.context_data[key] = value
|
||||||
|
return self
|
||||||
|
|
||||||
|
def log_info(self, message: str, **kwargs):
|
||||||
|
"""Логирование с контекстом"""
|
||||||
|
context_str = self._format_context()
|
||||||
|
full_message = f"{message}{context_str}"
|
||||||
|
self.logger.info(full_message, **kwargs)
|
||||||
|
|
||||||
|
def log_warning(self, message: str, **kwargs):
|
||||||
|
"""Логирование предупреждения с контекстом"""
|
||||||
|
context_str = self._format_context()
|
||||||
|
full_message = f"{message}{context_str}"
|
||||||
|
self.logger.warning(full_message, **kwargs)
|
||||||
|
|
||||||
|
def log_error(self, message: str, **kwargs):
|
||||||
|
"""Логирование ошибки с контекстом"""
|
||||||
|
context_str = self._format_context()
|
||||||
|
full_message = f"{message}{context_str}"
|
||||||
|
self.logger.error(full_message, **kwargs)
|
||||||
|
|
||||||
|
def _format_context(self) -> str:
|
||||||
|
"""Форматирование контекстных данных"""
|
||||||
|
if not self.context_data:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
context_parts = [f"{k}={v}" for k, v in self.context_data.items()]
|
||||||
|
return f" | {', '.join(context_parts)}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_logging_context(module_name: str) -> LoggingContext:
|
||||||
|
"""Получить контекст логирования для модуля"""
|
||||||
|
return LoggingContext(module_name)
|
||||||
|
|
||||||
|
|
||||||
|
def log_user_action(
|
||||||
|
logger,
|
||||||
|
action: str,
|
||||||
|
user: Union[User, Message, CallbackQuery, int],
|
||||||
|
additional_info: Optional[Dict[str, Any]] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Логирование действий пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Логгер
|
||||||
|
action: Действие пользователя
|
||||||
|
user: Объект пользователя, сообщение, callback или user_id
|
||||||
|
additional_info: Дополнительная информация
|
||||||
|
"""
|
||||||
|
user_id = _extract_user_id(user)
|
||||||
|
user_info = _extract_user_info(user)
|
||||||
|
|
||||||
|
context_parts = [f"user_id={user_id}"]
|
||||||
|
if user_info:
|
||||||
|
context_parts.append(f"user_info={user_info}")
|
||||||
|
|
||||||
|
if additional_info:
|
||||||
|
for key, value in additional_info.items():
|
||||||
|
context_parts.append(f"{key}={value}")
|
||||||
|
|
||||||
|
context_str = f" | {', '.join(context_parts)}" if context_parts else ""
|
||||||
|
logger.info(f"👤 {action}{context_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_business_operation(
|
||||||
|
logger,
|
||||||
|
operation: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: Optional[Union[int, str]] = None,
|
||||||
|
additional_info: Optional[Dict[str, Any]] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Логирование бизнес-операций
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Логгер
|
||||||
|
operation: Операция (create, update, delete, etc.)
|
||||||
|
entity_type: Тип сущности (question, user, etc.)
|
||||||
|
entity_id: ID сущности
|
||||||
|
additional_info: Дополнительная информация
|
||||||
|
"""
|
||||||
|
context_parts = [f"operation={operation}", f"entity_type={entity_type}"]
|
||||||
|
|
||||||
|
if entity_id is not None:
|
||||||
|
context_parts.append(f"entity_id={entity_id}")
|
||||||
|
|
||||||
|
if additional_info:
|
||||||
|
for key, value in additional_info.items():
|
||||||
|
context_parts.append(f"{key}={value}")
|
||||||
|
|
||||||
|
context_str = f" | {', '.join(context_parts)}"
|
||||||
|
logger.info(f"📊 Бизнес-операция: {operation} {entity_type}{context_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_fsm_event(
|
||||||
|
logger,
|
||||||
|
event: str,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
additional_info: Optional[Dict[str, Any]] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Логирование FSM событий
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Логгер
|
||||||
|
event: Событие FSM
|
||||||
|
state: Текущее состояние
|
||||||
|
user_id: ID пользователя
|
||||||
|
additional_info: Дополнительная информация
|
||||||
|
"""
|
||||||
|
context_parts = [f"event={event}"]
|
||||||
|
|
||||||
|
if state:
|
||||||
|
context_parts.append(f"state={state}")
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
context_parts.append(f"user_id={user_id}")
|
||||||
|
|
||||||
|
if additional_info:
|
||||||
|
for key, value in additional_info.items():
|
||||||
|
context_parts.append(f"{key}={value}")
|
||||||
|
|
||||||
|
context_str = f" | {', '.join(context_parts)}"
|
||||||
|
logger.info(f"🔄 FSM: {event}{context_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def log_performance(
|
||||||
|
logger,
|
||||||
|
operation: str,
|
||||||
|
duration: float,
|
||||||
|
additional_info: Optional[Dict[str, Any]] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Логирование производительности
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Логгер
|
||||||
|
operation: Операция
|
||||||
|
duration: Время выполнения в секундах
|
||||||
|
additional_info: Дополнительная информация
|
||||||
|
"""
|
||||||
|
context_parts = [f"duration={duration:.3f}s"]
|
||||||
|
|
||||||
|
if additional_info:
|
||||||
|
for key, value in additional_info.items():
|
||||||
|
context_parts.append(f"{key}={value}")
|
||||||
|
|
||||||
|
context_str = f" | {', '.join(context_parts)}"
|
||||||
|
logger.info(f"⏱️ Производительность: {operation}{context_str}")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_user_id(user: Union[User, Message, CallbackQuery, int]) -> int:
|
||||||
|
"""Извлечение user_id из различных объектов"""
|
||||||
|
if isinstance(user, int):
|
||||||
|
return user
|
||||||
|
elif isinstance(user, User):
|
||||||
|
return user.id
|
||||||
|
elif isinstance(user, (Message, CallbackQuery)):
|
||||||
|
return user.from_user.id
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_user_info(user: Union[User, Message, CallbackQuery, int]) -> Optional[str]:
|
||||||
|
"""Извлечение информации о пользователе"""
|
||||||
|
if isinstance(user, int):
|
||||||
|
return None
|
||||||
|
elif isinstance(user, User):
|
||||||
|
return f"{user.first_name or ''} {user.last_name or ''}".strip() or user.username or "Unknown"
|
||||||
|
elif isinstance(user, (Message, CallbackQuery)):
|
||||||
|
user_obj = user.from_user
|
||||||
|
return f"{user_obj.first_name or ''} {user_obj.last_name or ''}".strip() or user_obj.username or "Unknown"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Удобные функции для быстрого логирования
|
||||||
|
def log_question_created(logger, question_id: int, from_user_id: int, to_user_id: int):
|
||||||
|
"""Логирование создания вопроса"""
|
||||||
|
log_business_operation(
|
||||||
|
logger, "create", "question", question_id,
|
||||||
|
{"from_user_id": from_user_id, "to_user_id": to_user_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_question_answered(logger, question_id: int, user_id: int):
|
||||||
|
"""Логирование ответа на вопрос"""
|
||||||
|
log_business_operation(
|
||||||
|
logger, "answer", "question", question_id,
|
||||||
|
{"user_id": user_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_user_created(logger, user_id: int, username: Optional[str] = None):
|
||||||
|
"""Логирование создания пользователя"""
|
||||||
|
additional_info = {"username": username} if username else None
|
||||||
|
log_business_operation(
|
||||||
|
logger, "create", "user", user_id, additional_info
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_user_blocked(logger, user_id: int, reason: Optional[str] = None):
|
||||||
|
"""Логирование блокировки пользователя"""
|
||||||
|
additional_info = {"reason": reason} if reason else None
|
||||||
|
log_business_operation(
|
||||||
|
logger, "block", "user", user_id, additional_info
|
||||||
|
)
|
||||||
351
services/infrastructure/metrics.py
Normal file
351
services/infrastructure/metrics.py
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
"""
|
||||||
|
Сервис для работы с Prometheus метриками
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import inspect
|
||||||
|
from typing import Optional, Callable
|
||||||
|
from prometheus_client import Counter, Histogram, Gauge, Info, generate_latest, CONTENT_TYPE_LATEST
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsService:
|
||||||
|
"""Сервис для управления Prometheus метриками"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._init_metrics()
|
||||||
|
|
||||||
|
def _init_metrics(self):
|
||||||
|
"""Инициализация метрик"""
|
||||||
|
|
||||||
|
# Информация о боте
|
||||||
|
self.bot_info = Info('anon_bot_info', 'Information about the AnonBot')
|
||||||
|
self.bot_info.info({
|
||||||
|
'version': '1.0.0',
|
||||||
|
'service': 'anon-bot'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Счетчики сообщений
|
||||||
|
self.messages_total = Counter(
|
||||||
|
'anon_bot_messages_total',
|
||||||
|
'Total number of messages processed',
|
||||||
|
['message_type', 'status']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Счетчики вопросов
|
||||||
|
self.questions_total = Counter(
|
||||||
|
'anon_bot_questions_total',
|
||||||
|
'Total number of questions received',
|
||||||
|
['status']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Счетчики ответов
|
||||||
|
self.answers_total = Counter(
|
||||||
|
'anon_bot_answers_total',
|
||||||
|
'Total number of answers sent',
|
||||||
|
['status']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Счетчики пользователей
|
||||||
|
self.users_total = Counter(
|
||||||
|
'anon_bot_users_total',
|
||||||
|
'Total number of users',
|
||||||
|
['action']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Время обработки сообщений
|
||||||
|
self.message_processing_time = Histogram(
|
||||||
|
'anon_bot_message_processing_seconds',
|
||||||
|
'Time spent processing messages',
|
||||||
|
['message_type'],
|
||||||
|
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Время обработки вопросов
|
||||||
|
self.question_processing_time = Histogram(
|
||||||
|
'anon_bot_question_processing_seconds',
|
||||||
|
'Time spent processing questions',
|
||||||
|
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Время обработки ответов
|
||||||
|
self.answer_processing_time = Histogram(
|
||||||
|
'anon_bot_answer_processing_seconds',
|
||||||
|
'Time spent processing answers',
|
||||||
|
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Активные пользователи
|
||||||
|
self.active_users = Gauge(
|
||||||
|
'anon_bot_active_users',
|
||||||
|
'Number of active users'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Активные вопросы
|
||||||
|
self.active_questions = Gauge(
|
||||||
|
'anon_bot_active_questions',
|
||||||
|
'Number of active questions'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ошибки
|
||||||
|
self.errors_total = Counter(
|
||||||
|
'anon_bot_errors_total',
|
||||||
|
'Total number of errors',
|
||||||
|
['error_type', 'component']
|
||||||
|
)
|
||||||
|
|
||||||
|
# HTTP запросы к эндпоинтам
|
||||||
|
self.http_requests_total = Counter(
|
||||||
|
'anon_bot_http_requests_total',
|
||||||
|
'Total number of HTTP requests',
|
||||||
|
['method', 'endpoint', 'status_code']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Метрики производительности БД
|
||||||
|
self.db_queries_total = Counter(
|
||||||
|
'anon_bot_db_queries_total',
|
||||||
|
'Total number of database queries',
|
||||||
|
['operation', 'table', 'status']
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db_query_duration = Histogram(
|
||||||
|
'anon_bot_db_query_duration_seconds',
|
||||||
|
'Database query duration',
|
||||||
|
['operation', 'table'],
|
||||||
|
buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db_connections_active = Gauge(
|
||||||
|
'anon_bot_db_connections_active',
|
||||||
|
'Number of active database connections'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db_connections_total = Counter(
|
||||||
|
'anon_bot_db_connections_total',
|
||||||
|
'Total number of database connections',
|
||||||
|
['status']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Метрики пагинации
|
||||||
|
self.pagination_requests_total = Counter(
|
||||||
|
'anon_bot_pagination_requests_total',
|
||||||
|
'Total number of pagination requests',
|
||||||
|
['entity_type', 'method']
|
||||||
|
)
|
||||||
|
|
||||||
|
self.pagination_duration = Histogram(
|
||||||
|
'anon_bot_pagination_duration_seconds',
|
||||||
|
'Pagination operation duration',
|
||||||
|
['entity_type', 'method'],
|
||||||
|
buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.pagination_errors_total = Counter(
|
||||||
|
'anon_bot_pagination_errors_total',
|
||||||
|
'Total number of pagination errors',
|
||||||
|
['entity_type', 'error_type']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Метрики batch операций
|
||||||
|
self.batch_operations_total = Counter(
|
||||||
|
'anon_bot_batch_operations_total',
|
||||||
|
'Total number of batch operations',
|
||||||
|
['operation', 'table', 'status']
|
||||||
|
)
|
||||||
|
|
||||||
|
self.batch_operation_duration = Histogram(
|
||||||
|
'anon_bot_batch_operation_duration_seconds',
|
||||||
|
'Batch operation duration',
|
||||||
|
['operation', 'table'],
|
||||||
|
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.batch_operation_size = Histogram(
|
||||||
|
'anon_bot_batch_operation_size',
|
||||||
|
'Batch operation size (number of items)',
|
||||||
|
['operation', 'table'],
|
||||||
|
buckets=[1, 5, 10, 25, 50, 100, 250, 500, 1000]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Время ответа HTTP эндпоинтов
|
||||||
|
self.http_request_duration = Histogram(
|
||||||
|
'anon_bot_http_request_duration_seconds',
|
||||||
|
'HTTP request duration',
|
||||||
|
['method', 'endpoint'],
|
||||||
|
buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Prometheus metrics initialized")
|
||||||
|
|
||||||
|
def increment_messages(self, message_type: str, status: str = "success"):
|
||||||
|
"""Увеличить счетчик сообщений"""
|
||||||
|
self.messages_total.labels(message_type=message_type, status=status).inc()
|
||||||
|
|
||||||
|
def increment_questions(self, status: str = "received"):
|
||||||
|
"""Увеличить счетчик вопросов"""
|
||||||
|
self.questions_total.labels(status=status).inc()
|
||||||
|
|
||||||
|
def increment_answers(self, status: str = "sent"):
|
||||||
|
"""Увеличить счетчик ответов"""
|
||||||
|
self.answers_total.labels(status=status).inc()
|
||||||
|
|
||||||
|
def increment_users(self, action: str):
|
||||||
|
"""Увеличить счетчик пользователей"""
|
||||||
|
self.users_total.labels(action=action).inc()
|
||||||
|
|
||||||
|
def increment_errors(self, error_type: str, component: str):
|
||||||
|
"""Увеличить счетчик ошибок"""
|
||||||
|
self.errors_total.labels(error_type=error_type, component=component).inc()
|
||||||
|
|
||||||
|
def increment_http_requests(self, method: str, endpoint: str, status_code: int):
|
||||||
|
"""Увеличить счетчик HTTP запросов"""
|
||||||
|
self.http_requests_total.labels(
|
||||||
|
method=method,
|
||||||
|
endpoint=endpoint,
|
||||||
|
status_code=status_code
|
||||||
|
).inc()
|
||||||
|
|
||||||
|
def set_active_users(self, count: int):
|
||||||
|
"""Установить количество активных пользователей"""
|
||||||
|
self.active_users.set(count)
|
||||||
|
|
||||||
|
def set_active_questions(self, count: int):
|
||||||
|
"""Установить количество активных вопросов"""
|
||||||
|
self.active_questions.set(count)
|
||||||
|
|
||||||
|
def record_message_processing_time(self, message_type: str, duration: float):
|
||||||
|
"""Записать время обработки сообщения"""
|
||||||
|
self.message_processing_time.labels(message_type=message_type).observe(duration)
|
||||||
|
|
||||||
|
def record_question_processing_time(self, duration: float):
|
||||||
|
"""Записать время обработки вопроса"""
|
||||||
|
self.question_processing_time.observe(duration)
|
||||||
|
|
||||||
|
def record_answer_processing_time(self, duration: float):
|
||||||
|
"""Записать время обработки ответа"""
|
||||||
|
self.answer_processing_time.observe(duration)
|
||||||
|
|
||||||
|
def record_http_request_duration(self, method: str, endpoint: str, duration: float):
|
||||||
|
"""Записать время обработки HTTP запроса"""
|
||||||
|
self.http_request_duration.labels(method=method, endpoint=endpoint).observe(duration)
|
||||||
|
|
||||||
|
# Методы для метрик БД
|
||||||
|
def record_db_query(self, operation: str, table: str, status: str, duration: float):
|
||||||
|
"""Записать метрики запроса к БД"""
|
||||||
|
self.db_queries_total.labels(operation=operation, table=table, status=status).inc()
|
||||||
|
self.db_query_duration.labels(operation=operation, table=table).observe(duration)
|
||||||
|
|
||||||
|
def record_db_connection(self, status: str):
|
||||||
|
"""Записать метрики подключения к БД"""
|
||||||
|
self.db_connections_total.labels(status=status).inc()
|
||||||
|
if status == "opened":
|
||||||
|
self.db_connections_active.inc()
|
||||||
|
elif status == "closed":
|
||||||
|
self.db_connections_active.dec()
|
||||||
|
|
||||||
|
def record_pagination_time(self, entity_type: str, duration: float, method: str = "cursor"):
|
||||||
|
"""Записать время пагинации"""
|
||||||
|
self.pagination_requests_total.labels(entity_type=entity_type, method=method).inc()
|
||||||
|
self.pagination_duration.labels(entity_type=entity_type, method=method).observe(duration)
|
||||||
|
|
||||||
|
def increment_pagination_requests(self, entity_type: str, method: str = "cursor"):
|
||||||
|
"""Увеличить счетчик запросов пагинации"""
|
||||||
|
self.pagination_requests_total.labels(entity_type=entity_type, method=method).inc()
|
||||||
|
|
||||||
|
def increment_pagination_errors(self, entity_type: str, error_type: str = "unknown"):
|
||||||
|
"""Увеличить счетчик ошибок пагинации"""
|
||||||
|
self.pagination_errors_total.labels(entity_type=entity_type, error_type=error_type).inc()
|
||||||
|
|
||||||
|
def record_batch_operation(self, operation: str, table: str, status: str, duration: float, size: int):
|
||||||
|
"""Записать метрики batch операции"""
|
||||||
|
self.batch_operations_total.labels(operation=operation, table=table, status=status).inc()
|
||||||
|
self.batch_operation_duration.labels(operation=operation, table=table).observe(duration)
|
||||||
|
self.batch_operation_size.labels(operation=operation, table=table).observe(size)
|
||||||
|
|
||||||
|
def get_metrics(self) -> str:
|
||||||
|
"""Получить метрики в формате Prometheus"""
|
||||||
|
return generate_latest()
|
||||||
|
|
||||||
|
def get_content_type(self) -> str:
|
||||||
|
"""Получить Content-Type для метрик"""
|
||||||
|
return CONTENT_TYPE_LATEST
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальный экземпляр сервиса метрик
|
||||||
|
metrics_service = MetricsService()
|
||||||
|
|
||||||
|
|
||||||
|
def get_metrics_service() -> MetricsService:
|
||||||
|
"""Получить экземпляр сервиса метрик"""
|
||||||
|
return metrics_service
|
||||||
|
|
||||||
|
|
||||||
|
# Декораторы для автоматического сбора метрик
|
||||||
|
def track_message_processing(message_type: str):
|
||||||
|
"""Декоратор для отслеживания обработки сообщений"""
|
||||||
|
def decorator(func):
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Убираем dispatcher, если он есть, так как он не нужен
|
||||||
|
kwargs.pop('dispatcher', None)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
metrics_service.increment_messages(message_type, "success")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
metrics_service.increment_messages(message_type, "error")
|
||||||
|
metrics_service.increment_errors(type(e).__name__, "message_processing")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
metrics_service.record_message_processing_time(message_type, duration)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def track_question_processing():
|
||||||
|
"""Декоратор для отслеживания обработки вопросов"""
|
||||||
|
def decorator(func):
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Убираем dispatcher, если он есть, так как он не нужен
|
||||||
|
kwargs.pop('dispatcher', None)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
metrics_service.increment_questions("processed")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
metrics_service.increment_questions("error")
|
||||||
|
metrics_service.increment_errors(type(e).__name__, "question_processing")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
metrics_service.record_question_processing_time(duration)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def track_answer_processing():
|
||||||
|
"""Декоратор для отслеживания обработки ответов"""
|
||||||
|
def decorator(func):
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Убираем dispatcher, если он есть, так как он не нужен
|
||||||
|
kwargs.pop('dispatcher', None)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
metrics_service.increment_answers("sent")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
metrics_service.increment_answers("error")
|
||||||
|
metrics_service.increment_errors(type(e).__name__, "answer_processing")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
metrics_service.record_answer_processing_time(duration)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
117
services/infrastructure/pid_manager.py
Normal file
117
services/infrastructure/pid_manager.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
PID менеджер для управления PID файлом процесса
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
class PIDManager:
|
||||||
|
"""Менеджер для управления PID файлом процесса"""
|
||||||
|
|
||||||
|
def __init__(self, service_name: str = "anon_bot", pid_dir: str = "/tmp"):
|
||||||
|
self.service_name = service_name
|
||||||
|
self.pid_dir = Path(pid_dir)
|
||||||
|
self.pid_file_path = self.pid_dir / f"{service_name}.pid"
|
||||||
|
self.pid: Optional[int] = None
|
||||||
|
|
||||||
|
def create_pid_file(self) -> bool:
|
||||||
|
"""Создать PID файл"""
|
||||||
|
try:
|
||||||
|
# Создаем директорию для PID файлов, если она не существует
|
||||||
|
self.pid_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Проверяем, не запущен ли уже процесс
|
||||||
|
if self.pid_file_path.exists():
|
||||||
|
try:
|
||||||
|
with open(self.pid_file_path, 'r') as f:
|
||||||
|
existing_pid = int(f.read().strip())
|
||||||
|
|
||||||
|
# Проверяем, жив ли процесс с этим PID
|
||||||
|
if self._is_process_running(existing_pid):
|
||||||
|
logger.error(f"Процесс {self.service_name} уже запущен с PID {existing_pid}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.warning(f"Найден устаревший PID файл для {existing_pid}, удаляем его")
|
||||||
|
self.pid_file_path.unlink()
|
||||||
|
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
logger.warning(f"Не удалось прочитать существующий PID файл: {e}, удаляем его")
|
||||||
|
self.pid_file_path.unlink()
|
||||||
|
|
||||||
|
# Получаем PID текущего процесса
|
||||||
|
self.pid = os.getpid()
|
||||||
|
|
||||||
|
# Создаем PID файл
|
||||||
|
with open(self.pid_file_path, 'w') as f:
|
||||||
|
f.write(str(self.pid))
|
||||||
|
|
||||||
|
logger.info(f"PID файл создан: {self.pid_file_path} (PID: {self.pid})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Не удалось создать PID файл: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cleanup_pid_file(self) -> None:
|
||||||
|
"""Очистить PID файл"""
|
||||||
|
try:
|
||||||
|
if self.pid_file_path.exists():
|
||||||
|
# Проверяем, что PID файл принадлежит нашему процессу
|
||||||
|
with open(self.pid_file_path, 'r') as f:
|
||||||
|
file_pid = int(f.read().strip())
|
||||||
|
|
||||||
|
if file_pid == self.pid:
|
||||||
|
self.pid_file_path.unlink()
|
||||||
|
logger.info(f"PID файл удален: {self.pid_file_path}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"PID файл содержит другой PID ({file_pid}), не удаляем")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при удалении PID файла: {e}")
|
||||||
|
|
||||||
|
def get_pid(self) -> Optional[int]:
|
||||||
|
"""Получить PID процесса"""
|
||||||
|
return self.pid
|
||||||
|
|
||||||
|
def get_pid_file_path(self) -> Path:
|
||||||
|
"""Получить путь к PID файлу"""
|
||||||
|
return self.pid_file_path
|
||||||
|
|
||||||
|
|
||||||
|
def _is_process_running(self, pid: int) -> bool:
|
||||||
|
"""Проверить, запущен ли процесс с указанным PID"""
|
||||||
|
try:
|
||||||
|
# В Unix-системах отправляем сигнал 0 для проверки существования процесса
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except (OSError, ProcessLookupError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Глобальный экземпляр PID менеджера
|
||||||
|
_pid_manager: Optional[PIDManager] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pid_manager(service_name: str = "anon_bot", pid_dir: str = "/tmp") -> PIDManager:
|
||||||
|
"""Получить экземпляр PID менеджера"""
|
||||||
|
global _pid_manager
|
||||||
|
if _pid_manager is None:
|
||||||
|
_pid_manager = PIDManager(service_name, pid_dir)
|
||||||
|
return _pid_manager
|
||||||
|
|
||||||
|
|
||||||
|
def create_pid_file(service_name: str = "anon_bot", pid_dir: str = "/tmp") -> bool:
|
||||||
|
"""Создать PID файл"""
|
||||||
|
pid_manager = get_pid_manager(service_name, pid_dir)
|
||||||
|
return pid_manager.create_pid_file()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_pid_file() -> None:
|
||||||
|
"""Очистить PID файл"""
|
||||||
|
if _pid_manager:
|
||||||
|
_pid_manager.cleanup_pid_file()
|
||||||
20
services/permissions/__init__.py
Normal file
20
services/permissions/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""
|
||||||
|
Система разрешений для AnonBot
|
||||||
|
Соблюдает принцип открытости/закрытости (OCP)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import Permission, PermissionChecker, PermissionRegistry
|
||||||
|
from .decorators import require_permission, require_admin, require_superuser
|
||||||
|
from .registry import get_permission_registry, get_permission_checker, init_permission_checker
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'Permission',
|
||||||
|
'PermissionChecker',
|
||||||
|
'PermissionRegistry',
|
||||||
|
'require_permission',
|
||||||
|
'require_admin',
|
||||||
|
'require_superuser',
|
||||||
|
'get_permission_registry',
|
||||||
|
'get_permission_checker',
|
||||||
|
'init_permission_checker'
|
||||||
|
]
|
||||||
165
services/permissions/base.py
Normal file
165
services/permissions/base.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Базовые классы для системы разрешений
|
||||||
|
"""
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, Type, Optional, Any
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(ABC):
|
||||||
|
"""
|
||||||
|
Абстрактный базовый класс для всех разрешений.
|
||||||
|
Соблюдает принцип открытости/закрытости (OCP).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str, description: str = ""):
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config: Any) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка разрешения для пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
database: Сервис базы данных
|
||||||
|
config: Конфигурация приложения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если у пользователя есть разрешение, False иначе
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Permission({self.name})"
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Permission(name='{self.name}', description='{self.description}')"
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionRegistry:
|
||||||
|
"""
|
||||||
|
Реестр разрешений. Позволяет регистрировать и получать разрешения.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._permissions: Dict[str, Permission] = {}
|
||||||
|
|
||||||
|
def register(self, permission: Permission) -> None:
|
||||||
|
"""
|
||||||
|
Регистрация разрешения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
permission: Разрешение для регистрации
|
||||||
|
"""
|
||||||
|
if permission.name in self._permissions:
|
||||||
|
logger.warning(f"Разрешение '{permission.name}' уже зарегистрировано. Перезаписываем.")
|
||||||
|
|
||||||
|
self._permissions[permission.name] = permission
|
||||||
|
logger.debug(f"Зарегистрировано разрешение: {permission}")
|
||||||
|
|
||||||
|
def get(self, name: str) -> Optional[Permission]:
|
||||||
|
"""
|
||||||
|
Получение разрешения по имени
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Имя разрешения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Разрешение или None если не найдено
|
||||||
|
"""
|
||||||
|
return self._permissions.get(name)
|
||||||
|
|
||||||
|
def get_all(self) -> Dict[str, Permission]:
|
||||||
|
"""
|
||||||
|
Получение всех зарегистрированных разрешений
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь всех разрешений
|
||||||
|
"""
|
||||||
|
return self._permissions.copy()
|
||||||
|
|
||||||
|
def is_registered(self, name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка, зарегистрировано ли разрешение
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Имя разрешения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если разрешение зарегистрировано, False иначе
|
||||||
|
"""
|
||||||
|
return name in self._permissions
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionChecker:
|
||||||
|
"""
|
||||||
|
Сервис для проверки разрешений пользователей.
|
||||||
|
Использует реестр разрешений для получения логики проверки.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, registry: PermissionRegistry, database: DatabaseService, config: Any):
|
||||||
|
self.registry = registry
|
||||||
|
self.database = database
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
async def has_permission(self, user_id: int, permission_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка наличия разрешения у пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
permission_name: Имя разрешения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если у пользователя есть разрешение, False иначе
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
permission = self.registry.get(permission_name)
|
||||||
|
if not permission:
|
||||||
|
logger.warning(f"Разрешение '{permission_name}' не найдено в реестре")
|
||||||
|
return False
|
||||||
|
|
||||||
|
result = await permission.check(user_id, self.database, self.config)
|
||||||
|
logger.debug(f"Проверка разрешения '{permission_name}' для пользователя {user_id}: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при проверке разрешения '{permission_name}' для пользователя {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def has_any_permission(self, user_id: int, permission_names: list[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка наличия хотя бы одного из разрешений у пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
permission_names: Список имен разрешений
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если у пользователя есть хотя бы одно разрешение, False иначе
|
||||||
|
"""
|
||||||
|
for permission_name in permission_names:
|
||||||
|
if await self.has_permission(user_id, permission_name):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def has_all_permissions(self, user_id: int, permission_names: list[str]) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка наличия всех разрешений у пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя в Telegram
|
||||||
|
permission_names: Список имен разрешений
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если у пользователя есть все разрешения, False иначе
|
||||||
|
"""
|
||||||
|
for permission_name in permission_names:
|
||||||
|
if not await self.has_permission(user_id, permission_name):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
141
services/permissions/decorators.py
Normal file
141
services/permissions/decorators.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
Декораторы для проверки разрешений
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Callable, Any, Union
|
||||||
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from .registry import get_permission_checker
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(permission_name: str, error_message: str = "❌ У вас нет прав для выполнения этой команды."):
|
||||||
|
"""
|
||||||
|
Декоратор для проверки разрешения пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
permission_name: Имя разрешения для проверки
|
||||||
|
error_message: Сообщение об ошибке при отсутствии разрешения
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Извлекаем объект события (Message или CallbackQuery)
|
||||||
|
event = None
|
||||||
|
for arg in args:
|
||||||
|
if isinstance(arg, (Message, CallbackQuery)):
|
||||||
|
event = arg
|
||||||
|
break
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
logger.error("Не удалось найти объект события для проверки разрешения")
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Получаем проверщик разрешений
|
||||||
|
checker = get_permission_checker()
|
||||||
|
if not checker:
|
||||||
|
logger.error("Проверщик разрешений не инициализирован")
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Проверяем разрешение
|
||||||
|
user_id = event.from_user.id
|
||||||
|
has_permission = await checker.has_permission(user_id, permission_name)
|
||||||
|
|
||||||
|
if not has_permission:
|
||||||
|
if isinstance(event, Message):
|
||||||
|
await event.answer(error_message)
|
||||||
|
elif isinstance(event, CallbackQuery):
|
||||||
|
await event.answer(error_message, show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Выполняем оригинальную функцию
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin(error_message: str = "❌ У вас нет прав администратора."):
|
||||||
|
"""
|
||||||
|
Декоратор для проверки прав администратора
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_message: Сообщение об ошибке при отсутствии прав администратора
|
||||||
|
"""
|
||||||
|
return require_permission("admin", error_message)
|
||||||
|
|
||||||
|
|
||||||
|
def require_superuser(error_message: str = "❌ У вас нет прав суперпользователя."):
|
||||||
|
"""
|
||||||
|
Декоратор для проверки прав суперпользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_message: Сообщение об ошибке при отсутствии прав суперпользователя
|
||||||
|
"""
|
||||||
|
return require_permission("superuser", error_message)
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_or_superuser(error_message: str = "❌ У вас нет прав для выполнения этой команды."):
|
||||||
|
"""
|
||||||
|
Декоратор для проверки прав администратора или суперпользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_message: Сообщение об ошибке при отсутствии прав
|
||||||
|
"""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
# Извлекаем объект события (Message или CallbackQuery)
|
||||||
|
event = None
|
||||||
|
for arg in args:
|
||||||
|
if isinstance(arg, (Message, CallbackQuery)):
|
||||||
|
event = arg
|
||||||
|
break
|
||||||
|
|
||||||
|
if not event:
|
||||||
|
logger.error("Не удалось найти объект события для проверки разрешения")
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Получаем проверщик разрешений
|
||||||
|
checker = get_permission_checker()
|
||||||
|
if not checker:
|
||||||
|
logger.error("Проверщик разрешений не инициализирован")
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
# Проверяем права администратора или суперпользователя
|
||||||
|
user_id = event.from_user.id
|
||||||
|
has_permission = await checker.has_any_permission(user_id, ["admin", "superuser"])
|
||||||
|
|
||||||
|
if not has_permission:
|
||||||
|
if isinstance(event, Message):
|
||||||
|
await event.answer(error_message)
|
||||||
|
elif isinstance(event, CallbackQuery):
|
||||||
|
await event.answer(error_message, show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Выполняем оригинальную функцию
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def require_active_user(error_message: str = "❌ Ваш аккаунт неактивен."):
|
||||||
|
"""
|
||||||
|
Декоратор для проверки активности пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_message: Сообщение об ошибке при неактивном аккаунте
|
||||||
|
"""
|
||||||
|
return require_permission("view_questions", error_message)
|
||||||
|
|
||||||
|
|
||||||
|
def require_unbanned_user(error_message: str = "❌ Ваш аккаунт заблокирован."):
|
||||||
|
"""
|
||||||
|
Декоратор для проверки, что пользователь не забанен
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_message: Сообщение об ошибке при заблокированном аккаунте
|
||||||
|
"""
|
||||||
|
return require_permission("ask_questions", error_message)
|
||||||
55
services/permissions/init_permissions.py
Normal file
55
services/permissions/init_permissions.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Инициализация системы разрешений
|
||||||
|
Автоматически регистрирует все доступные разрешения
|
||||||
|
"""
|
||||||
|
from .registry import get_permission_registry, register_permission
|
||||||
|
from .permissions import (
|
||||||
|
AdminPermission,
|
||||||
|
SuperuserPermission,
|
||||||
|
ViewStatsPermission,
|
||||||
|
AdminPanelPermission,
|
||||||
|
ManageUsersPermission,
|
||||||
|
BroadcastPermission,
|
||||||
|
SuperuserOnlyPermission,
|
||||||
|
ViewQuestionsPermission,
|
||||||
|
AskQuestionsPermission,
|
||||||
|
AnswerQuestionsPermission
|
||||||
|
)
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def init_all_permissions():
|
||||||
|
"""
|
||||||
|
Инициализация всех разрешений в системе
|
||||||
|
"""
|
||||||
|
logger.info("Начинаем инициализацию системы разрешений...")
|
||||||
|
|
||||||
|
# Список всех разрешений для регистрации
|
||||||
|
permissions = [
|
||||||
|
AdminPermission(),
|
||||||
|
SuperuserPermission(),
|
||||||
|
ViewStatsPermission(),
|
||||||
|
AdminPanelPermission(),
|
||||||
|
ManageUsersPermission(),
|
||||||
|
BroadcastPermission(),
|
||||||
|
SuperuserOnlyPermission(),
|
||||||
|
ViewQuestionsPermission(),
|
||||||
|
AskQuestionsPermission(),
|
||||||
|
AnswerQuestionsPermission(),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Регистрируем все разрешения
|
||||||
|
for permission in permissions:
|
||||||
|
register_permission(permission)
|
||||||
|
logger.debug(f"Зарегистрировано разрешение: {permission.name}")
|
||||||
|
|
||||||
|
registry = get_permission_registry()
|
||||||
|
total_permissions = len(registry.get_all())
|
||||||
|
|
||||||
|
logger.info(f"✅ Система разрешений инициализирована. Зарегистрировано {total_permissions} разрешений")
|
||||||
|
|
||||||
|
return registry
|
||||||
|
|
||||||
|
|
||||||
196
services/permissions/permissions.py
Normal file
196
services/permissions/permissions.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
Конкретные реализации разрешений
|
||||||
|
Каждое разрешение - отдельный класс, что позволяет легко добавлять новые без изменения существующего кода
|
||||||
|
"""
|
||||||
|
from .base import Permission
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPermission(Permission):
|
||||||
|
"""Разрешение для администраторов"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="admin",
|
||||||
|
description="Права администратора"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||||
|
"""Проверка, является ли пользователь администратором"""
|
||||||
|
return user_id in config.ADMINS
|
||||||
|
|
||||||
|
|
||||||
|
class SuperuserPermission(Permission):
|
||||||
|
"""Разрешение для суперпользователей"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="superuser",
|
||||||
|
description="Права суперпользователя"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||||
|
"""Проверка, является ли пользователь суперпользователем"""
|
||||||
|
try:
|
||||||
|
user = await database.get_user(user_id)
|
||||||
|
return user.is_superuser if user else False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при проверке суперпользователя {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ViewStatsPermission(Permission):
|
||||||
|
"""Разрешение на просмотр статистики"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="view_stats",
|
||||||
|
description="Просмотр статистики"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||||
|
"""Проверка права на просмотр статистики"""
|
||||||
|
# Администраторы и суперпользователи могут просматривать статистику
|
||||||
|
admin_permission = AdminPermission()
|
||||||
|
superuser_permission = SuperuserPermission()
|
||||||
|
|
||||||
|
is_admin = await admin_permission.check(user_id, database, config)
|
||||||
|
is_superuser = await superuser_permission.check(user_id, database, config)
|
||||||
|
|
||||||
|
return is_admin or is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPanelPermission(Permission):
|
||||||
|
"""Разрешение на доступ к админ панели"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="admin_panel",
|
||||||
|
description="Доступ к админ панели"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||||
|
"""Проверка права на доступ к админ панели"""
|
||||||
|
# Администраторы и суперпользователи могут использовать админ панель
|
||||||
|
admin_permission = AdminPermission()
|
||||||
|
superuser_permission = SuperuserPermission()
|
||||||
|
|
||||||
|
is_admin = await admin_permission.check(user_id, database, config)
|
||||||
|
is_superuser = await superuser_permission.check(user_id, database, config)
|
||||||
|
|
||||||
|
return is_admin or is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
class ManageUsersPermission(Permission):
|
||||||
|
"""Разрешение на управление пользователями"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="manage_users",
|
||||||
|
description="Управление пользователями"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||||
|
"""Проверка права на управление пользователями"""
|
||||||
|
# Администраторы и суперпользователи могут управлять пользователями
|
||||||
|
admin_permission = AdminPermission()
|
||||||
|
superuser_permission = SuperuserPermission()
|
||||||
|
|
||||||
|
is_admin = await admin_permission.check(user_id, database, config)
|
||||||
|
is_superuser = await superuser_permission.check(user_id, database, config)
|
||||||
|
|
||||||
|
return is_admin or is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastPermission(Permission):
|
||||||
|
"""Разрешение на рассылку сообщений"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="broadcast",
|
||||||
|
description="Рассылка сообщений"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||||
|
"""Проверка права на рассылку"""
|
||||||
|
# Только администраторы могут делать рассылку
|
||||||
|
admin_permission = AdminPermission()
|
||||||
|
return await admin_permission.check(user_id, database, config)
|
||||||
|
|
||||||
|
|
||||||
|
class SuperuserOnlyPermission(Permission):
|
||||||
|
"""Разрешение только для суперпользователей"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="superuser_only",
|
||||||
|
description="Только для суперпользователей"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||||
|
"""Проверка права только для суперпользователей"""
|
||||||
|
superuser_permission = SuperuserPermission()
|
||||||
|
return await superuser_permission.check(user_id, database, config)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewQuestionsPermission(Permission):
|
||||||
|
"""Разрешение на просмотр вопросов"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="view_questions",
|
||||||
|
description="Просмотр вопросов"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||||
|
"""Проверка права на просмотр вопросов"""
|
||||||
|
# Все активные пользователи могут просматривать вопросы
|
||||||
|
try:
|
||||||
|
user = await database.get_user(user_id)
|
||||||
|
return user.is_active if user else False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при проверке активности пользователя {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class AskQuestionsPermission(Permission):
|
||||||
|
"""Разрешение на задавание вопросов"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="ask_questions",
|
||||||
|
description="Задавание вопросов"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||||
|
"""Проверка права на задавание вопросов"""
|
||||||
|
# Все активные пользователи могут задавать вопросы
|
||||||
|
try:
|
||||||
|
user = await database.get_user(user_id)
|
||||||
|
return user.is_active and not user.is_banned if user else False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при проверке права задавать вопросы для пользователя {user_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class AnswerQuestionsPermission(Permission):
|
||||||
|
"""Разрешение на ответы на вопросы"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
name="answer_questions",
|
||||||
|
description="Ответы на вопросы"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check(self, user_id: int, database: DatabaseService, config) -> bool:
|
||||||
|
"""Проверка права на ответы на вопросы"""
|
||||||
|
# Все активные пользователи могут отвечать на вопросы
|
||||||
|
try:
|
||||||
|
user = await database.get_user(user_id)
|
||||||
|
return user.is_active and not user.is_banned if user else False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при проверке права отвечать на вопросы для пользователя {user_id}: {e}")
|
||||||
|
return False
|
||||||
66
services/permissions/registry.py
Normal file
66
services/permissions/registry.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Глобальный реестр разрешений и фабричные функции
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
from .base import PermissionRegistry, PermissionChecker, Permission
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Глобальный реестр разрешений
|
||||||
|
_permission_registry: Optional[PermissionRegistry] = None
|
||||||
|
_permission_checker: Optional[PermissionChecker] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_permission_registry() -> PermissionRegistry:
|
||||||
|
"""
|
||||||
|
Получение глобального реестра разрешений
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Глобальный экземпляр реестра разрешений
|
||||||
|
"""
|
||||||
|
global _permission_registry
|
||||||
|
if _permission_registry is None:
|
||||||
|
_permission_registry = PermissionRegistry()
|
||||||
|
logger.info("Создан глобальный реестр разрешений")
|
||||||
|
return _permission_registry
|
||||||
|
|
||||||
|
|
||||||
|
def get_permission_checker() -> Optional[PermissionChecker]:
|
||||||
|
"""
|
||||||
|
Получение глобального проверщика разрешений
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Глобальный экземпляр проверщика разрешений или None если не инициализирован
|
||||||
|
"""
|
||||||
|
return _permission_checker
|
||||||
|
|
||||||
|
|
||||||
|
def init_permission_checker(database: DatabaseService, config) -> PermissionChecker:
|
||||||
|
"""
|
||||||
|
Инициализация глобального проверщика разрешений
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database: Сервис базы данных
|
||||||
|
config: Конфигурация приложения
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Инициализированный проверщик разрешений
|
||||||
|
"""
|
||||||
|
global _permission_checker
|
||||||
|
registry = get_permission_registry()
|
||||||
|
_permission_checker = PermissionChecker(registry, database, config)
|
||||||
|
logger.info("Инициализирован глобальный проверщик разрешений")
|
||||||
|
return _permission_checker
|
||||||
|
|
||||||
|
|
||||||
|
def register_permission(permission: Permission) -> None:
|
||||||
|
"""
|
||||||
|
Регистрация разрешения в глобальном реестре
|
||||||
|
|
||||||
|
Args:
|
||||||
|
permission: Разрешение для регистрации
|
||||||
|
"""
|
||||||
|
registry = get_permission_registry()
|
||||||
|
registry.register(permission)
|
||||||
13
services/rate_limiting/__init__.py
Normal file
13
services/rate_limiting/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
Rate limiting сервисы
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .rate_limit_config import RateLimitSettings, get_rate_limit_config, get_adaptive_config
|
||||||
|
from .rate_limiter import RateLimitConfig, send_with_rate_limit, telegram_rate_limiter
|
||||||
|
from .rate_limit_service import RateLimitService
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'RateLimitSettings', 'get_rate_limit_config', 'get_adaptive_config',
|
||||||
|
'RateLimitConfig', 'send_with_rate_limit', 'telegram_rate_limiter',
|
||||||
|
'RateLimitService'
|
||||||
|
]
|
||||||
150
services/rate_limiting/rate_limit_config.py
Normal file
150
services/rate_limiting/rate_limit_config.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация для rate limiting в AnonBot
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Загружаем переменные окружения
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RateLimitSettings:
|
||||||
|
"""Настройки rate limiting для разных типов сообщений"""
|
||||||
|
|
||||||
|
# Основные настройки
|
||||||
|
messages_per_second: float = float(os.getenv('RATE_LIMIT_MESSAGES_PER_SECOND', '0.5')) # Максимум 0.5 сообщений в секунду на чат
|
||||||
|
burst_limit: int = int(os.getenv('RATE_LIMIT_BURST_LIMIT', '2')) # Максимум 2 сообщения подряд
|
||||||
|
retry_after_multiplier: float = float(os.getenv('RATE_LIMIT_RETRY_MULTIPLIER', '1.5')) # Множитель для увеличения задержки при retry
|
||||||
|
max_retry_delay: float = float(os.getenv('RATE_LIMIT_MAX_RETRY_DELAY', '30.0')) # Максимальная задержка между попытками
|
||||||
|
max_retries: int = int(os.getenv('RATE_LIMIT_MAX_RETRIES', '3')) # Максимальное количество повторных попыток
|
||||||
|
|
||||||
|
# Специальные настройки для разных типов сообщений
|
||||||
|
voice_message_delay: float = float(os.getenv('RATE_LIMIT_VOICE_DELAY', '2.0')) # Дополнительная задержка для голосовых сообщений
|
||||||
|
media_message_delay: float = float(os.getenv('RATE_LIMIT_MEDIA_DELAY', '1.5')) # Дополнительная задержка для медиа сообщений
|
||||||
|
text_message_delay: float = float(os.getenv('RATE_LIMIT_TEXT_DELAY', '1.0')) # Дополнительная задержка для текстовых сообщений
|
||||||
|
|
||||||
|
# Настройки для разных типов чатов
|
||||||
|
private_chat_multiplier: float = float(os.getenv('RATE_LIMIT_PRIVATE_MULTIPLIER', '1.0')) # Множитель для приватных чатов
|
||||||
|
group_chat_multiplier: float = float(os.getenv('RATE_LIMIT_GROUP_MULTIPLIER', '0.8')) # Множитель для групповых чатов
|
||||||
|
channel_multiplier: float = float(os.getenv('RATE_LIMIT_CHANNEL_MULTIPLIER', '0.6')) # Множитель для каналов
|
||||||
|
|
||||||
|
# Глобальные ограничения
|
||||||
|
global_messages_per_second: float = float(os.getenv('RATE_LIMIT_GLOBAL_MESSAGES_PER_SECOND', '10.0')) # Максимум 10 сообщений в секунду глобально
|
||||||
|
global_burst_limit: int = int(os.getenv('RATE_LIMIT_GLOBAL_BURST_LIMIT', '20')) # Максимум 20 сообщений подряд глобально
|
||||||
|
|
||||||
|
|
||||||
|
# Конфигурации для разных сценариев использования
|
||||||
|
# Основаны на официальных лимитах Telegram Bot API:
|
||||||
|
# - 1 сообщение в секунду в личных чатах
|
||||||
|
# - 20 сообщений в минуту в групповых чатах (0.33 в секунду)
|
||||||
|
# - 30 запросов в секунду глобально
|
||||||
|
|
||||||
|
DEVELOPMENT_CONFIG = RateLimitSettings(
|
||||||
|
messages_per_second=0.8, # Более мягкие ограничения для разработки (80% от лимита)
|
||||||
|
burst_limit=3, # До 3 сообщений подряд
|
||||||
|
retry_after_multiplier=1.2,
|
||||||
|
max_retry_delay=15.0,
|
||||||
|
max_retries=2,
|
||||||
|
voice_message_delay=1.5,
|
||||||
|
media_message_delay=1.2,
|
||||||
|
text_message_delay=1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
PRODUCTION_CONFIG = RateLimitSettings(
|
||||||
|
messages_per_second=0.5, # Консервативные ограничения (50% от лимита)
|
||||||
|
burst_limit=2, # До 2 сообщений подряд
|
||||||
|
retry_after_multiplier=1.5,
|
||||||
|
max_retry_delay=30.0,
|
||||||
|
max_retries=3,
|
||||||
|
voice_message_delay=2.5, # Дополнительная задержка для голосовых
|
||||||
|
media_message_delay=2.0, # Дополнительная задержка для медиа
|
||||||
|
text_message_delay=1.5, # Дополнительная задержка для текста
|
||||||
|
global_messages_per_second=20.0, # 20 из 30 доступных запросов в секунду
|
||||||
|
global_burst_limit=15 # До 15 сообщений подряд глобально
|
||||||
|
)
|
||||||
|
|
||||||
|
STRICT_CONFIG = RateLimitSettings(
|
||||||
|
messages_per_second=0.3, # Очень консервативные ограничения (30% от лимита)
|
||||||
|
burst_limit=1, # Только 1 сообщение подряд
|
||||||
|
retry_after_multiplier=2.0,
|
||||||
|
max_retry_delay=60.0,
|
||||||
|
max_retries=5,
|
||||||
|
voice_message_delay=3.0,
|
||||||
|
media_message_delay=2.5,
|
||||||
|
text_message_delay=2.0,
|
||||||
|
global_messages_per_second=10.0, # 10 из 30 доступных запросов в секунду
|
||||||
|
global_burst_limit=8 # До 8 сообщений подряд глобально
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_rate_limit_config(environment: str = None) -> RateLimitSettings:
|
||||||
|
"""
|
||||||
|
Получает конфигурацию rate limiting в зависимости от окружения
|
||||||
|
|
||||||
|
Args:
|
||||||
|
environment: Окружение ('development', 'production', 'strict')
|
||||||
|
Если не указано, берется из переменной окружения RATE_LIMIT_ENV
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RateLimitSettings: Конфигурация для указанного окружения
|
||||||
|
"""
|
||||||
|
if environment is None:
|
||||||
|
environment = os.getenv('RATE_LIMIT_ENV', 'production')
|
||||||
|
|
||||||
|
configs = {
|
||||||
|
"development": DEVELOPMENT_CONFIG,
|
||||||
|
"production": PRODUCTION_CONFIG,
|
||||||
|
"strict": STRICT_CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs.get(environment, PRODUCTION_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def get_adaptive_config(
|
||||||
|
current_error_rate: float,
|
||||||
|
base_config: Optional[RateLimitSettings] = None
|
||||||
|
) -> RateLimitSettings:
|
||||||
|
"""
|
||||||
|
Получает адаптивную конфигурацию на основе текущего уровня ошибок
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_error_rate: Текущий уровень ошибок (0.0 - 1.0)
|
||||||
|
base_config: Базовая конфигурация
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RateLimitSettings: Адаптированная конфигурация
|
||||||
|
"""
|
||||||
|
if base_config is None:
|
||||||
|
base_config = PRODUCTION_CONFIG
|
||||||
|
|
||||||
|
# Если уровень ошибок высокий, ужесточаем ограничения
|
||||||
|
if current_error_rate > 0.1: # Более 10% ошибок
|
||||||
|
return RateLimitSettings(
|
||||||
|
messages_per_second=base_config.messages_per_second * 0.5,
|
||||||
|
burst_limit=max(1, base_config.burst_limit - 1),
|
||||||
|
retry_after_multiplier=base_config.retry_after_multiplier * 1.5,
|
||||||
|
max_retry_delay=base_config.max_retry_delay * 1.5,
|
||||||
|
max_retries=base_config.max_retries + 1,
|
||||||
|
voice_message_delay=base_config.voice_message_delay * 1.5,
|
||||||
|
media_message_delay=base_config.media_message_delay * 1.3,
|
||||||
|
text_message_delay=base_config.text_message_delay * 1.2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Если уровень ошибок низкий, можно немного ослабить ограничения
|
||||||
|
elif current_error_rate < 0.01: # Менее 1% ошибок
|
||||||
|
return RateLimitSettings(
|
||||||
|
messages_per_second=base_config.messages_per_second * 1.2,
|
||||||
|
burst_limit=base_config.burst_limit + 1,
|
||||||
|
retry_after_multiplier=base_config.retry_after_multiplier * 0.9,
|
||||||
|
max_retry_delay=base_config.max_retry_delay * 0.8,
|
||||||
|
max_retries=max(1, base_config.max_retries - 1),
|
||||||
|
voice_message_delay=base_config.voice_message_delay * 0.8,
|
||||||
|
media_message_delay=base_config.media_message_delay * 0.9,
|
||||||
|
text_message_delay=base_config.text_message_delay * 0.9
|
||||||
|
)
|
||||||
|
|
||||||
|
# Возвращаем базовую конфигурацию
|
||||||
|
return base_config
|
||||||
142
services/rate_limiting/rate_limit_service.py
Normal file
142
services/rate_limiting/rate_limit_service.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
Сервис для управления rate limiting в AnonBot
|
||||||
|
"""
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter
|
||||||
|
|
||||||
|
from config.constants import MIN_REQUESTS_FOR_ADAPTATION, HIGH_ERROR_RATE_THRESHOLD, LOW_ERROR_RATE_THRESHOLD
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from .rate_limit_config import RateLimitSettings, get_adaptive_config, get_rate_limit_config
|
||||||
|
from .rate_limiter import send_with_rate_limit, telegram_rate_limiter
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitService:
|
||||||
|
"""Сервис для управления rate limiting"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.rate_limiter = telegram_rate_limiter
|
||||||
|
self.config = get_rate_limit_config()
|
||||||
|
self.stats = {
|
||||||
|
'total_requests': 0,
|
||||||
|
'successful_requests': 0,
|
||||||
|
'failed_requests': 0,
|
||||||
|
'retry_after_errors': 0,
|
||||||
|
'other_errors': 0,
|
||||||
|
'total_wait_time': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
async def send_with_rate_limit(
|
||||||
|
self,
|
||||||
|
send_func: Callable,
|
||||||
|
chat_id: int,
|
||||||
|
*args,
|
||||||
|
**kwargs
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Отправляет сообщение с соблюдением rate limit
|
||||||
|
|
||||||
|
Args:
|
||||||
|
send_func: Функция отправки (например, bot.send_message)
|
||||||
|
chat_id: ID чата
|
||||||
|
*args, **kwargs: Аргументы для функции отправки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Результат выполнения функции отправки
|
||||||
|
"""
|
||||||
|
self.stats['total_requests'] += 1
|
||||||
|
logger.info(f"Обработка rate limit запроса для чата {chat_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result, wait_time = await self.rate_limiter.send_with_rate_limit(send_func, chat_id, *args, **kwargs)
|
||||||
|
self.stats['successful_requests'] += 1
|
||||||
|
self.stats['total_wait_time'] += wait_time
|
||||||
|
logger.info(f"Rate limited сообщение успешно отправлено в чат {chat_id}, время ожидания: {wait_time:.2f}с")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except TelegramRetryAfter as e:
|
||||||
|
self.stats['failed_requests'] += 1
|
||||||
|
self.stats['retry_after_errors'] += 1
|
||||||
|
logger.warning(f"Превышен rate limit для чата {chat_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
except TelegramAPIError as e:
|
||||||
|
self.stats['failed_requests'] += 1
|
||||||
|
self.stats['other_errors'] += 1
|
||||||
|
logger.error(f"Ошибка Telegram API для чата {chat_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.stats['failed_requests'] += 1
|
||||||
|
self.stats['other_errors'] += 1
|
||||||
|
logger.error(f"Неожиданная ошибка в rate limit сервисе для чата {chat_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Получает статистику rate limiting"""
|
||||||
|
total = self.stats['total_requests']
|
||||||
|
if total == 0:
|
||||||
|
return {
|
||||||
|
'total_requests': 0,
|
||||||
|
'successful_requests': 0,
|
||||||
|
'failed_requests': 0,
|
||||||
|
'success_rate': 0.0,
|
||||||
|
'error_rate': 0.0,
|
||||||
|
'retry_after_errors': 0,
|
||||||
|
'other_errors': 0,
|
||||||
|
'retry_after_rate': 0.0,
|
||||||
|
'other_error_rate': 0.0,
|
||||||
|
'average_wait_time': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_requests': total,
|
||||||
|
'successful_requests': self.stats['successful_requests'],
|
||||||
|
'failed_requests': self.stats['failed_requests'],
|
||||||
|
'success_rate': self.stats['successful_requests'] / total,
|
||||||
|
'error_rate': self.stats['failed_requests'] / total,
|
||||||
|
'retry_after_errors': self.stats['retry_after_errors'],
|
||||||
|
'other_errors': self.stats['other_errors'],
|
||||||
|
'retry_after_rate': self.stats['retry_after_errors'] / total,
|
||||||
|
'other_error_rate': self.stats['other_errors'] / total,
|
||||||
|
'average_wait_time': self.stats['total_wait_time'] / total if total > 0 else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset_stats(self):
|
||||||
|
"""Сбрасывает статистику"""
|
||||||
|
self.stats = {
|
||||||
|
'total_requests': 0,
|
||||||
|
'successful_requests': 0,
|
||||||
|
'failed_requests': 0,
|
||||||
|
'retry_after_errors': 0,
|
||||||
|
'other_errors': 0,
|
||||||
|
'total_wait_time': 0.0
|
||||||
|
}
|
||||||
|
logger.info("Статистика rate limit сброшена")
|
||||||
|
|
||||||
|
def update_config(self, new_config: RateLimitSettings):
|
||||||
|
"""Обновляет конфигурацию rate limiting"""
|
||||||
|
self.config = new_config
|
||||||
|
logger.info(f"Конфигурация rate limit обновлена: {new_config}")
|
||||||
|
|
||||||
|
def get_adaptive_config(self) -> RateLimitSettings:
|
||||||
|
"""Получает адаптивную конфигурацию на основе текущей статистики"""
|
||||||
|
error_rate = self.stats['failed_requests'] / max(1, self.stats['total_requests'])
|
||||||
|
return get_adaptive_config(error_rate, self.config)
|
||||||
|
|
||||||
|
def should_adapt_config(self) -> bool:
|
||||||
|
"""Определяет, нужно ли адаптировать конфигурацию"""
|
||||||
|
if self.stats['total_requests'] < MIN_REQUESTS_FOR_ADAPTATION: # Недостаточно данных
|
||||||
|
return False
|
||||||
|
|
||||||
|
error_rate = self.stats['failed_requests'] / self.stats['total_requests']
|
||||||
|
return error_rate > HIGH_ERROR_RATE_THRESHOLD or error_rate < LOW_ERROR_RATE_THRESHOLD # Высокий или низкий уровень ошибок
|
||||||
|
|
||||||
|
async def adapt_config_if_needed(self):
|
||||||
|
"""Адаптирует конфигурацию если необходимо"""
|
||||||
|
if self.should_adapt_config():
|
||||||
|
new_config = self.get_adaptive_config()
|
||||||
|
self.update_config(new_config)
|
||||||
|
logger.info("Конфигурация rate limit адаптирована на основе текущей производительности")
|
||||||
230
services/rate_limiting/rate_limiter.py
Normal file
230
services/rate_limiting/rate_limiter.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
Rate limiter для предотвращения Flood control ошибок в Telegram Bot API
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from typing import Dict, Optional, Any, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
|
||||||
|
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from .rate_limit_config import RateLimitSettings, get_rate_limit_config
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RateLimitConfig:
|
||||||
|
"""Конфигурация для rate limiting"""
|
||||||
|
messages_per_second: float = 0.5 # Максимум 0.5 сообщений в секунду на чат
|
||||||
|
burst_limit: int = 3 # Максимум 3 сообщения подряд
|
||||||
|
retry_after_multiplier: float = 1.2 # Множитель для увеличения задержки при retry
|
||||||
|
max_retry_delay: float = 60.0 # Максимальная задержка между попытками
|
||||||
|
|
||||||
|
|
||||||
|
class ChatRateLimiter:
|
||||||
|
"""Rate limiter для конкретного чата"""
|
||||||
|
|
||||||
|
def __init__(self, config: RateLimitConfig):
|
||||||
|
self.config = config
|
||||||
|
self.last_send_time = 0.0
|
||||||
|
self.burst_count = 0
|
||||||
|
self.burst_reset_time = 0.0
|
||||||
|
self.retry_delay = 1.0
|
||||||
|
|
||||||
|
async def wait_if_needed(self) -> None:
|
||||||
|
"""Ждет если необходимо для соблюдения rate limit"""
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Сбрасываем счетчик burst если прошло достаточно времени
|
||||||
|
if current_time >= self.burst_reset_time:
|
||||||
|
self.burst_count = 0
|
||||||
|
self.burst_reset_time = current_time + 1.0
|
||||||
|
|
||||||
|
# Проверяем burst limit
|
||||||
|
if self.burst_count >= self.config.burst_limit:
|
||||||
|
wait_time = self.burst_reset_time - current_time
|
||||||
|
if wait_time > 0:
|
||||||
|
logger.info(f"Достигнут лимит burst, ожидание {wait_time:.2f}с")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
current_time = time.time()
|
||||||
|
self.burst_count = 0
|
||||||
|
self.burst_reset_time = current_time + 1.0
|
||||||
|
|
||||||
|
# Проверяем минимальный интервал между сообщениями
|
||||||
|
time_since_last = current_time - self.last_send_time
|
||||||
|
min_interval = 1.0 / self.config.messages_per_second
|
||||||
|
|
||||||
|
if time_since_last < min_interval:
|
||||||
|
wait_time = min_interval - time_since_last
|
||||||
|
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
|
||||||
|
# Обновляем время последней отправки
|
||||||
|
self.last_send_time = time.time()
|
||||||
|
self.burst_count += 1
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalRateLimiter:
|
||||||
|
"""Глобальный rate limiter для всех чатов"""
|
||||||
|
|
||||||
|
def __init__(self, config: RateLimitConfig):
|
||||||
|
self.config = config
|
||||||
|
self.chat_limiters: Dict[int, ChatRateLimiter] = {}
|
||||||
|
self.global_last_send = 0.0
|
||||||
|
self.global_min_interval = 0.1 # Минимум 100ms между любыми сообщениями
|
||||||
|
|
||||||
|
def get_chat_limiter(self, chat_id: int) -> ChatRateLimiter:
|
||||||
|
"""Получает rate limiter для конкретного чата"""
|
||||||
|
if chat_id not in self.chat_limiters:
|
||||||
|
self.chat_limiters[chat_id] = ChatRateLimiter(self.config)
|
||||||
|
return self.chat_limiters[chat_id]
|
||||||
|
|
||||||
|
async def wait_if_needed(self, chat_id: int) -> None:
|
||||||
|
"""Ждет если необходимо для соблюдения глобального и чат-специфичного rate limit"""
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Глобальный rate limit
|
||||||
|
time_since_global = current_time - self.global_last_send
|
||||||
|
if time_since_global < self.global_min_interval:
|
||||||
|
wait_time = self.global_min_interval - time_since_global
|
||||||
|
logger.info(f"Применен глобальный rate limit для чата {chat_id}, ожидание {wait_time:.2f}с")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Чат-специфичный rate limit
|
||||||
|
chat_limiter = self.get_chat_limiter(chat_id)
|
||||||
|
await chat_limiter.wait_if_needed()
|
||||||
|
|
||||||
|
self.global_last_send = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class RetryHandler:
|
||||||
|
"""Обработчик повторных попыток с экспоненциальной задержкой"""
|
||||||
|
|
||||||
|
def __init__(self, config: RateLimitConfig):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
async def execute_with_retry(
|
||||||
|
self,
|
||||||
|
func: Callable,
|
||||||
|
chat_id: int,
|
||||||
|
*args,
|
||||||
|
max_retries: int = 3,
|
||||||
|
**kwargs
|
||||||
|
) -> tuple[Any, float]:
|
||||||
|
"""Выполняет функцию с повторными попытками при ошибках"""
|
||||||
|
retry_count = 0
|
||||||
|
current_delay = self.config.retry_after_multiplier
|
||||||
|
total_wait_time = 0.0
|
||||||
|
|
||||||
|
while retry_count <= max_retries:
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
# Записываем успешный запрос
|
||||||
|
logger.debug(f"Rate limit запрос успешен для чата {chat_id}")
|
||||||
|
return result, total_wait_time
|
||||||
|
|
||||||
|
except TelegramRetryAfter as e:
|
||||||
|
retry_count += 1
|
||||||
|
if retry_count > max_retries:
|
||||||
|
logger.error(f"Превышено максимальное количество попыток для RetryAfter: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Используем время ожидания от Telegram или наше увеличенное
|
||||||
|
wait_time = max(e.retry_after, current_delay)
|
||||||
|
wait_time = min(wait_time, self.config.max_retry_delay)
|
||||||
|
total_wait_time += wait_time
|
||||||
|
|
||||||
|
logger.info(f"RetryAfter ошибка для чата {chat_id}, ожидание {wait_time:.2f}с (попытка {retry_count}/{max_retries})")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
current_delay *= self.config.retry_after_multiplier
|
||||||
|
|
||||||
|
except TelegramAPIError as e:
|
||||||
|
retry_count += 1
|
||||||
|
if retry_count > max_retries:
|
||||||
|
logger.error(f"Превышено максимальное количество попыток для TelegramAPIError: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
wait_time = min(current_delay, self.config.max_retry_delay)
|
||||||
|
total_wait_time += wait_time
|
||||||
|
logger.info(f"TelegramAPIError для чата {chat_id}, ожидание {wait_time:.2f}с (попытка {retry_count}/{max_retries}): {e}")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
current_delay *= self.config.retry_after_multiplier
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Для других ошибок не делаем retry
|
||||||
|
logger.error(f"Ошибка без повторных попыток: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramRateLimiter:
|
||||||
|
"""Основной класс для rate limiting в Telegram боте"""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[RateLimitConfig] = None):
|
||||||
|
self.config = config or RateLimitConfig()
|
||||||
|
self.global_limiter = GlobalRateLimiter(self.config)
|
||||||
|
self.retry_handler = RetryHandler(self.config)
|
||||||
|
|
||||||
|
async def send_with_rate_limit(
|
||||||
|
self,
|
||||||
|
send_func: Callable,
|
||||||
|
chat_id: int,
|
||||||
|
*args,
|
||||||
|
**kwargs
|
||||||
|
) -> tuple[Any, float]:
|
||||||
|
"""Отправляет сообщение с соблюдением rate limit и retry логики"""
|
||||||
|
|
||||||
|
async def _send():
|
||||||
|
await self.global_limiter.wait_if_needed(chat_id)
|
||||||
|
# Добавляем chat_id в kwargs для функции отправки
|
||||||
|
send_kwargs = kwargs.copy()
|
||||||
|
send_kwargs['chat_id'] = chat_id
|
||||||
|
return await send_func(*args, **send_kwargs)
|
||||||
|
|
||||||
|
return await self.retry_handler.execute_with_retry(_send, chat_id)
|
||||||
|
|
||||||
|
async def execute_with_rate_limit(
|
||||||
|
self,
|
||||||
|
handler_func: Callable,
|
||||||
|
chat_id: int
|
||||||
|
) -> tuple[Any, float]:
|
||||||
|
"""Выполняет обработчик с соблюдением rate limit (без добавления chat_id в kwargs)"""
|
||||||
|
|
||||||
|
async def _execute():
|
||||||
|
await self.global_limiter.wait_if_needed(chat_id)
|
||||||
|
return await handler_func()
|
||||||
|
|
||||||
|
return await self.retry_handler.execute_with_retry(_execute, chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_rate_limit_config(settings: RateLimitSettings) -> RateLimitConfig:
|
||||||
|
"""Создает RateLimitConfig из RateLimitSettings"""
|
||||||
|
return RateLimitConfig(
|
||||||
|
messages_per_second=settings.messages_per_second,
|
||||||
|
burst_limit=settings.burst_limit,
|
||||||
|
retry_after_multiplier=settings.retry_after_multiplier,
|
||||||
|
max_retry_delay=settings.max_retry_delay
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Получаем конфигурацию из настроек
|
||||||
|
_rate_limit_settings = get_rate_limit_config()
|
||||||
|
_default_config = _create_rate_limit_config(_rate_limit_settings)
|
||||||
|
|
||||||
|
telegram_rate_limiter = TelegramRateLimiter(_default_config)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_with_rate_limit(send_func: Callable, chat_id: int, *args, **kwargs) -> tuple[Any, float]:
|
||||||
|
"""
|
||||||
|
Удобная функция для отправки сообщений с rate limiting
|
||||||
|
|
||||||
|
Args:
|
||||||
|
send_func: Функция отправки (например, bot.send_message)
|
||||||
|
chat_id: ID чата
|
||||||
|
*args, **kwargs: Аргументы для функции отправки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Кортеж (результат выполнения функции отправки, общее время ожидания)
|
||||||
|
"""
|
||||||
|
return await telegram_rate_limiter.send_with_rate_limit(send_func, chat_id, *args, **kwargs)
|
||||||
402
services/utils.py
Normal file
402
services/utils.py
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
"""
|
||||||
|
Сервис утилит для бота
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from config.constants import ANONYMOUS_TOKEN_LENGTH, DEFAULT_QUESTION_PREVIEW_LENGTH, DEFAULT_TEXT_TRUNCATE_LENGTH, MIN_QUESTION_LENGTH, MIN_ANSWER_LENGTH, SEPARATOR_LENGTH
|
||||||
|
from models.question import Question
|
||||||
|
from models.user import User
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UtilsService:
|
||||||
|
"""Сервис утилит для форматирования и валидации"""
|
||||||
|
|
||||||
|
def __init__(self, database: DatabaseService):
|
||||||
|
self.database = database
|
||||||
|
|
||||||
|
def generate_referral_link(self, bot_username: str, user_id: int) -> str:
|
||||||
|
"""
|
||||||
|
Генерация уникальной реферальной ссылки для пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot_username: Имя бота (без @)
|
||||||
|
user_id: ID пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Ссылка формата: t.me/bot_username?start=ref_{user_id}
|
||||||
|
"""
|
||||||
|
return f"https://t.me/{bot_username}?start=ref_{user_id}"
|
||||||
|
|
||||||
|
def generate_anonymous_id(self) -> str:
|
||||||
|
"""
|
||||||
|
Генерация анонимного ID для отправителя вопроса
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Случайная строка для идентификации анонимного пользователя
|
||||||
|
"""
|
||||||
|
return secrets.token_hex(ANONYMOUS_TOKEN_LENGTH)
|
||||||
|
|
||||||
|
def format_user_info(self, user: User, show_stats: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Форматирование информации о пользователе
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Объект пользователя
|
||||||
|
show_stats: Показывать ли статистику
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отформатированная строка с информацией о пользователе
|
||||||
|
"""
|
||||||
|
info = f"👤 <b>{user.display_name}</b>\n"
|
||||||
|
|
||||||
|
if user.full_name and user.username:
|
||||||
|
info += f"📝 {user.full_name}\n"
|
||||||
|
|
||||||
|
if hasattr(user, 'is_premium') and user.is_premium:
|
||||||
|
info += "⭐ Premium пользователь\n"
|
||||||
|
|
||||||
|
if hasattr(user, 'language_code') and user.language_code:
|
||||||
|
info += f"🌐 Язык: {user.language_code.upper()}\n"
|
||||||
|
|
||||||
|
if user.created_at:
|
||||||
|
info += f"📅 Регистрация: {user.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||||
|
|
||||||
|
if user.is_active:
|
||||||
|
info += "✅ Активен\n"
|
||||||
|
else:
|
||||||
|
info += "❌ Неактивен\n"
|
||||||
|
|
||||||
|
if show_stats:
|
||||||
|
# Здесь можно добавить статистику пользователя
|
||||||
|
pass
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
def format_user_display_name(self, user: User) -> str:
|
||||||
|
"""
|
||||||
|
Форматирование отображаемого имени пользователя для суперпользователей
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: Объект пользователя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Строка в формате: {@username} {first_name} {last_name}
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
# Добавляем username если есть
|
||||||
|
if user.username:
|
||||||
|
parts.append(f"@{user.username}")
|
||||||
|
|
||||||
|
# Добавляем имя
|
||||||
|
if user.first_name:
|
||||||
|
parts.append(user.first_name)
|
||||||
|
|
||||||
|
# Добавляем фамилию если есть
|
||||||
|
if user.last_name:
|
||||||
|
parts.append(user.last_name)
|
||||||
|
|
||||||
|
return " ".join(parts) if parts else "Неизвестный пользователь"
|
||||||
|
|
||||||
|
def format_question_info(self, question: Question, show_answer: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Форматирование информации о вопросе
|
||||||
|
|
||||||
|
Args:
|
||||||
|
question: Объект вопроса
|
||||||
|
show_answer: Показывать ли ответ
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отформатированная строка с информацией о вопросе
|
||||||
|
"""
|
||||||
|
# Используем user_question_number для отображения, если он есть
|
||||||
|
display_number = question.user_question_number if question.user_question_number is not None else question.id
|
||||||
|
info = f"❓ <b>Вопрос #{display_number}</b>\n\n"
|
||||||
|
|
||||||
|
if question.is_anonymous:
|
||||||
|
info += "👤 <i>Анонимный вопрос</i>\n"
|
||||||
|
else:
|
||||||
|
info += f"👤 От: {question.from_user_id}\n"
|
||||||
|
|
||||||
|
info += f"📝 <b>Вопрос:</b>\n{question.message_text}\n\n"
|
||||||
|
|
||||||
|
if question.created_at:
|
||||||
|
info += f"📅 {question.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||||
|
|
||||||
|
# Статус вопроса
|
||||||
|
status_emoji = {
|
||||||
|
'pending': '⏳',
|
||||||
|
'answered': '✅',
|
||||||
|
'rejected': '❌',
|
||||||
|
'deleted': '🗑️'
|
||||||
|
}
|
||||||
|
|
||||||
|
status_text = {
|
||||||
|
'pending': 'Ожидает ответа',
|
||||||
|
'answered': 'Отвечен',
|
||||||
|
'rejected': 'Отклонен',
|
||||||
|
'deleted': 'Удален'
|
||||||
|
}
|
||||||
|
|
||||||
|
info += f"{status_emoji.get(question.status.value, '❓')} {status_text.get(question.status.value, 'Неизвестно')}\n"
|
||||||
|
|
||||||
|
if show_answer and question.answer_text:
|
||||||
|
info += f"\n💬 <b>Ответ:</b>\n{question.answer_text}\n"
|
||||||
|
|
||||||
|
if question.answered_at:
|
||||||
|
info += f"\n📅 Ответ дан: {question.answered_at.strftime('%d.%m.%Y %H:%M')}"
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
def format_questions_list(self, questions: list, show_answers: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Форматирование списка вопросов
|
||||||
|
|
||||||
|
Args:
|
||||||
|
questions: Список вопросов
|
||||||
|
show_answers: Показывать ли ответы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отформатированная строка со списком вопросов
|
||||||
|
"""
|
||||||
|
if not questions:
|
||||||
|
return "📭 Вопросов пока нет"
|
||||||
|
|
||||||
|
info = f"📋 <b>Список вопросов ({len(questions)}):</b>\n\n"
|
||||||
|
|
||||||
|
for i, question in enumerate(questions, 1):
|
||||||
|
info += f"{i}. {self.format_question_info(question, show_answers)}\n"
|
||||||
|
if i < len(questions):
|
||||||
|
info += "─" * SEPARATOR_LENGTH + "\n\n"
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
def format_stats(self, stats: dict) -> str:
|
||||||
|
"""
|
||||||
|
Форматирование статистики
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stats: Словарь со статистикой
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Отформатированная строка со статистикой
|
||||||
|
"""
|
||||||
|
info = "📊 <b>Статистика бота:</b>\n\n"
|
||||||
|
|
||||||
|
# Статистика пользователей
|
||||||
|
if 'total_users' in stats:
|
||||||
|
info += "👥 <b>Пользователи:</b>\n"
|
||||||
|
info += f"• Всего: {stats.get('total_users', 0)}\n"
|
||||||
|
info += f"• Активных за неделю: {stats.get('active_week', 0)}\n"
|
||||||
|
info += f"• Активных сегодня: {stats.get('active_today', 0)}\n\n"
|
||||||
|
|
||||||
|
# Статистика вопросов
|
||||||
|
if 'total_questions' in stats:
|
||||||
|
info += "❓ <b>Вопросы:</b>\n"
|
||||||
|
info += f"• Всего: {stats.get('total_questions', 0)}\n"
|
||||||
|
info += f"• Ожидают ответа: {stats.get('pending_questions', 0)}\n"
|
||||||
|
info += f"• Отвечено: {stats.get('answered_questions', 0)}\n"
|
||||||
|
info += f"• За сегодня: {stats.get('questions_today', 0)}\n"
|
||||||
|
info += f"• За неделю: {stats.get('questions_week', 0)}\n\n"
|
||||||
|
|
||||||
|
# Дополнительная статистика
|
||||||
|
if 'total_questions_received' in stats:
|
||||||
|
info += "📈 <b>Активность:</b>\n"
|
||||||
|
info += f"• Получено вопросов: {stats.get('total_questions_received', 0)}\n"
|
||||||
|
info += f"• Отвечено вопросов: {stats.get('total_questions_answered', 0)}\n"
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
def escape_html(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Экранирование HTML символов
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Исходный текст
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Экранированный текст
|
||||||
|
"""
|
||||||
|
return (text
|
||||||
|
.replace('&', '&')
|
||||||
|
.replace('<', '<')
|
||||||
|
.replace('>', '>')
|
||||||
|
.replace('"', '"')
|
||||||
|
.replace("'", '''))
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_question_text(self, text: str, max_length: int = 1000) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Проверка валидности текста вопроса
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст вопроса
|
||||||
|
max_length: Максимальная длина
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Кортеж (валидность, сообщение об ошибке)
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return False, "Вопрос не может быть пустым"
|
||||||
|
|
||||||
|
if len(text.strip()) < MIN_QUESTION_LENGTH:
|
||||||
|
return False, f"Вопрос должен содержать минимум {MIN_QUESTION_LENGTH} символов"
|
||||||
|
|
||||||
|
if len(text) > max_length:
|
||||||
|
return False, f"Вопрос слишком длинный (максимум {max_length} символов)"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def is_valid_answer_text(self, text: str, max_length: int = 2000) -> Tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Проверка валидности текста ответа
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст ответа
|
||||||
|
max_length: Максимальная длина
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Кортеж (валидность, сообщение об ошибке)
|
||||||
|
"""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return False, "Ответ не может быть пустым"
|
||||||
|
|
||||||
|
if len(text.strip()) < MIN_ANSWER_LENGTH:
|
||||||
|
return False, f"Ответ должен содержать минимум {MIN_ANSWER_LENGTH} символов"
|
||||||
|
|
||||||
|
if len(text) > max_length:
|
||||||
|
return False, f"Ответ слишком длинный (максимум {max_length} символов)"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
async def send_answer_to_author(self, bot, question: Question, answer_text: str):
|
||||||
|
"""
|
||||||
|
Отправляет ответ автору вопроса
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot: Экземпляр бота
|
||||||
|
question: Объект вопроса
|
||||||
|
answer_text: Текст ответа
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"send_answer_to_author вызвана для вопроса {question.id}, from_user_id: {question.from_user_id}")
|
||||||
|
|
||||||
|
# Проверяем, есть ли ID автора (для анонимных вопросов может быть None)
|
||||||
|
if not question.from_user_id:
|
||||||
|
logger.warning(f"Нельзя отправить ответ автору вопроса {question.id}: from_user_id = None (анонимный вопрос)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Формируем сообщение для автора
|
||||||
|
message_text = f"💬 <b>Получен ответ на ваш вопрос!</b>\n\n"
|
||||||
|
message_text += f"❓ <b>Ваш вопрос:</b>\n{question.message_text}\n\n"
|
||||||
|
message_text += f"✅ <b>Ответ:</b>\n{answer_text}\n\n"
|
||||||
|
|
||||||
|
# Обрабатываем дату ответа (асинхронно)
|
||||||
|
if question.answered_at:
|
||||||
|
if isinstance(question.answered_at, str):
|
||||||
|
# Если дата пришла как строка, конвертируем в datetime
|
||||||
|
try:
|
||||||
|
# Используем asyncio для неблокирующего парсинга
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
answered_at = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: datetime.fromisoformat(question.answered_at.replace('Z', '+00:00'))
|
||||||
|
)
|
||||||
|
date_str = answered_at.strftime('%d.%m.%Y %H:%M')
|
||||||
|
except:
|
||||||
|
date_str = str(question.answered_at)
|
||||||
|
else:
|
||||||
|
date_str = question.answered_at.strftime('%d.%m.%Y %H:%M')
|
||||||
|
else:
|
||||||
|
date_str = "Неизвестно"
|
||||||
|
|
||||||
|
message_text += f"📅 <b>Дата ответа:</b> {date_str}"
|
||||||
|
|
||||||
|
# Отправляем сообщение автору
|
||||||
|
logger.info(f"Попытка отправить сообщение пользователю {question.from_user_id}")
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=question.from_user_id,
|
||||||
|
text=message_text,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✅ Ответ успешно отправлен автору вопроса {question.id} (пользователь {question.from_user_id})")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при отправке ответа автору вопроса {question.id}: {e}")
|
||||||
|
# Не поднимаем исключение, чтобы не прерывать основной процесс
|
||||||
|
|
||||||
|
|
||||||
|
# Функции для обратной совместимости
|
||||||
|
def generate_referral_link(bot_username: str, user_id: int) -> str:
|
||||||
|
"""Генерация уникальной реферальной ссылки для пользователя"""
|
||||||
|
return f"https://t.me/{bot_username}?start=ref_{user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_anonymous_id() -> str:
|
||||||
|
"""Генерация анонимного ID для отправителя вопроса"""
|
||||||
|
return secrets.token_hex(ANONYMOUS_TOKEN_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
def format_user_info(user: User, show_stats: bool = False) -> str:
|
||||||
|
"""Форматирование информации о пользователе"""
|
||||||
|
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||||
|
return utils.format_user_info(user, show_stats)
|
||||||
|
|
||||||
|
|
||||||
|
def format_user_display_name(user: User) -> str:
|
||||||
|
"""Форматирование отображаемого имени пользователя для суперпользователей"""
|
||||||
|
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||||
|
return utils.format_user_display_name(user)
|
||||||
|
|
||||||
|
|
||||||
|
def format_question_info(question: Question, show_answer: bool = False) -> str:
|
||||||
|
"""Форматирование информации о вопросе"""
|
||||||
|
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||||
|
return utils.format_question_info(question, show_answer)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def format_stats(stats: dict) -> str:
|
||||||
|
"""Форматирование статистики"""
|
||||||
|
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||||
|
return utils.format_stats(stats)
|
||||||
|
|
||||||
|
|
||||||
|
def escape_html(text: str) -> str:
|
||||||
|
"""Экранирование HTML символов"""
|
||||||
|
return (text
|
||||||
|
.replace('&', '&')
|
||||||
|
.replace('<', '<')
|
||||||
|
.replace('>', '>')
|
||||||
|
.replace('"', '"')
|
||||||
|
.replace("'", '''))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_question_text(text: str, max_length: int = 1000) -> Tuple[bool, str]:
|
||||||
|
"""Проверка валидности текста вопроса"""
|
||||||
|
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||||
|
return utils.is_valid_question_text(text, max_length)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_answer_text(text: str, max_length: int = 2000) -> Tuple[bool, str]:
|
||||||
|
"""Проверка валидности текста ответа"""
|
||||||
|
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||||
|
return utils.is_valid_answer_text(text, max_length)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_answer_to_author(bot, question: Question, answer_text: str):
|
||||||
|
"""Отправляет ответ автору вопроса"""
|
||||||
|
utils = UtilsService(None) # Временное решение для обратной совместимости
|
||||||
|
await utils.send_answer_to_author(bot, question, answer_text)
|
||||||
6
services/validation/__init__.py
Normal file
6
services/validation/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Модуль валидации входных данных
|
||||||
|
"""
|
||||||
|
from .input_validator import InputValidator, ValidationResult
|
||||||
|
|
||||||
|
__all__ = ['InputValidator', 'ValidationResult']
|
||||||
359
services/validation/input_validator.py
Normal file
359
services/validation/input_validator.py
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
"""
|
||||||
|
Централизованный валидатор входных данных для AnonBot
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import html
|
||||||
|
from typing import Optional, Tuple, List
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from services.infrastructure.logger import get_logger
|
||||||
|
from config.constants import MIN_QUESTION_LENGTH, MIN_ANSWER_LENGTH
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationResult:
|
||||||
|
"""Результат валидации"""
|
||||||
|
is_valid: bool
|
||||||
|
error_message: str = ""
|
||||||
|
sanitized_value: Optional[str] = None
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return self.is_valid
|
||||||
|
|
||||||
|
|
||||||
|
class InputValidator:
|
||||||
|
"""Централизованный валидатор входных данных"""
|
||||||
|
|
||||||
|
# Константы для валидации
|
||||||
|
MIN_TELEGRAM_ID = 1
|
||||||
|
MAX_TELEGRAM_ID = 2**63 - 1
|
||||||
|
MIN_USERNAME_LENGTH = 1
|
||||||
|
MAX_USERNAME_LENGTH = 32
|
||||||
|
MAX_CALLBACK_DATA_LENGTH = 64
|
||||||
|
MAX_TEXT_LENGTH = 4000 # Telegram limit
|
||||||
|
MAX_HTML_ENTITIES = 100 # Защита от HTML-спама
|
||||||
|
|
||||||
|
# Регулярные выражения
|
||||||
|
USERNAME_PATTERN = re.compile(r'^[a-zA-Z0-9_]{1,32}$')
|
||||||
|
DEEP_LINK_PATTERN = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||||
|
CALLBACK_DATA_PATTERN = re.compile(r'^[a-zA-Z0-9_:-]{1,64}$')
|
||||||
|
|
||||||
|
# Опасные HTML теги и атрибуты
|
||||||
|
DANGEROUS_TAGS = {'script', 'iframe', 'object', 'embed', 'form', 'input', 'button'}
|
||||||
|
DANGEROUS_ATTRIBUTES = {'onclick', 'onload', 'onerror', 'onmouseover', 'href', 'src'}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
logger.info("🔍 InputValidator инициализирован")
|
||||||
|
|
||||||
|
def validate_telegram_id(self, user_id: int) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Валидация Telegram ID пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID пользователя Telegram
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult с результатом валидации
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not isinstance(user_id, int):
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
f"Telegram ID должен быть числом, получен: {type(user_id).__name__}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_id < self.MIN_TELEGRAM_ID:
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
f"Telegram ID должен быть больше {self.MIN_TELEGRAM_ID}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_id > self.MAX_TELEGRAM_ID:
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
f"Telegram ID должен быть меньше {self.MAX_TELEGRAM_ID}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"✅ Telegram ID {user_id} прошел валидацию")
|
||||||
|
return ValidationResult(True, sanitized_value=str(user_id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка валидации Telegram ID {user_id}: {e}")
|
||||||
|
return ValidationResult(False, f"Ошибка валидации Telegram ID: {str(e)}")
|
||||||
|
|
||||||
|
def validate_username(self, username: str) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Валидация username пользователя
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: Username пользователя (без @)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult с результатом валидации
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not username:
|
||||||
|
return ValidationResult(True, sanitized_value="") # Username может быть пустым
|
||||||
|
|
||||||
|
# Убираем @ если есть
|
||||||
|
username = username.lstrip('@')
|
||||||
|
|
||||||
|
if len(username) < self.MIN_USERNAME_LENGTH:
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
f"Username должен содержать минимум {self.MIN_USERNAME_LENGTH} символ"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(username) > self.MAX_USERNAME_LENGTH:
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
f"Username не должен превышать {self.MAX_USERNAME_LENGTH} символов"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.USERNAME_PATTERN.match(username):
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
"Username может содержать только латинские буквы, цифры и подчеркивания"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"✅ Username '{username}' прошел валидацию")
|
||||||
|
return ValidationResult(True, sanitized_value=username)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка валидации username '{username}': {e}")
|
||||||
|
return ValidationResult(False, f"Ошибка валидации username: {str(e)}")
|
||||||
|
|
||||||
|
def validate_text_content(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
min_length: int = 1,
|
||||||
|
max_length: int = MAX_TEXT_LENGTH,
|
||||||
|
content_type: str = "текст"
|
||||||
|
) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Валидация текстового контента
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст для валидации
|
||||||
|
min_length: Минимальная длина
|
||||||
|
max_length: Максимальная длина
|
||||||
|
content_type: Тип контента для сообщений об ошибках
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult с результатом валидации
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not text:
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
f"{content_type.capitalize()} не может быть пустым"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем длину до санитизации
|
||||||
|
if len(text) > max_length:
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
f"{content_type.capitalize()} слишком длинный (максимум {max_length} символов)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Санитизируем HTML
|
||||||
|
sanitized_text = self.sanitize_html(text)
|
||||||
|
|
||||||
|
# Проверяем длину после санитизации
|
||||||
|
if len(sanitized_text.strip()) < min_length:
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
f"{content_type.capitalize()} должен содержать минимум {min_length} символов"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем на спам (повторяющиеся символы)
|
||||||
|
if self._is_spam_text(sanitized_text):
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
f"{content_type.capitalize()} содержит слишком много повторяющихся символов"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"✅ {content_type} прошел валидацию (длина: {len(sanitized_text)})")
|
||||||
|
return ValidationResult(True, sanitized_value=sanitized_text)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка валидации {content_type}: {e}")
|
||||||
|
return ValidationResult(False, f"Ошибка валидации {content_type}: {str(e)}")
|
||||||
|
|
||||||
|
def validate_question_text(self, text: str, max_length: int = 1000) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Валидация текста вопроса
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст вопроса
|
||||||
|
max_length: Максимальная длина вопроса
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult с результатом валидации
|
||||||
|
"""
|
||||||
|
return self.validate_text_content(
|
||||||
|
text,
|
||||||
|
min_length=MIN_QUESTION_LENGTH,
|
||||||
|
max_length=max_length,
|
||||||
|
content_type="вопрос"
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_answer_text(self, text: str, max_length: int = 2000) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Валидация текста ответа
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст ответа
|
||||||
|
max_length: Максимальная длина ответа
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult с результатом валидации
|
||||||
|
"""
|
||||||
|
return self.validate_text_content(
|
||||||
|
text,
|
||||||
|
min_length=MIN_ANSWER_LENGTH,
|
||||||
|
max_length=max_length,
|
||||||
|
content_type="ответ"
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_callback_data(self, data: str) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Валидация callback data
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Callback data для валидации
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult с результатом валидации
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not data:
|
||||||
|
return ValidationResult(False, "Callback data не может быть пустым")
|
||||||
|
|
||||||
|
if len(data) > self.MAX_CALLBACK_DATA_LENGTH:
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
f"Callback data не должен превышать {self.MAX_CALLBACK_DATA_LENGTH} символов"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.CALLBACK_DATA_PATTERN.match(data):
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
"Callback data содержит недопустимые символы"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"✅ Callback data '{data}' прошел валидацию")
|
||||||
|
return ValidationResult(True, sanitized_value=data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка валидации callback data '{data}': {e}")
|
||||||
|
return ValidationResult(False, f"Ошибка валидации callback data: {str(e)}")
|
||||||
|
|
||||||
|
def validate_deep_link(self, link: str) -> ValidationResult:
|
||||||
|
"""
|
||||||
|
Валидация deep link
|
||||||
|
|
||||||
|
Args:
|
||||||
|
link: Deep link для валидации
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult с результатом валидации
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not link:
|
||||||
|
return ValidationResult(False, "Deep link не может быть пустым")
|
||||||
|
|
||||||
|
if len(link) > 64: # Telegram deep link limit
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
"Deep link не должен превышать 64 символа"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.DEEP_LINK_PATTERN.match(link):
|
||||||
|
return ValidationResult(
|
||||||
|
False,
|
||||||
|
"Deep link содержит недопустимые символы"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"✅ Deep link '{link}' прошел валидацию")
|
||||||
|
return ValidationResult(True, sanitized_value=link)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка валидации deep link '{link}': {e}")
|
||||||
|
return ValidationResult(False, f"Ошибка валидации deep link: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html(self, text: str) -> str:
|
||||||
|
"""
|
||||||
|
Санитизация HTML в тексте
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст для санитизации
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Санитизированный текст
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Экранируем HTML сущности
|
||||||
|
sanitized = html.escape(text, quote=True)
|
||||||
|
|
||||||
|
# Проверяем количество HTML сущностей (защита от спама)
|
||||||
|
html_entities_count = len(re.findall(r'&[a-zA-Z0-9#]+;', sanitized))
|
||||||
|
if html_entities_count > self.MAX_HTML_ENTITIES:
|
||||||
|
logger.warning(f"⚠️ Обнаружено много HTML сущностей в тексте: {html_entities_count}")
|
||||||
|
# Убираем лишние HTML сущности
|
||||||
|
sanitized = re.sub(r'&[a-zA-Z0-9#]+;', '', sanitized)
|
||||||
|
|
||||||
|
return sanitized.strip()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка санитизации HTML: {e}")
|
||||||
|
return text.strip() # Возвращаем исходный текст в случае ошибки
|
||||||
|
|
||||||
|
def _is_spam_text(self, text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Проверка текста на спам (повторяющиеся символы)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Текст для проверки
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True если текст похож на спам
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if len(text) < 10:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Проверяем повторяющиеся символы
|
||||||
|
char_counts = {}
|
||||||
|
for char in text:
|
||||||
|
char_counts[char] = char_counts.get(char, 0) + 1
|
||||||
|
|
||||||
|
# Если какой-то символ повторяется более 50% от длины текста
|
||||||
|
max_count = max(char_counts.values())
|
||||||
|
if max_count > len(text) * 0.5:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Проверяем повторяющиеся слова
|
||||||
|
words = text.split()
|
||||||
|
if len(words) > 3:
|
||||||
|
word_counts = {}
|
||||||
|
for word in words:
|
||||||
|
word_counts[word] = word_counts.get(word, 0) + 1
|
||||||
|
|
||||||
|
max_word_count = max(word_counts.values())
|
||||||
|
if max_word_count > len(words) * 0.6:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка проверки на спам: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
319
tests/IMPLEMENTATION_PLAN.md
Normal file
319
tests/IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
# 📋 План реализации тестов для AnonBot
|
||||||
|
|
||||||
|
## 🎯 Общая информация
|
||||||
|
|
||||||
|
**Всего файлов для тестирования: 25-30 файлов**
|
||||||
|
**Цель покрытия: 80%+**
|
||||||
|
**Время реализации: 6-9 дней**
|
||||||
|
|
||||||
|
## 📊 Статус реализации
|
||||||
|
|
||||||
|
### ✅ Создано (структура)
|
||||||
|
- [x] Базовая структура тестов
|
||||||
|
- [x] Конфигурация pytest
|
||||||
|
- [x] Фикстуры и моки
|
||||||
|
- [x] Все файлы тестов (заглушки)
|
||||||
|
- [x] Документация тестов
|
||||||
|
- [x] Примеры тестов
|
||||||
|
|
||||||
|
### 🔄 В процессе
|
||||||
|
- [ ] Реализация unit тестов для моделей
|
||||||
|
- [ ] Реализация unit тестов для валидации
|
||||||
|
- [ ] Реализация unit тестов для авторизации
|
||||||
|
- [ ] Реализация unit тестов для CRUD
|
||||||
|
- [ ] Реализация unit тестов для бизнес-сервисов
|
||||||
|
|
||||||
|
### ⏳ Планируется
|
||||||
|
- [ ] Реализация unit тестов для обработчиков
|
||||||
|
- [ ] Реализация unit тестов для middleware
|
||||||
|
- [ ] Реализация unit тестов для инфраструктурных сервисов
|
||||||
|
- [ ] Реализация unit тестов для утилит
|
||||||
|
- [ ] Реализация unit тестов для конфигурации
|
||||||
|
- [ ] Реализация интеграционных тестов
|
||||||
|
|
||||||
|
## 🚀 Этапы реализации
|
||||||
|
|
||||||
|
### Этап 1: Критически важные компоненты (3-4 дня)
|
||||||
|
|
||||||
|
#### 1.1 Модели данных (1 день)
|
||||||
|
- [ ] `test_user.py` - полная реализация
|
||||||
|
- [ ] `test_question.py` - полная реализация
|
||||||
|
- [ ] `test_user_block.py` - полная реализация
|
||||||
|
- [ ] `test_user_settings.py` - полная реализация
|
||||||
|
|
||||||
|
#### 1.2 Валидация (1 день)
|
||||||
|
- [ ] `test_input_validator.py` - полная реализация
|
||||||
|
- [ ] `test_validation_middleware.py` - полная реализация
|
||||||
|
|
||||||
|
#### 1.3 Авторизация (1 день)
|
||||||
|
- [ ] `test_auth_service.py` - полная реализация
|
||||||
|
- [ ] `test_permissions.py` - полная реализация
|
||||||
|
|
||||||
|
#### 1.4 CRUD операции (1 день)
|
||||||
|
- [ ] `test_crud.py` - полная реализация
|
||||||
|
|
||||||
|
### Этап 2: Важные компоненты (2-3 дня)
|
||||||
|
|
||||||
|
#### 2.1 Бизнес-сервисы (1 день)
|
||||||
|
- [ ] `test_user_service.py` - полная реализация
|
||||||
|
- [ ] `test_question_service.py` - полная реализация
|
||||||
|
- [ ] `test_message_service.py` - полная реализация
|
||||||
|
- [ ] `test_pagination_service.py` - полная реализация
|
||||||
|
|
||||||
|
#### 2.2 Обработчики (1 день)
|
||||||
|
- [ ] `test_start.py` - полная реализация
|
||||||
|
- [ ] `test_questions.py` - полная реализация
|
||||||
|
- [ ] `test_answers.py` - полная реализация
|
||||||
|
- [ ] `test_admin.py` - полная реализация
|
||||||
|
|
||||||
|
#### 2.3 Middleware (0.5 дня)
|
||||||
|
- [ ] `test_validation_middleware.py` - полная реализация
|
||||||
|
- [ ] `test_rate_limit_middleware.py` - полная реализация
|
||||||
|
|
||||||
|
#### 2.4 Инфраструктурные сервисы (0.5 дня)
|
||||||
|
- [ ] `test_database.py` - полная реализация
|
||||||
|
- [ ] `test_metrics.py` - полная реализация
|
||||||
|
|
||||||
|
### Этап 3: Дополнительные компоненты (1-2 дня)
|
||||||
|
|
||||||
|
#### 3.1 Утилиты (0.5 дня)
|
||||||
|
- [ ] `test_utils.py` - полная реализация
|
||||||
|
|
||||||
|
#### 3.2 Конфигурация (0.5 дня)
|
||||||
|
- [ ] `test_config.py` - полная реализация
|
||||||
|
- [ ] `test_constants.py` - полная реализация
|
||||||
|
|
||||||
|
#### 3.3 Интеграционные тесты (1 день)
|
||||||
|
- [ ] `test_database_integration.py` - полная реализация
|
||||||
|
- [ ] `test_bot_integration.py` - полная реализация
|
||||||
|
|
||||||
|
## 📝 Детальный план по файлам
|
||||||
|
|
||||||
|
### Модели данных
|
||||||
|
|
||||||
|
#### `test_user.py`
|
||||||
|
- [ ] `test_user_creation_basic()` - создание пользователя
|
||||||
|
- [ ] `test_user_creation_with_all_fields()` - создание со всеми полями
|
||||||
|
- [ ] `test_user_validation_telegram_id()` - валидация ID
|
||||||
|
- [ ] `test_user_display_name()` - отображение имени
|
||||||
|
- [ ] `test_user_profile_link_generation()` - генерация ссылки
|
||||||
|
- [ ] `test_user_html_escaping()` - HTML экранирование
|
||||||
|
- [ ] `test_user_serialization()` - сериализация
|
||||||
|
- [ ] `test_user_deserialization()` - десериализация
|
||||||
|
|
||||||
|
#### `test_question.py`
|
||||||
|
- [ ] `test_question_creation_basic()` - создание вопроса
|
||||||
|
- [ ] `test_question_status_values()` - статусы вопросов
|
||||||
|
- [ ] `test_question_validation_message_text()` - валидация текста
|
||||||
|
- [ ] `test_question_mark_as_answered()` - отметка как отвеченный
|
||||||
|
- [ ] `test_question_formatting_methods()` - методы форматирования
|
||||||
|
|
||||||
|
#### `test_user_block.py`
|
||||||
|
- [ ] `test_user_block_creation_basic()` - создание блокировки
|
||||||
|
- [ ] `test_user_block_validation_different_ids()` - валидация ID
|
||||||
|
- [ ] `test_user_block_created_at_timestamp()` - временная метка
|
||||||
|
- [ ] `test_user_block_serialization()` - сериализация
|
||||||
|
|
||||||
|
#### `test_user_settings.py`
|
||||||
|
- [ ] `test_user_settings_creation_basic()` - создание настроек
|
||||||
|
- [ ] `test_user_settings_default_values()` - значения по умолчанию
|
||||||
|
- [ ] `test_user_settings_validation_language()` - валидация языка
|
||||||
|
- [ ] `test_user_settings_boolean_flags()` - булевы флаги
|
||||||
|
|
||||||
|
### Валидация
|
||||||
|
|
||||||
|
#### `test_input_validator.py`
|
||||||
|
- [ ] `test_validate_telegram_id_valid()` - валидация ID
|
||||||
|
- [ ] `test_validate_username_valid()` - валидация username
|
||||||
|
- [ ] `test_validate_text_content_valid()` - валидация текста
|
||||||
|
- [ ] `test_validate_deep_link_valid()` - валидация deep link
|
||||||
|
- [ ] `test_validate_callback_data_valid()` - валидация callback
|
||||||
|
- [ ] `test_sanitize_html_basic()` - HTML санитизация
|
||||||
|
- [ ] `test_is_spam_repeating_characters()` - спам-фильтры
|
||||||
|
|
||||||
|
#### `test_validation_middleware.py`
|
||||||
|
- [ ] `test_validate_callback_query_valid()` - валидация callback
|
||||||
|
- [ ] `test_validate_message_valid()` - валидация сообщений
|
||||||
|
- [ ] `test_validation_error_handling()` - обработка ошибок
|
||||||
|
- [ ] `test_sanitized_data_injection()` - инъекция данных
|
||||||
|
|
||||||
|
### Авторизация
|
||||||
|
|
||||||
|
#### `test_auth_service.py`
|
||||||
|
- [ ] `test_is_admin_valid_admin()` - проверка админа
|
||||||
|
- [ ] `test_is_superuser_valid_superuser()` - проверка суперпользователя
|
||||||
|
- [ ] `test_get_user_role_admin()` - получение роли
|
||||||
|
- [ ] `test_has_permission_valid_permission()` - проверка разрешений
|
||||||
|
|
||||||
|
#### `test_permissions.py`
|
||||||
|
- [ ] `test_admin_permission_check_valid_admin()` - проверка админского разрешения
|
||||||
|
- [ ] `test_superuser_permission_check_valid_superuser()` - проверка суперпользовательского разрешения
|
||||||
|
- [ ] `test_permission_registry_creation()` - создание реестра
|
||||||
|
- [ ] `test_require_permission_decorator()` - декоратор разрешений
|
||||||
|
|
||||||
|
### CRUD операции
|
||||||
|
|
||||||
|
#### `test_crud.py`
|
||||||
|
- [ ] `test_create_user_basic()` - создание пользователя
|
||||||
|
- [ ] `test_create_question_basic()` - создание вопроса
|
||||||
|
- [ ] `test_create_batch_users()` - batch создание
|
||||||
|
- [ ] `test_get_by_telegram_id_existing()` - получение по ID
|
||||||
|
- [ ] `test_update_user_existing()` - обновление
|
||||||
|
- [ ] `test_delete_user_existing()` - удаление
|
||||||
|
- [ ] `test_cursor_pagination()` - cursor пагинация
|
||||||
|
|
||||||
|
### Бизнес-сервисы
|
||||||
|
|
||||||
|
#### `test_user_service.py`
|
||||||
|
- [ ] `test_create_or_update_user_new_user()` - создание пользователя
|
||||||
|
- [ ] `test_get_user_by_id_existing()` - получение по ID
|
||||||
|
- [ ] `test_user_exists_true()` - проверка существования
|
||||||
|
- [ ] `test_format_user_info()` - форматирование
|
||||||
|
|
||||||
|
#### `test_question_service.py`
|
||||||
|
- [ ] `test_create_question_basic()` - создание вопроса
|
||||||
|
- [ ] `test_answer_question_valid()` - ответ на вопрос
|
||||||
|
- [ ] `test_edit_answer_valid()` - редактирование ответа
|
||||||
|
- [ ] `test_delete_question_existing()` - удаление вопроса
|
||||||
|
|
||||||
|
#### `test_message_service.py`
|
||||||
|
- [ ] `test_send_message_basic()` - отправка сообщения
|
||||||
|
- [ ] `test_send_message_with_keyboard()` - отправка с клавиатурой
|
||||||
|
- [ ] `test_send_error_message()` - отправка ошибки
|
||||||
|
- [ ] `test_format_message_basic()` - форматирование
|
||||||
|
|
||||||
|
#### `test_pagination_service.py`
|
||||||
|
- [ ] `test_offset_pagination_basic()` - offset пагинация
|
||||||
|
- [ ] `test_cursor_pagination_basic()` - cursor пагинация
|
||||||
|
- [ ] `test_validate_pagination_params_valid()` - валидация параметров
|
||||||
|
- [ ] `test_format_pagination_info_basic()` - форматирование
|
||||||
|
|
||||||
|
### Обработчики
|
||||||
|
|
||||||
|
#### `test_start.py`
|
||||||
|
- [ ] `test_cmd_start_basic()` - команда /start
|
||||||
|
- [ ] `test_cmd_start_with_deep_link()` - /start с deep link
|
||||||
|
- [ ] `test_handle_deep_link_valid()` - обработка deep link
|
||||||
|
- [ ] `test_process_start_command_new_user()` - обработка для нового пользователя
|
||||||
|
|
||||||
|
#### `test_questions.py`
|
||||||
|
- [ ] `test_process_anonymous_question_valid()` - обработка вопроса
|
||||||
|
- [ ] `test_my_questions_button_with_questions()` - кнопка вопросов
|
||||||
|
- [ ] `test_answer_question_callback_valid()` - callback ответа
|
||||||
|
- [ ] `test_format_questions_list_basic()` - форматирование списка
|
||||||
|
|
||||||
|
#### `test_answers.py`
|
||||||
|
- [ ] `test_process_new_answer_valid()` - обработка ответа
|
||||||
|
- [ ] `test_view_question_callback_valid()` - просмотр вопроса
|
||||||
|
- [ ] `test_edit_answer_callback_valid()` - редактирование ответа
|
||||||
|
- [ ] `test_delete_answer_callback_valid()` - удаление ответа
|
||||||
|
|
||||||
|
#### `test_admin.py`
|
||||||
|
- [ ] `test_admin_menu_basic()` - админское меню
|
||||||
|
- [ ] `test_admin_stats_basic()` - админская статистика
|
||||||
|
- [ ] `test_assign_superuser_callback_valid()` - назначение суперпользователя
|
||||||
|
- [ ] `test_permission_checking_admin_required()` - проверка прав
|
||||||
|
|
||||||
|
### Middleware
|
||||||
|
|
||||||
|
#### `test_validation_middleware.py`
|
||||||
|
- [ ] `test_validate_callback_query_valid()` - валидация callback
|
||||||
|
- [ ] `test_validate_message_valid()` - валидация сообщений
|
||||||
|
- [ ] `test_validation_error_handling()` - обработка ошибок
|
||||||
|
- [ ] `test_sanitized_data_injection()` - инъекция данных
|
||||||
|
|
||||||
|
#### `test_rate_limit_middleware.py`
|
||||||
|
- [ ] `test_apply_rate_limit_to_message()` - применение rate limiting
|
||||||
|
- [ ] `test_skip_rate_limit_for_callback_query()` - пропуск для callback
|
||||||
|
- [ ] `test_handle_telegram_retry_after()` - обработка retry after
|
||||||
|
- [ ] `test_rate_limit_success()` - успешный rate limiting
|
||||||
|
|
||||||
|
### Инфраструктурные сервисы
|
||||||
|
|
||||||
|
#### `test_database.py`
|
||||||
|
- [ ] `test_database_service_initialization()` - инициализация
|
||||||
|
- [ ] `test_connect_to_database_success()` - подключение
|
||||||
|
- [ ] `test_create_tables_success()` - создание таблиц
|
||||||
|
- [ ] `test_connection_pool_management()` - управление пулом
|
||||||
|
|
||||||
|
#### `test_metrics.py`
|
||||||
|
- [ ] `test_metrics_service_initialization()` - инициализация
|
||||||
|
- [ ] `test_create_counter_metric()` - создание счетчика
|
||||||
|
- [ ] `test_increment_counter()` - инкремент счетчика
|
||||||
|
- [ ] `test_export_metrics_prometheus_format()` - экспорт метрик
|
||||||
|
|
||||||
|
### Утилиты
|
||||||
|
|
||||||
|
#### `test_utils.py`
|
||||||
|
- [ ] `test_format_user_data_basic()` - форматирование данных пользователя
|
||||||
|
- [ ] `test_is_valid_question_text_valid()` - валидация текста вопроса
|
||||||
|
- [ ] `test_escape_html_basic()` - HTML экранирование
|
||||||
|
- [ ] `test_generate_profile_link()` - генерация ссылки
|
||||||
|
|
||||||
|
### Конфигурация
|
||||||
|
|
||||||
|
#### `test_config.py`
|
||||||
|
- [ ] `test_config_initialization()` - инициализация
|
||||||
|
- [ ] `test_load_config_from_env()` - загрузка из .env
|
||||||
|
- [ ] `test_config_validation_telegram_token()` - валидация токена
|
||||||
|
- [ ] `test_config_error_handling()` - обработка ошибок
|
||||||
|
|
||||||
|
#### `test_constants.py`
|
||||||
|
- [ ] `test_question_constants()` - константы вопросов
|
||||||
|
- [ ] `test_answer_constants()` - константы ответов
|
||||||
|
- [ ] `test_validation_constants()` - константы валидации
|
||||||
|
- [ ] `test_constants_consistency()` - консистентность
|
||||||
|
|
||||||
|
### Интеграционные тесты
|
||||||
|
|
||||||
|
#### `test_database_integration.py`
|
||||||
|
- [ ] `test_full_user_lifecycle()` - полный жизненный цикл пользователя
|
||||||
|
- [ ] `test_full_question_lifecycle()` - полный жизненный цикл вопроса
|
||||||
|
- [ ] `test_database_transactions()` - транзакции
|
||||||
|
- [ ] `test_database_performance()` - производительность
|
||||||
|
|
||||||
|
#### `test_bot_integration.py`
|
||||||
|
- [ ] `test_bot_initialization()` - инициализация бота
|
||||||
|
- [ ] `test_full_start_command_flow()` - полный поток /start
|
||||||
|
- [ ] `test_full_question_flow()` - полный поток вопроса
|
||||||
|
- [ ] `test_middleware_chain()` - цепочка middleware
|
||||||
|
|
||||||
|
## 🎯 Критерии готовности
|
||||||
|
|
||||||
|
### Unit тесты
|
||||||
|
- [ ] Все тесты проходят
|
||||||
|
- [ ] Покрытие кода 80%+
|
||||||
|
- [ ] Все граничные случаи покрыты
|
||||||
|
- [ ] Обработка ошибок протестирована
|
||||||
|
- [ ] Производительность приемлема
|
||||||
|
|
||||||
|
### Интеграционные тесты
|
||||||
|
- [ ] Все сценарии работают
|
||||||
|
- [ ] Интеграция компонентов протестирована
|
||||||
|
- [ ] End-to-end тесты проходят
|
||||||
|
- [ ] Производительность приемлема
|
||||||
|
|
||||||
|
### Общие критерии
|
||||||
|
- [ ] Документация обновлена
|
||||||
|
- [ ] Примеры тестов созданы
|
||||||
|
- [ ] CI/CD настроен
|
||||||
|
- [ ] Отчеты о покрытии генерируются
|
||||||
|
|
||||||
|
## 🚀 Следующие шаги
|
||||||
|
|
||||||
|
1. **Начать с моделей данных** - это основа для всех остальных тестов
|
||||||
|
2. **Реализовать валидацию** - критически важно для безопасности
|
||||||
|
3. **Добавить авторизацию** - важно для контроля доступа
|
||||||
|
4. **Покрыть CRUD операции** - основа работы с данными
|
||||||
|
5. **Тестировать бизнес-сервисы** - основная логика приложения
|
||||||
|
6. **Добавить обработчики** - пользовательский интерфейс
|
||||||
|
7. **Покрыть middleware** - инфраструктурные компоненты
|
||||||
|
8. **Добавить интеграционные тесты** - полные сценарии
|
||||||
|
|
||||||
|
## 📊 Метрики успеха
|
||||||
|
|
||||||
|
- **Покрытие кода**: 80%+
|
||||||
|
- **Время выполнения тестов**: < 30 секунд
|
||||||
|
- **Количество тестов**: 200+ unit тестов, 20+ интеграционных
|
||||||
|
- **Прохождение тестов**: 100%
|
||||||
|
- **Документация**: Полная и актуальная
|
||||||
287
tests/README.md
Normal file
287
tests/README.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
# 🧪 Тесты для AnonBot
|
||||||
|
|
||||||
|
## 📁 Структура тестов
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── __init__.py
|
||||||
|
├── conftest.py # Конфигурация pytest
|
||||||
|
├── requirements.txt # Тестовые зависимости
|
||||||
|
├── README.md # Документация тестов
|
||||||
|
├── unit/ # Unit тесты
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── models/ # Тесты моделей данных
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── test_user.py
|
||||||
|
│ │ ├── test_question.py
|
||||||
|
│ │ ├── test_user_block.py
|
||||||
|
│ │ └── test_user_settings.py
|
||||||
|
│ ├── services/ # Тесты сервисов
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── validation/ # Тесты валидации
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── test_input_validator.py
|
||||||
|
│ │ │ └── test_validation_middleware.py
|
||||||
|
│ │ ├── auth/ # Тесты авторизации
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── test_auth_service.py
|
||||||
|
│ │ │ └── test_permissions.py
|
||||||
|
│ │ ├── business/ # Тесты бизнес-сервисов
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── test_user_service.py
|
||||||
|
│ │ │ ├── test_question_service.py
|
||||||
|
│ │ │ ├── test_message_service.py
|
||||||
|
│ │ │ └── test_pagination_service.py
|
||||||
|
│ │ ├── infrastructure/ # Тесты инфраструктурных сервисов
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── test_database.py
|
||||||
|
│ │ │ └── test_metrics.py
|
||||||
|
│ │ └── test_utils.py # Тесты утилит
|
||||||
|
│ ├── database/ # Тесты CRUD операций
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ └── test_crud.py
|
||||||
|
│ ├── handlers/ # Тесты обработчиков
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── test_start.py
|
||||||
|
│ │ ├── test_questions.py
|
||||||
|
│ │ ├── test_answers.py
|
||||||
|
│ │ └── test_admin.py
|
||||||
|
│ ├── middlewares/ # Тесты middleware
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── test_validation_middleware.py
|
||||||
|
│ │ └── test_rate_limit_middleware.py
|
||||||
|
│ └── config/ # Тесты конфигурации
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── test_config.py
|
||||||
|
│ └── test_constants.py
|
||||||
|
└── integration/ # Интеграционные тесты
|
||||||
|
├── __init__.py
|
||||||
|
├── test_database_integration.py
|
||||||
|
└── test_bot_integration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Запуск тестов
|
||||||
|
|
||||||
|
### Установка зависимостей
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r tests/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск всех тестов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск unit тестов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/unit/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск интеграционных тестов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/integration/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск тестов с покрытием
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest --cov=. --cov-report=html
|
||||||
|
```
|
||||||
|
|
||||||
|
### Запуск тестов по категориям
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Тесты моделей
|
||||||
|
pytest -m models
|
||||||
|
|
||||||
|
# Тесты сервисов
|
||||||
|
pytest -m services
|
||||||
|
|
||||||
|
# Тесты БД
|
||||||
|
pytest -m database
|
||||||
|
|
||||||
|
# Тесты авторизации
|
||||||
|
pytest -m auth
|
||||||
|
|
||||||
|
# Тесты валидации
|
||||||
|
pytest -m validation
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Покрытие кода
|
||||||
|
|
||||||
|
Цель покрытия: **80%+**
|
||||||
|
|
||||||
|
### Приоритеты покрытия:
|
||||||
|
|
||||||
|
1. **Критически важные компоненты (90%+)**:
|
||||||
|
- Модели данных
|
||||||
|
- Валидация
|
||||||
|
- Авторизация
|
||||||
|
- CRUD операции
|
||||||
|
- Бизнес-сервисы
|
||||||
|
|
||||||
|
2. **Важные компоненты (80%+)**:
|
||||||
|
- Обработчики
|
||||||
|
- Middleware
|
||||||
|
- Инфраструктурные сервисы
|
||||||
|
|
||||||
|
3. **Дополнительные компоненты (70%+)**:
|
||||||
|
- Утилиты
|
||||||
|
- Конфигурация
|
||||||
|
- Интеграционные тесты
|
||||||
|
|
||||||
|
## 🎯 Что тестировать
|
||||||
|
|
||||||
|
### Модели данных
|
||||||
|
- Создание объектов
|
||||||
|
- Валидация полей
|
||||||
|
- Методы форматирования
|
||||||
|
- HTML экранирование
|
||||||
|
- Сериализация/десериализация
|
||||||
|
|
||||||
|
### Валидация
|
||||||
|
- Валидация Telegram ID
|
||||||
|
- Валидация текстового контента
|
||||||
|
- Валидация deep links
|
||||||
|
- Валидация callback data
|
||||||
|
- HTML санитизация
|
||||||
|
- Спам-фильтры
|
||||||
|
|
||||||
|
### Авторизация
|
||||||
|
- Проверка ролей
|
||||||
|
- Система разрешений
|
||||||
|
- Проверка прав доступа
|
||||||
|
- Обработка ошибок
|
||||||
|
|
||||||
|
### CRUD операции
|
||||||
|
- Создание, чтение, обновление, удаление
|
||||||
|
- Batch операции
|
||||||
|
- Cursor-based пагинация
|
||||||
|
- Обработка ошибок БД
|
||||||
|
- Транзакции
|
||||||
|
|
||||||
|
### Бизнес-сервисы
|
||||||
|
- Создание/обновление пользователей
|
||||||
|
- Обработка вопросов и ответов
|
||||||
|
- Форматирование сообщений
|
||||||
|
- Пагинация
|
||||||
|
- Интеграция с БД
|
||||||
|
|
||||||
|
### Обработчики
|
||||||
|
- Обработка команд
|
||||||
|
- Deep linking
|
||||||
|
- FSM состояния
|
||||||
|
- Callback обработчики
|
||||||
|
- Валидация входных данных
|
||||||
|
|
||||||
|
### Middleware
|
||||||
|
- Валидация входящих данных
|
||||||
|
- Rate limiting
|
||||||
|
- Обработка ошибок
|
||||||
|
- Пропуск событий
|
||||||
|
|
||||||
|
## 🔧 Настройка тестов
|
||||||
|
|
||||||
|
### Переменные окружения
|
||||||
|
|
||||||
|
Создайте `.env.test` файл:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Тестовая конфигурация
|
||||||
|
TELEGRAM_BOT_TOKEN=test_token
|
||||||
|
DATABASE_PATH=:memory:
|
||||||
|
ADMINS=123456789,987654321
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
METRICS_PORT=9090
|
||||||
|
```
|
||||||
|
|
||||||
|
### Фикстуры
|
||||||
|
|
||||||
|
Основные фикстуры в `conftest.py`:
|
||||||
|
- `mock_database` - мок для DatabaseService
|
||||||
|
- `mock_auth` - мок для AuthService
|
||||||
|
- `mock_validator` - мок для InputValidator
|
||||||
|
- `mock_utils` - мок для UtilsService
|
||||||
|
- `mock_config` - мок для конфигурации
|
||||||
|
|
||||||
|
## 📝 Примеры тестов
|
||||||
|
|
||||||
|
### Unit тест
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_user_creation_basic():
|
||||||
|
"""Тест базового создания пользователя"""
|
||||||
|
user = User(
|
||||||
|
telegram_id=123456789,
|
||||||
|
first_name="Test",
|
||||||
|
chat_id=123456789,
|
||||||
|
profile_link="test_link"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert user.telegram_id == 123456789
|
||||||
|
assert user.first_name == "Test"
|
||||||
|
assert user.is_active is True
|
||||||
|
assert user.is_superuser is False
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async тест
|
||||||
|
|
||||||
|
```python
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_user_async():
|
||||||
|
"""Тест асинхронного создания пользователя"""
|
||||||
|
user_service = UserService(mock_database)
|
||||||
|
user = await user_service.create_or_update_user(telegram_user, chat_id)
|
||||||
|
|
||||||
|
assert user.telegram_id == telegram_user.id
|
||||||
|
assert user.first_name == telegram_user.first_name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тест с моками
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_auth_service_is_admin(mock_config):
|
||||||
|
"""Тест проверки администратора"""
|
||||||
|
auth_service = AuthService(mock_database, mock_config)
|
||||||
|
|
||||||
|
assert auth_service.is_admin(123456789) is True
|
||||||
|
assert auth_service.is_admin(999999999) is False
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 Обработка ошибок
|
||||||
|
|
||||||
|
Все тесты должны:
|
||||||
|
- Проверять как успешные, так и ошибочные сценарии
|
||||||
|
- Использовать соответствующие исключения
|
||||||
|
- Проверять логирование ошибок
|
||||||
|
- Тестировать восстановление после ошибок
|
||||||
|
|
||||||
|
## 📈 Производительность
|
||||||
|
|
||||||
|
Тесты должны:
|
||||||
|
- Выполняться быстро (< 1 секунды для unit тестов)
|
||||||
|
- Использовать моки для внешних зависимостей
|
||||||
|
- Тестировать производительность критических компонентов
|
||||||
|
- Включать benchmark тесты для БД операций
|
||||||
|
|
||||||
|
## 🔍 Отладка
|
||||||
|
|
||||||
|
Для отладки тестов:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Запуск с подробным выводом
|
||||||
|
pytest -v -s
|
||||||
|
|
||||||
|
# Запуск конкретного теста
|
||||||
|
pytest tests/unit/models/test_user.py::TestUser::test_user_creation_basic
|
||||||
|
|
||||||
|
# Запуск с остановкой на первой ошибке
|
||||||
|
pytest -x
|
||||||
|
|
||||||
|
# Запуск с отладочным выводом
|
||||||
|
pytest --pdb
|
||||||
|
```
|
||||||
161
tests/SUMMARY.md
Normal file
161
tests/SUMMARY.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# 📋 Итоговая сводка по тестам AnonBot
|
||||||
|
|
||||||
|
## ✅ Что создано
|
||||||
|
|
||||||
|
### 📁 Структура тестов
|
||||||
|
```
|
||||||
|
tests/
|
||||||
|
├── __init__.py
|
||||||
|
├── conftest.py # Конфигурация pytest + фикстуры
|
||||||
|
├── requirements.txt # Тестовые зависимости
|
||||||
|
├── README.md # Документация тестов
|
||||||
|
├── IMPLEMENTATION_PLAN.md # План реализации
|
||||||
|
├── SUMMARY.md # Эта сводка
|
||||||
|
├── run_tests.sh # Скрипт запуска тестов
|
||||||
|
├── test_config.env # Тестовая конфигурация
|
||||||
|
├── unit/ # Unit тесты (25 файлов)
|
||||||
|
│ ├── models/ # 4 файла тестов моделей
|
||||||
|
│ ├── services/ # 8 файлов тестов сервисов
|
||||||
|
│ ├── database/ # 1 файл тестов CRUD
|
||||||
|
│ ├── handlers/ # 4 файла тестов обработчиков
|
||||||
|
│ ├── middlewares/ # 2 файла тестов middleware
|
||||||
|
│ └── config/ # 2 файла тестов конфигурации
|
||||||
|
└── integration/ # Интеграционные тесты (2 файла)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 Статистика файлов
|
||||||
|
- **Всего файлов тестов**: 30
|
||||||
|
- **Unit тесты**: 25 файлов
|
||||||
|
- **Интеграционные тесты**: 2 файла
|
||||||
|
- **Конфигурационные файлы**: 3 файла
|
||||||
|
- **Документация**: 3 файла
|
||||||
|
|
||||||
|
### 🎯 Покрытие компонентов
|
||||||
|
|
||||||
|
#### Критически важные (90%+ покрытие)
|
||||||
|
- ✅ **Модели данных** (4 файла)
|
||||||
|
- ✅ **Валидация** (2 файла)
|
||||||
|
- ✅ **Авторизация** (2 файла)
|
||||||
|
- ✅ **CRUD операции** (1 файл)
|
||||||
|
|
||||||
|
#### Важные (80%+ покрытие)
|
||||||
|
- ✅ **Бизнес-сервисы** (4 файла)
|
||||||
|
- ✅ **Обработчики** (4 файла)
|
||||||
|
- ✅ **Middleware** (2 файла)
|
||||||
|
- ✅ **Инфраструктурные сервисы** (2 файла)
|
||||||
|
|
||||||
|
#### Дополнительные (70%+ покрытие)
|
||||||
|
- ✅ **Утилиты** (1 файл)
|
||||||
|
- ✅ **Конфигурация** (2 файла)
|
||||||
|
- ✅ **Интеграционные тесты** (2 файла)
|
||||||
|
|
||||||
|
## 🚀 Готово к использованию
|
||||||
|
|
||||||
|
### ✅ Создано и готово
|
||||||
|
1. **Полная структура тестов** - все директории и файлы
|
||||||
|
2. **Конфигурация pytest** - настройки, маркеры, покрытие
|
||||||
|
3. **Фикстуры и моки** - для всех основных сервисов
|
||||||
|
4. **Скрипт запуска тестов** - с различными опциями
|
||||||
|
5. **Документация** - подробные инструкции
|
||||||
|
6. **План реализации** - пошаговый план
|
||||||
|
7. **Примеры тестов** - демонстрация подхода
|
||||||
|
|
||||||
|
### 🔄 Требует реализации
|
||||||
|
1. **Содержимое тестов** - все файлы содержат только заглушки с TODO
|
||||||
|
2. **Реальные тесты** - нужно написать код тестов
|
||||||
|
3. **Настройка CI/CD** - интеграция с системой сборки
|
||||||
|
4. **Benchmark тесты** - тесты производительности
|
||||||
|
|
||||||
|
## 📝 Следующие шаги
|
||||||
|
|
||||||
|
### 1. Начать реализацию тестов
|
||||||
|
```bash
|
||||||
|
# Установить зависимости
|
||||||
|
pip install -r tests/requirements.txt
|
||||||
|
|
||||||
|
# Запустить пример теста
|
||||||
|
pytest tests/unit/models/test_user_example.py -v
|
||||||
|
|
||||||
|
# Запустить все тесты (пока будут падать)
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Реализовать по приоритетам
|
||||||
|
1. **Модели данных** - основа для всех остальных тестов
|
||||||
|
2. **Валидация** - критически важно для безопасности
|
||||||
|
3. **Авторизация** - важно для контроля доступа
|
||||||
|
4. **CRUD операции** - основа работы с данными
|
||||||
|
5. **Бизнес-сервисы** - основная логика приложения
|
||||||
|
|
||||||
|
### 3. Использовать план реализации
|
||||||
|
Следуйте `IMPLEMENTATION_PLAN.md` для пошаговой реализации.
|
||||||
|
|
||||||
|
## 🛠️ Технические детали
|
||||||
|
|
||||||
|
### Технологии
|
||||||
|
- **pytest** - основной фреймворк
|
||||||
|
- **pytest-asyncio** - для async тестов
|
||||||
|
- **pytest-mock** - для моков
|
||||||
|
- **pytest-cov** - для покрытия кода
|
||||||
|
- **aiogram-test** - для тестирования бота
|
||||||
|
|
||||||
|
### Конфигурация
|
||||||
|
- **pytest.ini** - настройки pytest
|
||||||
|
- **conftest.py** - фикстуры и моки
|
||||||
|
- **test_config.env** - тестовая конфигурация
|
||||||
|
|
||||||
|
### Скрипты
|
||||||
|
- **run_tests.sh** - запуск тестов с различными опциями
|
||||||
|
- **requirements.txt** - тестовые зависимости
|
||||||
|
|
||||||
|
## 📊 Ожидаемые результаты
|
||||||
|
|
||||||
|
### После полной реализации
|
||||||
|
- **Покрытие кода**: 80%+
|
||||||
|
- **Количество тестов**: 200+ unit тестов, 20+ интеграционных
|
||||||
|
- **Время выполнения**: < 30 секунд
|
||||||
|
- **Прохождение тестов**: 100%
|
||||||
|
|
||||||
|
### Преимущества
|
||||||
|
- **Надежность** - выявление ошибок на раннем этапе
|
||||||
|
- **Рефакторинг** - безопасные изменения кода
|
||||||
|
- **Документация** - тесты как живая документация
|
||||||
|
- **Качество** - повышение качества кода
|
||||||
|
|
||||||
|
## 🎯 Рекомендации
|
||||||
|
|
||||||
|
### Для разработки
|
||||||
|
1. **Начните с моделей** - они проще всего и нужны везде
|
||||||
|
2. **Используйте примеры** - `test_user_example.py` показывает подход
|
||||||
|
3. **Следуйте плану** - `IMPLEMENTATION_PLAN.md` содержит детальный план
|
||||||
|
4. **Тестируйте часто** - запускайте тесты после каждого изменения
|
||||||
|
|
||||||
|
### Для команды
|
||||||
|
1. **Изучите документацию** - `README.md` содержит подробные инструкции
|
||||||
|
2. **Используйте скрипты** - `run_tests.sh` упрощает запуск тестов
|
||||||
|
3. **Следуйте стандартам** - используйте существующие фикстуры и моки
|
||||||
|
4. **Документируйте изменения** - обновляйте тесты при изменении кода
|
||||||
|
|
||||||
|
## 🚨 Важные замечания
|
||||||
|
|
||||||
|
### Текущее состояние
|
||||||
|
- **Все файлы созданы** - структура готова
|
||||||
|
- **Содержимое пустое** - нужно написать код тестов
|
||||||
|
- **Конфигурация готова** - можно сразу начинать разработку
|
||||||
|
|
||||||
|
### Ограничения
|
||||||
|
- **Нет реальных тестов** - только заглушки
|
||||||
|
- **Нет CI/CD** - нужно настроить отдельно
|
||||||
|
- **Нет benchmark тестов** - нужно добавить при необходимости
|
||||||
|
|
||||||
|
### Следующие действия
|
||||||
|
1. **Реализовать тесты** - начать с моделей данных
|
||||||
|
2. **Настроить CI/CD** - интеграция с системой сборки
|
||||||
|
3. **Добавить benchmark тесты** - для тестирования производительности
|
||||||
|
4. **Обновить документацию** - по мере реализации тестов
|
||||||
|
|
||||||
|
## 🎉 Заключение
|
||||||
|
|
||||||
|
Структура тестов для AnonBot полностью готова к использованию. Все необходимые файлы созданы, конфигурация настроена, документация написана. Теперь можно приступать к реализации реальных тестов, следуя плану и используя созданные примеры.
|
||||||
|
|
||||||
|
**Готово к разработке! 🚀**
|
||||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Тесты для AnonBot
|
||||||
|
"""
|
||||||
483
tests/benchmark_db_performance.py
Normal file
483
tests/benchmark_db_performance.py
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
"""
|
||||||
|
Benchmark тесты для проверки производительности БД
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
import statistics
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from models.user import User
|
||||||
|
from models.question import Question, QuestionStatus
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
# from services.business.optimized_pagination_service import OptimizedPaginationService, PaginationCursor # Файл удален
|
||||||
|
from services.infrastructure.metrics import get_metrics_service
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BenchmarkResult:
|
||||||
|
"""Результат benchmark теста"""
|
||||||
|
operation: str
|
||||||
|
duration: float
|
||||||
|
items_processed: int
|
||||||
|
items_per_second: float
|
||||||
|
memory_usage: float = 0.0
|
||||||
|
error_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseBenchmark:
|
||||||
|
"""Класс для проведения benchmark тестов БД"""
|
||||||
|
|
||||||
|
def __init__(self, database: DatabaseService):
|
||||||
|
self.database = database
|
||||||
|
# self.pagination_service = OptimizedPaginationService() # Файл удален
|
||||||
|
self.metrics = get_metrics_service()
|
||||||
|
self.results: List[BenchmarkResult] = []
|
||||||
|
|
||||||
|
async def run_all_benchmarks(self) -> Dict[str, Any]:
|
||||||
|
"""Запуск всех benchmark тестов"""
|
||||||
|
print("🚀 Запуск benchmark тестов производительности БД...")
|
||||||
|
|
||||||
|
# Подготовка тестовых данных
|
||||||
|
await self._prepare_test_data()
|
||||||
|
|
||||||
|
# Запуск тестов
|
||||||
|
benchmarks = [
|
||||||
|
("single_insert_users", self._benchmark_single_insert_users),
|
||||||
|
("batch_insert_users", self._benchmark_batch_insert_users),
|
||||||
|
("single_insert_questions", self._benchmark_single_insert_questions),
|
||||||
|
("batch_insert_questions", self._benchmark_batch_insert_questions),
|
||||||
|
("offset_pagination", self._benchmark_offset_pagination),
|
||||||
|
("cursor_pagination", self._benchmark_cursor_pagination),
|
||||||
|
("n_plus_one_query", self._benchmark_n_plus_one_query),
|
||||||
|
("optimized_join_query", self._benchmark_optimized_join_query),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, benchmark_func in benchmarks:
|
||||||
|
try:
|
||||||
|
print(f"📊 Запуск теста: {name}")
|
||||||
|
result = await benchmark_func()
|
||||||
|
self.results.append(result)
|
||||||
|
print(f"✅ {name}: {result.items_per_second:.2f} items/sec")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка в тесте {name}: {e}")
|
||||||
|
self.results.append(BenchmarkResult(
|
||||||
|
operation=name,
|
||||||
|
duration=0.0,
|
||||||
|
items_processed=0,
|
||||||
|
items_per_second=0.0,
|
||||||
|
error_count=1
|
||||||
|
))
|
||||||
|
|
||||||
|
# Очистка тестовых данных
|
||||||
|
await self._cleanup_test_data()
|
||||||
|
|
||||||
|
return self._generate_report()
|
||||||
|
|
||||||
|
async def _prepare_test_data(self):
|
||||||
|
"""Подготовка тестовых данных"""
|
||||||
|
print("📝 Подготовка тестовых данных...")
|
||||||
|
|
||||||
|
# Создаем тестовых пользователей
|
||||||
|
self.test_users = []
|
||||||
|
for i in range(1000):
|
||||||
|
user = User(
|
||||||
|
telegram_id=9000000 + i,
|
||||||
|
username=f"test_user_{i}",
|
||||||
|
first_name=f"Test{i}",
|
||||||
|
last_name="User",
|
||||||
|
chat_id=9000000 + i,
|
||||||
|
profile_link=f"test_link_{i}",
|
||||||
|
is_active=True,
|
||||||
|
is_superuser=False
|
||||||
|
)
|
||||||
|
self.test_users.append(user)
|
||||||
|
|
||||||
|
# Создаем тестовые вопросы
|
||||||
|
self.test_questions = []
|
||||||
|
for i in range(5000):
|
||||||
|
question = Question(
|
||||||
|
from_user_id=9000000 + (i % 100), # Циклически используем пользователей
|
||||||
|
to_user_id=9000000 + ((i + 1) % 100),
|
||||||
|
message_text=f"Test question {i}",
|
||||||
|
status=QuestionStatus.PENDING
|
||||||
|
)
|
||||||
|
self.test_questions.append(question)
|
||||||
|
|
||||||
|
print(f"✅ Подготовлено {len(self.test_users)} пользователей и {len(self.test_questions)} вопросов")
|
||||||
|
|
||||||
|
async def _cleanup_test_data(self):
|
||||||
|
"""Очистка тестовых данных"""
|
||||||
|
print("🧹 Очистка тестовых данных...")
|
||||||
|
|
||||||
|
# Удаляем тестовых пользователей (вопросы удалятся каскадно)
|
||||||
|
for user in self.test_users[:10]: # Удаляем только первых 10 для скорости
|
||||||
|
try:
|
||||||
|
await self.database.users.delete(user.telegram_id)
|
||||||
|
except:
|
||||||
|
pass # Игнорируем ошибки при очистке
|
||||||
|
|
||||||
|
async def _benchmark_single_insert_users(self) -> BenchmarkResult:
|
||||||
|
"""Benchmark одиночной вставки пользователей"""
|
||||||
|
start_time = time.time()
|
||||||
|
items_processed = 0
|
||||||
|
|
||||||
|
for user in self.test_users[:100]: # Тестируем на 100 пользователях
|
||||||
|
try:
|
||||||
|
await self.database.create_user(user)
|
||||||
|
items_processed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при создании пользователя: {e}")
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
return BenchmarkResult(
|
||||||
|
operation="single_insert_users",
|
||||||
|
duration=duration,
|
||||||
|
items_processed=items_processed,
|
||||||
|
items_per_second=items_processed / duration if duration > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _benchmark_batch_insert_users(self) -> BenchmarkResult:
|
||||||
|
"""Benchmark batch вставки пользователей"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Создаем новые пользователей для batch теста
|
||||||
|
batch_users = []
|
||||||
|
for i in range(100):
|
||||||
|
user = User(
|
||||||
|
telegram_id=8000000 + i,
|
||||||
|
username=f"batch_user_{i}",
|
||||||
|
first_name=f"Batch{i}",
|
||||||
|
last_name="User",
|
||||||
|
chat_id=8000000 + i,
|
||||||
|
profile_link=f"batch_link_{i}",
|
||||||
|
is_active=True,
|
||||||
|
is_superuser=False
|
||||||
|
)
|
||||||
|
batch_users.append(user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.database.create_users_batch(batch_users)
|
||||||
|
items_processed = len(batch_users)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при batch создании пользователей: {e}")
|
||||||
|
items_processed = 0
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
return BenchmarkResult(
|
||||||
|
operation="batch_insert_users",
|
||||||
|
duration=duration,
|
||||||
|
items_processed=items_processed,
|
||||||
|
items_per_second=items_processed / duration if duration > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _benchmark_single_insert_questions(self) -> BenchmarkResult:
|
||||||
|
"""Benchmark одиночной вставки вопросов"""
|
||||||
|
start_time = time.time()
|
||||||
|
items_processed = 0
|
||||||
|
|
||||||
|
for question in self.test_questions[:100]: # Тестируем на 100 вопросах
|
||||||
|
try:
|
||||||
|
await self.database.create_question(question)
|
||||||
|
items_processed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при создании вопроса: {e}")
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
return BenchmarkResult(
|
||||||
|
operation="single_insert_questions",
|
||||||
|
duration=duration,
|
||||||
|
items_processed=items_processed,
|
||||||
|
items_per_second=items_processed / duration if duration > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _benchmark_batch_insert_questions(self) -> BenchmarkResult:
|
||||||
|
"""Benchmark batch вставки вопросов"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Создаем новые вопросы для batch теста
|
||||||
|
batch_questions = []
|
||||||
|
for i in range(100):
|
||||||
|
question = Question(
|
||||||
|
from_user_id=8000000 + (i % 10),
|
||||||
|
to_user_id=8000000 + ((i + 1) % 10),
|
||||||
|
message_text=f"Batch question {i}",
|
||||||
|
status=QuestionStatus.PENDING
|
||||||
|
)
|
||||||
|
batch_questions.append(question)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.database.create_questions_batch(batch_questions)
|
||||||
|
items_processed = len(batch_questions)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при batch создании вопросов: {e}")
|
||||||
|
items_processed = 0
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
return BenchmarkResult(
|
||||||
|
operation="batch_insert_questions",
|
||||||
|
duration=duration,
|
||||||
|
items_processed=items_processed,
|
||||||
|
items_per_second=items_processed / duration if duration > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _benchmark_offset_pagination(self) -> BenchmarkResult:
|
||||||
|
"""Benchmark offset-based пагинации"""
|
||||||
|
start_time = time.time()
|
||||||
|
items_processed = 0
|
||||||
|
|
||||||
|
# Тестируем пагинацию с offset
|
||||||
|
for offset in range(0, 1000, 50): # 20 страниц по 50 элементов
|
||||||
|
try:
|
||||||
|
questions = await self.database.get_user_questions(
|
||||||
|
to_user_id=9000000, # Используем первого тестового пользователя
|
||||||
|
limit=50,
|
||||||
|
offset=offset
|
||||||
|
)
|
||||||
|
items_processed += len(questions)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при offset пагинации: {e}")
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
return BenchmarkResult(
|
||||||
|
operation="offset_pagination",
|
||||||
|
duration=duration,
|
||||||
|
items_processed=items_processed,
|
||||||
|
items_per_second=items_processed / duration if duration > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _benchmark_cursor_pagination(self) -> BenchmarkResult:
|
||||||
|
"""Benchmark cursor-based пагинации"""
|
||||||
|
start_time = time.time()
|
||||||
|
items_processed = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Тестируем cursor-based пагинацию
|
||||||
|
cursor = None
|
||||||
|
for _ in range(20): # 20 страниц
|
||||||
|
result = await self.pagination_service.paginate_questions(
|
||||||
|
database=self.database,
|
||||||
|
to_user_id=9000000,
|
||||||
|
cursor=cursor,
|
||||||
|
limit=50
|
||||||
|
)
|
||||||
|
items_processed += len(result.items)
|
||||||
|
cursor = result.cursor
|
||||||
|
|
||||||
|
if not result.has_next:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при cursor пагинации: {e}")
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
return BenchmarkResult(
|
||||||
|
operation="cursor_pagination",
|
||||||
|
duration=duration,
|
||||||
|
items_processed=items_processed,
|
||||||
|
items_per_second=items_processed / duration if duration > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _benchmark_n_plus_one_query(self) -> BenchmarkResult:
|
||||||
|
"""Benchmark N+1 запроса (неоптимизированная версия)"""
|
||||||
|
start_time = time.time()
|
||||||
|
items_processed = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Получаем вопросы
|
||||||
|
questions = await self.database.get_user_questions(
|
||||||
|
to_user_id=9000000,
|
||||||
|
limit=100
|
||||||
|
)
|
||||||
|
|
||||||
|
# Для каждого вопроса делаем отдельный запрос к БД (N+1 проблема)
|
||||||
|
for question in questions:
|
||||||
|
try:
|
||||||
|
if question.from_user_id:
|
||||||
|
user = await self.database.get_user(question.from_user_id)
|
||||||
|
if user:
|
||||||
|
items_processed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при получении пользователя: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при N+1 запросе: {e}")
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
return BenchmarkResult(
|
||||||
|
operation="n_plus_one_query",
|
||||||
|
duration=duration,
|
||||||
|
items_processed=items_processed,
|
||||||
|
items_per_second=items_processed / duration if duration > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _benchmark_optimized_join_query(self) -> BenchmarkResult:
|
||||||
|
"""Benchmark оптимизированного JOIN запроса"""
|
||||||
|
start_time = time.time()
|
||||||
|
items_processed = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Используем оптимизированный запрос с JOIN
|
||||||
|
questions_with_authors = await self.database.get_user_questions_with_authors(
|
||||||
|
user_id=9000000,
|
||||||
|
limit=100
|
||||||
|
)
|
||||||
|
items_processed = len(questions_with_authors)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при оптимизированном JOIN запросе: {e}")
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
return BenchmarkResult(
|
||||||
|
operation="optimized_join_query",
|
||||||
|
duration=duration,
|
||||||
|
items_processed=items_processed,
|
||||||
|
items_per_second=items_processed / duration if duration > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate_report(self) -> Dict[str, Any]:
|
||||||
|
"""Генерация отчета по результатам benchmark"""
|
||||||
|
if not self.results:
|
||||||
|
return {"error": "Нет результатов для анализа"}
|
||||||
|
|
||||||
|
# Группируем результаты по типам операций
|
||||||
|
operation_groups = {}
|
||||||
|
for result in self.results:
|
||||||
|
if result.operation not in operation_groups:
|
||||||
|
operation_groups[result.operation] = []
|
||||||
|
operation_groups[result.operation].append(result)
|
||||||
|
|
||||||
|
# Анализируем производительность
|
||||||
|
analysis = {}
|
||||||
|
for operation, results in operation_groups.items():
|
||||||
|
if results:
|
||||||
|
avg_performance = statistics.mean([r.items_per_second for r in results])
|
||||||
|
max_performance = max([r.items_per_second for r in results])
|
||||||
|
min_performance = min([r.items_per_second for r in results])
|
||||||
|
|
||||||
|
analysis[operation] = {
|
||||||
|
"avg_items_per_second": round(avg_performance, 2),
|
||||||
|
"max_items_per_second": round(max_performance, 2),
|
||||||
|
"min_items_per_second": round(min_performance, 2),
|
||||||
|
"total_tests": len(results),
|
||||||
|
"error_rate": sum(1 for r in results if r.error_count > 0) / len(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Сравнение производительности
|
||||||
|
comparisons = {}
|
||||||
|
if "single_insert_users" in analysis and "batch_insert_users" in analysis:
|
||||||
|
single_perf = analysis["single_insert_users"]["avg_items_per_second"]
|
||||||
|
batch_perf = analysis["batch_insert_users"]["avg_items_per_second"]
|
||||||
|
comparisons["batch_vs_single_users"] = {
|
||||||
|
"batch_performance": batch_perf,
|
||||||
|
"single_performance": single_perf,
|
||||||
|
"improvement_factor": round(batch_perf / single_perf, 2) if single_perf > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if "offset_pagination" in analysis and "cursor_pagination" in analysis:
|
||||||
|
offset_perf = analysis["offset_pagination"]["avg_items_per_second"]
|
||||||
|
cursor_perf = analysis["cursor_pagination"]["avg_items_per_second"]
|
||||||
|
comparisons["cursor_vs_offset_pagination"] = {
|
||||||
|
"cursor_performance": cursor_perf,
|
||||||
|
"offset_performance": offset_perf,
|
||||||
|
"improvement_factor": round(cursor_perf / offset_perf, 2) if offset_perf > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if "n_plus_one_query" in analysis and "optimized_join_query" in analysis:
|
||||||
|
n_plus_one_perf = analysis["n_plus_one_query"]["avg_items_per_second"]
|
||||||
|
join_perf = analysis["optimized_join_query"]["avg_items_per_second"]
|
||||||
|
comparisons["join_vs_n_plus_one"] = {
|
||||||
|
"join_performance": join_perf,
|
||||||
|
"n_plus_one_performance": n_plus_one_perf,
|
||||||
|
"improvement_factor": round(join_perf / n_plus_one_perf, 2) if n_plus_one_perf > 0 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"total_benchmarks": len(self.results),
|
||||||
|
"successful_benchmarks": len([r for r in self.results if r.error_count == 0]),
|
||||||
|
"failed_benchmarks": len([r for r in self.results if r.error_count > 0])
|
||||||
|
},
|
||||||
|
"performance_analysis": analysis,
|
||||||
|
"performance_comparisons": comparisons,
|
||||||
|
"recommendations": self._generate_recommendations(analysis, comparisons)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _generate_recommendations(self, analysis: Dict, comparisons: Dict) -> List[str]:
|
||||||
|
"""Генерация рекомендаций по оптимизации"""
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Рекомендации по batch операциям
|
||||||
|
if "batch_vs_single_users" in comparisons:
|
||||||
|
improvement = comparisons["batch_vs_single_users"]["improvement_factor"]
|
||||||
|
if improvement > 2:
|
||||||
|
recommendations.append(f"✅ Batch операции показывают улучшение в {improvement}x раз - рекомендуется использовать для массовых вставок")
|
||||||
|
else:
|
||||||
|
recommendations.append("⚠️ Batch операции не показывают значительного улучшения - возможно, стоит пересмотреть реализацию")
|
||||||
|
|
||||||
|
# Рекомендации по пагинации
|
||||||
|
if "cursor_vs_offset_pagination" in comparisons:
|
||||||
|
improvement = comparisons["cursor_vs_offset_pagination"]["improvement_factor"]
|
||||||
|
if improvement > 1.5:
|
||||||
|
recommendations.append(f"✅ Cursor-based пагинация показывает улучшение в {improvement}x раз - рекомендуется для больших таблиц")
|
||||||
|
else:
|
||||||
|
recommendations.append("⚠️ Cursor-based пагинация не показывает значительного улучшения - возможно, данных недостаточно для демонстрации преимуществ")
|
||||||
|
|
||||||
|
# Рекомендации по JOIN запросам
|
||||||
|
if "join_vs_n_plus_one" in comparisons:
|
||||||
|
improvement = comparisons["join_vs_n_plus_one"]["improvement_factor"]
|
||||||
|
if improvement > 5:
|
||||||
|
recommendations.append(f"✅ JOIN запросы показывают улучшение в {improvement}x раз - критически важно избегать N+1 проблем")
|
||||||
|
else:
|
||||||
|
recommendations.append("⚠️ JOIN запросы не показывают ожидаемого улучшения - возможно, нужно больше данных для тестирования")
|
||||||
|
|
||||||
|
# Общие рекомендации
|
||||||
|
if not recommendations:
|
||||||
|
recommendations.append("📊 Недостаточно данных для генерации рекомендаций - увеличьте объем тестовых данных")
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
|
||||||
|
async def run_database_benchmark():
|
||||||
|
"""Запуск benchmark тестов БД"""
|
||||||
|
try:
|
||||||
|
# Инициализация БД
|
||||||
|
database = DatabaseService()
|
||||||
|
await database.init()
|
||||||
|
|
||||||
|
# Запуск benchmark
|
||||||
|
benchmark = DatabaseBenchmark(database)
|
||||||
|
results = await benchmark.run_all_benchmarks()
|
||||||
|
|
||||||
|
# Вывод результатов
|
||||||
|
print("\n" + "="*80)
|
||||||
|
print("📊 РЕЗУЛЬТАТЫ BENCHMARK ТЕСТОВ ПРОИЗВОДИТЕЛЬНОСТИ БД")
|
||||||
|
print("="*80)
|
||||||
|
|
||||||
|
print(f"\n📈 Общая статистика:")
|
||||||
|
print(f" Всего тестов: {results['summary']['total_benchmarks']}")
|
||||||
|
print(f" Успешных: {results['summary']['successful_benchmarks']}")
|
||||||
|
print(f" Неудачных: {results['summary']['failed_benchmarks']}")
|
||||||
|
|
||||||
|
print(f"\n⚡ Анализ производительности:")
|
||||||
|
for operation, stats in results['performance_analysis'].items():
|
||||||
|
print(f" {operation}:")
|
||||||
|
print(f" Средняя производительность: {stats['avg_items_per_second']} items/sec")
|
||||||
|
print(f" Максимальная: {stats['max_items_per_second']} items/sec")
|
||||||
|
print(f" Минимальная: {stats['min_items_per_second']} items/sec")
|
||||||
|
print(f" Ошибок: {stats['error_rate']:.1%}")
|
||||||
|
|
||||||
|
print(f"\n🔄 Сравнения производительности:")
|
||||||
|
for comparison, stats in results['performance_comparisons'].items():
|
||||||
|
print(f" {comparison}:")
|
||||||
|
print(f" Улучшение в {stats['improvement_factor']}x раз")
|
||||||
|
|
||||||
|
print(f"\n💡 Рекомендации:")
|
||||||
|
for recommendation in results['recommendations']:
|
||||||
|
print(f" {recommendation}")
|
||||||
|
|
||||||
|
print("\n" + "="*80)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка при запуске benchmark: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(run_database_benchmark())
|
||||||
65
tests/conftest.py
Normal file
65
tests/conftest.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация pytest для AnonBot
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
# Импорты для фикстур
|
||||||
|
from config import config
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from services.auth.auth_new import AuthService
|
||||||
|
from services.validation import InputValidator
|
||||||
|
from services.utils import UtilsService
|
||||||
|
from services.rate_limiting.rate_limit_service import RateLimitService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
"""Создание event loop для async тестов"""
|
||||||
|
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_database():
|
||||||
|
"""Мок для DatabaseService"""
|
||||||
|
return AsyncMock(spec=DatabaseService)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_auth():
|
||||||
|
"""Мок для AuthService"""
|
||||||
|
return AsyncMock(spec=AuthService)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_validator():
|
||||||
|
"""Мок для InputValidator"""
|
||||||
|
return MagicMock(spec=InputValidator)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_utils():
|
||||||
|
"""Мок для UtilsService"""
|
||||||
|
return MagicMock(spec=UtilsService)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_rate_limit_service():
|
||||||
|
"""Мок для RateLimitService"""
|
||||||
|
return AsyncMock(spec=RateLimitService)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_config():
|
||||||
|
"""Мок для конфигурации"""
|
||||||
|
mock_config = MagicMock()
|
||||||
|
mock_config.ADMINS = [123456789, 987654321]
|
||||||
|
mock_config.MAX_QUESTION_LENGTH = 1000
|
||||||
|
mock_config.MAX_ANSWER_LENGTH = 2000
|
||||||
|
mock_config.MIN_QUESTION_LENGTH = 10
|
||||||
|
mock_config.MIN_ANSWER_LENGTH = 5
|
||||||
|
return mock_config
|
||||||
3
tests/integration/__init__.py
Normal file
3
tests/integration/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Интеграционные тесты для AnonBot
|
||||||
|
"""
|
||||||
78
tests/integration/test_bot_integration.py
Normal file
78
tests/integration/test_bot_integration.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
Интеграционные тесты для бота
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Полные сценарии работы бота
|
||||||
|
- Интеграция всех компонентов
|
||||||
|
- End-to-end тесты
|
||||||
|
- Обработка реальных сообщений
|
||||||
|
- FSM состояния
|
||||||
|
- Middleware цепочка
|
||||||
|
- Обработка ошибок
|
||||||
|
- Производительность
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from aiogram.types import Message, User, Chat, CallbackQuery
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from bot import Bot
|
||||||
|
from loader import BotLoader
|
||||||
|
|
||||||
|
|
||||||
|
class TestBotIntegration:
|
||||||
|
"""Интеграционные тесты для бота"""
|
||||||
|
|
||||||
|
def test_bot_initialization(self):
|
||||||
|
"""Тест инициализации бота"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_bot_loader_initialization(self):
|
||||||
|
"""Тест инициализации BotLoader"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_full_start_command_flow(self):
|
||||||
|
"""Тест полного потока команды /start"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_full_question_flow(self):
|
||||||
|
"""Тест полного потока создания вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_full_answer_flow(self):
|
||||||
|
"""Тест полного потока ответа на вопрос"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_full_admin_flow(self):
|
||||||
|
"""Тест полного потока админских функций"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_middleware_chain(self):
|
||||||
|
"""Тест цепочки middleware"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_fsm_state_management(self):
|
||||||
|
"""Тест управления FSM состояниями"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_error_handling_chain(self):
|
||||||
|
"""Тест цепочки обработки ошибок"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_bot_performance(self):
|
||||||
|
"""Тест производительности бота"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_bot_concurrent_requests(self):
|
||||||
|
"""Тест конкурентных запросов к боту"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
74
tests/integration/test_database_integration.py
Normal file
74
tests/integration/test_database_integration.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Интеграционные тесты для базы данных
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Полные сценарии работы с БД
|
||||||
|
- Интеграция CRUD операций
|
||||||
|
- Транзакции
|
||||||
|
- Connection pooling
|
||||||
|
- Производительность
|
||||||
|
- Обработка ошибок
|
||||||
|
- Восстановление после ошибок
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from services.infrastructure.database import DatabaseService
|
||||||
|
from database.crud import UserCRUD, QuestionCRUD, UserBlockCRUD, UserSettingsCRUD
|
||||||
|
from models.user import User
|
||||||
|
from models.question import Question, QuestionStatus
|
||||||
|
from models.user_block import UserBlock
|
||||||
|
from models.user_settings import UserSettings
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabaseIntegration:
|
||||||
|
"""Интеграционные тесты для базы данных"""
|
||||||
|
|
||||||
|
def test_full_user_lifecycle(self):
|
||||||
|
"""Тест полного жизненного цикла пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_full_question_lifecycle(self):
|
||||||
|
"""Тест полного жизненного цикла вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_question_relationship(self):
|
||||||
|
"""Тест связи пользователя и вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_relationship(self):
|
||||||
|
"""Тест связи пользователя и блокировки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_relationship(self):
|
||||||
|
"""Тест связи пользователя и настроек"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_database_transactions(self):
|
||||||
|
"""Тест транзакций БД"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_database_connection_pooling(self):
|
||||||
|
"""Тест пула подключений БД"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_database_performance(self):
|
||||||
|
"""Тест производительности БД"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_database_error_recovery(self):
|
||||||
|
"""Тест восстановления после ошибок БД"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_database_concurrent_access(self):
|
||||||
|
"""Тест конкурентного доступа к БД"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
8
tests/requirements.txt
Normal file
8
tests/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Тестовые зависимости для AnonBot
|
||||||
|
pytest>=7.4.0
|
||||||
|
pytest-asyncio>=0.21.0
|
||||||
|
pytest-mock>=3.11.0
|
||||||
|
pytest-cov>=4.1.0
|
||||||
|
pytest-xdist>=3.3.0
|
||||||
|
aiogram-test>=0.1.0
|
||||||
|
asynctest>=0.13.0
|
||||||
166
tests/run_tests.sh
Executable file
166
tests/run_tests.sh
Executable file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для запуска тестов AnonBot
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🧪 Запуск тестов AnonBot"
|
||||||
|
echo "========================="
|
||||||
|
|
||||||
|
# Проверяем, что мы в правильной директории
|
||||||
|
if [ ! -f "pytest.ini" ]; then
|
||||||
|
echo "❌ Ошибка: pytest.ini не найден. Запустите скрипт из корневой директории проекта."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Устанавливаем тестовые зависимости
|
||||||
|
echo "📦 Установка тестовых зависимостей..."
|
||||||
|
pip install -r tests/requirements.txt
|
||||||
|
|
||||||
|
# Создаем тестовую БД в памяти
|
||||||
|
export DATABASE_PATH=":memory:"
|
||||||
|
export TELEGRAM_BOT_TOKEN="test_token"
|
||||||
|
export ADMINS="123456789,987654321"
|
||||||
|
export LOG_LEVEL="DEBUG"
|
||||||
|
|
||||||
|
echo "🔧 Настройка тестового окружения..."
|
||||||
|
|
||||||
|
# Функция для запуска тестов с покрытием
|
||||||
|
run_tests_with_coverage() {
|
||||||
|
local test_path="$1"
|
||||||
|
local test_name="$2"
|
||||||
|
|
||||||
|
echo "📊 Запуск $test_name с покрытием..."
|
||||||
|
pytest "$test_path" \
|
||||||
|
--cov=. \
|
||||||
|
--cov-report=html \
|
||||||
|
--cov-report=term-missing \
|
||||||
|
--cov-fail-under=80 \
|
||||||
|
-v
|
||||||
|
}
|
||||||
|
|
||||||
|
# Функция для запуска тестов без покрытия
|
||||||
|
run_tests() {
|
||||||
|
local test_path="$1"
|
||||||
|
local test_name="$2"
|
||||||
|
|
||||||
|
echo "🚀 Запуск $test_name..."
|
||||||
|
pytest "$test_path" -v
|
||||||
|
}
|
||||||
|
|
||||||
|
# Функция для запуска тестов по маркерам
|
||||||
|
run_tests_by_marker() {
|
||||||
|
local marker="$1"
|
||||||
|
local test_name="$2"
|
||||||
|
|
||||||
|
echo "🏷️ Запуск $test_name (маркер: $marker)..."
|
||||||
|
pytest -m "$marker" -v
|
||||||
|
}
|
||||||
|
|
||||||
|
# Основное меню
|
||||||
|
case "${1:-all}" in
|
||||||
|
"all")
|
||||||
|
echo "🎯 Запуск всех тестов..."
|
||||||
|
run_tests_with_coverage "tests/" "всех тестов"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"unit")
|
||||||
|
echo "🔬 Запуск unit тестов..."
|
||||||
|
run_tests_with_coverage "tests/unit/" "unit тестов"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"integration")
|
||||||
|
echo "🔗 Запуск интеграционных тестов..."
|
||||||
|
run_tests "tests/integration/" "интеграционных тестов"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"models")
|
||||||
|
echo "📊 Запуск тестов моделей..."
|
||||||
|
run_tests "tests/unit/models/" "тестов моделей"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"validation")
|
||||||
|
echo "✅ Запуск тестов валидации..."
|
||||||
|
run_tests "tests/unit/services/validation/" "тестов валидации"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"auth")
|
||||||
|
echo "🔐 Запуск тестов авторизации..."
|
||||||
|
run_tests "tests/unit/services/auth/" "тестов авторизации"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"crud")
|
||||||
|
echo "💾 Запуск тестов CRUD..."
|
||||||
|
run_tests "tests/unit/database/" "тестов CRUD"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"services")
|
||||||
|
echo "⚙️ Запуск тестов сервисов..."
|
||||||
|
run_tests "tests/unit/services/" "тестов сервисов"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"handlers")
|
||||||
|
echo "🎮 Запуск тестов обработчиков..."
|
||||||
|
run_tests "tests/unit/handlers/" "тестов обработчиков"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"middleware")
|
||||||
|
echo "🔧 Запуск тестов middleware..."
|
||||||
|
run_tests "tests/unit/middlewares/" "тестов middleware"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"config")
|
||||||
|
echo "⚙️ Запуск тестов конфигурации..."
|
||||||
|
run_tests "tests/unit/config/" "тестов конфигурации"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"coverage")
|
||||||
|
echo "📊 Генерация отчета о покрытии..."
|
||||||
|
pytest --cov=. --cov-report=html --cov-report=term-missing -v
|
||||||
|
echo "📁 Отчет сохранен в htmlcov/index.html"
|
||||||
|
;;
|
||||||
|
|
||||||
|
"fast")
|
||||||
|
echo "⚡ Быстрый запуск тестов..."
|
||||||
|
pytest -x -v --tb=short
|
||||||
|
;;
|
||||||
|
|
||||||
|
"debug")
|
||||||
|
echo "🐛 Запуск тестов в режиме отладки..."
|
||||||
|
pytest -v -s --pdb --tb=long
|
||||||
|
;;
|
||||||
|
|
||||||
|
"help")
|
||||||
|
echo "📖 Доступные команды:"
|
||||||
|
echo " all - Запуск всех тестов с покрытием"
|
||||||
|
echo " unit - Запуск unit тестов с покрытием"
|
||||||
|
echo " integration - Запуск интеграционных тестов"
|
||||||
|
echo " models - Запуск тестов моделей"
|
||||||
|
echo " validation - Запуск тестов валидации"
|
||||||
|
echo " auth - Запуск тестов авторизации"
|
||||||
|
echo " crud - Запуск тестов CRUD"
|
||||||
|
echo " services - Запуск тестов сервисов"
|
||||||
|
echo " handlers - Запуск тестов обработчиков"
|
||||||
|
echo " middleware - Запуск тестов middleware"
|
||||||
|
echo " config - Запуск тестов конфигурации"
|
||||||
|
echo " coverage - Генерация отчета о покрытии"
|
||||||
|
echo " fast - Быстрый запуск тестов"
|
||||||
|
echo " debug - Запуск в режиме отладки"
|
||||||
|
echo " help - Показать эту справку"
|
||||||
|
echo ""
|
||||||
|
echo "Примеры использования:"
|
||||||
|
echo " ./tests/run_tests.sh all"
|
||||||
|
echo " ./tests/run_tests.sh unit"
|
||||||
|
echo " ./tests/run_tests.sh models"
|
||||||
|
echo " ./tests/run_tests.sh coverage"
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "❌ Неизвестная команда: $1"
|
||||||
|
echo "Используйте './tests/run_tests.sh help' для справки"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Тесты завершены!"
|
||||||
43
tests/test_config.env
Normal file
43
tests/test_config.env
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Тестовая конфигурация для AnonBot
|
||||||
|
# Этот файл используется для тестов
|
||||||
|
|
||||||
|
# Telegram Bot Token (тестовый)
|
||||||
|
TELEGRAM_BOT_TOKEN=test_token_1234567890
|
||||||
|
|
||||||
|
# База данных (в памяти для тестов)
|
||||||
|
DATABASE_PATH=:memory:
|
||||||
|
|
||||||
|
# Администраторы (тестовые ID)
|
||||||
|
ADMINS=123456789,987654321
|
||||||
|
|
||||||
|
# Уровень логирования
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
|
||||||
|
# Порт для метрик
|
||||||
|
METRICS_PORT=9090
|
||||||
|
|
||||||
|
# Rate limiting (тестовые значения)
|
||||||
|
RATE_LIMIT_MESSAGES_PER_MINUTE=60
|
||||||
|
RATE_LIMIT_CALLBACKS_PER_MINUTE=30
|
||||||
|
|
||||||
|
# Валидация (тестовые значения)
|
||||||
|
MIN_QUESTION_LENGTH=10
|
||||||
|
MAX_QUESTION_LENGTH=1000
|
||||||
|
MIN_ANSWER_LENGTH=5
|
||||||
|
MAX_ANSWER_LENGTH=2000
|
||||||
|
|
||||||
|
# Пагинация (тестовые значения)
|
||||||
|
DEFAULT_PAGE_SIZE=10
|
||||||
|
MAX_PAGE_SIZE=50
|
||||||
|
|
||||||
|
# Логирование (тестовые значения)
|
||||||
|
LOG_TO_FILE=false
|
||||||
|
LOG_FILE_PATH=logs/test.log
|
||||||
|
|
||||||
|
# Метрики (тестовые значения)
|
||||||
|
ENABLE_METRICS=true
|
||||||
|
METRICS_HOST=localhost
|
||||||
|
|
||||||
|
# HTTP сервер (тестовые значения)
|
||||||
|
HTTP_HOST=localhost
|
||||||
|
HTTP_PORT=8080
|
||||||
3
tests/unit/__init__.py
Normal file
3
tests/unit/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Unit тесты для AnonBot
|
||||||
|
"""
|
||||||
3
tests/unit/config/__init__.py
Normal file
3
tests/unit/config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Unit тесты для конфигурации
|
||||||
|
"""
|
||||||
85
tests/unit/config/test_config.py
Normal file
85
tests/unit/config/test_config.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Тесты для конфигурации
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Загрузка конфигурации из .env
|
||||||
|
- Валидация обязательных параметров
|
||||||
|
- Валидация типов данных
|
||||||
|
- Валидация диапазонов значений
|
||||||
|
- Обработка отсутствующих параметров
|
||||||
|
- Обработка невалидных значений
|
||||||
|
- Обработка ошибок загрузки
|
||||||
|
- Интеграция с dotenv
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from config.config import config, load_config
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
"""Тесты для конфигурации"""
|
||||||
|
|
||||||
|
def test_config_initialization(self):
|
||||||
|
"""Тест инициализации конфигурации"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_load_config_from_env(self):
|
||||||
|
"""Тест загрузки конфигурации из .env"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_load_config_missing_required_params(self):
|
||||||
|
"""Тест загрузки конфигурации с отсутствующими обязательными параметрами"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_load_config_invalid_types(self):
|
||||||
|
"""Тест загрузки конфигурации с невалидными типами"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_load_config_invalid_ranges(self):
|
||||||
|
"""Тест загрузки конфигурации с невалидными диапазонами"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_config_validation_telegram_token(self):
|
||||||
|
"""Тест валидации Telegram токена"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_config_validation_database_path(self):
|
||||||
|
"""Тест валидации пути к БД"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_config_validation_admins_list(self):
|
||||||
|
"""Тест валидации списка админов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_config_validation_rate_limits(self):
|
||||||
|
"""Тест валидации лимитов rate limiting"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_config_validation_logging_level(self):
|
||||||
|
"""Тест валидации уровня логирования"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_config_validation_metrics_port(self):
|
||||||
|
"""Тест валидации порта метрик"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_config_error_handling(self):
|
||||||
|
"""Тест обработки ошибок конфигурации"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_config_default_values(self):
|
||||||
|
"""Тест значений по умолчанию"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
65
tests/unit/config/test_constants.py
Normal file
65
tests/unit/config/test_constants.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Тесты для констант
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Значения констант
|
||||||
|
- Валидация констант
|
||||||
|
- Консистентность констант
|
||||||
|
- Обработка изменений констант
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from config.constants import *
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstants:
|
||||||
|
"""Тесты для констант"""
|
||||||
|
|
||||||
|
def test_question_constants(self):
|
||||||
|
"""Тест констант для вопросов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_answer_constants(self):
|
||||||
|
"""Тест констант для ответов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_constants(self):
|
||||||
|
"""Тест констант для пользователей"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_validation_constants(self):
|
||||||
|
"""Тест констант для валидации"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_rate_limit_constants(self):
|
||||||
|
"""Тест констант для rate limiting"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_database_constants(self):
|
||||||
|
"""Тест констант для БД"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_logging_constants(self):
|
||||||
|
"""Тест констант для логирования"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_metrics_constants(self):
|
||||||
|
"""Тест констант для метрик"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_constants_consistency(self):
|
||||||
|
"""Тест консистентности констант"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_constants_validation(self):
|
||||||
|
"""Тест валидации констант"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
3
tests/unit/database/__init__.py
Normal file
3
tests/unit/database/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Unit тесты для базы данных
|
||||||
|
"""
|
||||||
314
tests/unit/database/test_crud.py
Normal file
314
tests/unit/database/test_crud.py
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"""
|
||||||
|
Тесты для CRUD операций
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- UserCRUD (создание, обновление, удаление, получение)
|
||||||
|
- QuestionCRUD (создание, обновление, удаление, получение)
|
||||||
|
- UserBlockCRUD (блокировки пользователей)
|
||||||
|
- UserSettingsCRUD (настройки пользователей)
|
||||||
|
- Batch операции (create_batch для пользователей и вопросов)
|
||||||
|
- Cursor-based пагинация
|
||||||
|
- Обработка ошибок БД
|
||||||
|
- Валидация входных данных
|
||||||
|
- Транзакции
|
||||||
|
- Connection pooling
|
||||||
|
- SQL injection защита
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from datetime import datetime
|
||||||
|
from database.crud import UserCRUD, QuestionCRUD, UserBlockCRUD, UserSettingsCRUD
|
||||||
|
from models.user import User
|
||||||
|
from models.question import Question, QuestionStatus
|
||||||
|
from models.user_block import UserBlock
|
||||||
|
from models.user_settings import UserSettings
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserCRUD:
|
||||||
|
"""Тесты для UserCRUD"""
|
||||||
|
|
||||||
|
def test_create_user_basic(self):
|
||||||
|
"""Тест базового создания пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_user_with_all_fields(self):
|
||||||
|
"""Тест создания пользователя со всеми полями"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_user_duplicate_telegram_id(self):
|
||||||
|
"""Тест создания пользователя с дублирующимся telegram_id"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_user_duplicate_profile_link(self):
|
||||||
|
"""Тест создания пользователя с дублирующимся profile_link"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_batch_users(self):
|
||||||
|
"""Тест batch создания пользователей"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_batch_users_empty_list(self):
|
||||||
|
"""Тест batch создания пустого списка пользователей"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_telegram_id_existing(self):
|
||||||
|
"""Тест получения пользователя по telegram_id - существующий"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_telegram_id_nonexistent(self):
|
||||||
|
"""Тест получения пользователя по telegram_id - несуществующий"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_profile_link_existing(self):
|
||||||
|
"""Тест получения пользователя по profile_link - существующий"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_profile_link_nonexistent(self):
|
||||||
|
"""Тест получения пользователя по profile_link - несуществующий"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_update_user_existing(self):
|
||||||
|
"""Тест обновления существующего пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_update_user_nonexistent(self):
|
||||||
|
"""Тест обновления несуществующего пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_user_existing(self):
|
||||||
|
"""Тест удаления существующего пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_user_nonexistent(self):
|
||||||
|
"""Тест удаления несуществующего пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_all_users(self):
|
||||||
|
"""Тест получения всех пользователей"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_all_users_cursor_pagination(self):
|
||||||
|
"""Тест cursor-based пагинации пользователей"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_all_users_asc(self):
|
||||||
|
"""Тест получения пользователей в порядке возрастания"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_stats(self):
|
||||||
|
"""Тест получения статистики пользователей"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuestionCRUD:
|
||||||
|
"""Тесты для QuestionCRUD"""
|
||||||
|
|
||||||
|
def test_create_question_basic(self):
|
||||||
|
"""Тест базового создания вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_question_with_answer(self):
|
||||||
|
"""Тест создания вопроса с ответом"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_question_anonymous(self):
|
||||||
|
"""Тест создания анонимного вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_batch_questions(self):
|
||||||
|
"""Тест batch создания вопросов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_id_existing(self):
|
||||||
|
"""Тест получения вопроса по ID - существующий"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_id_nonexistent(self):
|
||||||
|
"""Тест получения вопроса по ID - несуществующий"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_to_user(self):
|
||||||
|
"""Тест получения вопросов для пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_to_user_with_status_filter(self):
|
||||||
|
"""Тест получения вопросов с фильтром по статусу"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_to_user_with_authors(self):
|
||||||
|
"""Тест получения вопросов с информацией об авторах"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_to_user_cursor_pagination(self):
|
||||||
|
"""Тест cursor-based пагинации вопросов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_to_user_asc(self):
|
||||||
|
"""Тест получения вопросов в порядке возрастания"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_update_question_existing(self):
|
||||||
|
"""Тест обновления существующего вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_update_question_nonexistent(self):
|
||||||
|
"""Тест обновления несуществующего вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_question_existing(self):
|
||||||
|
"""Тест удаления существующего вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_question_nonexistent(self):
|
||||||
|
"""Тест удаления несуществующего вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_stats(self):
|
||||||
|
"""Тест получения статистики вопросов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserBlockCRUD:
|
||||||
|
"""Тесты для UserBlockCRUD"""
|
||||||
|
|
||||||
|
def test_create_block_basic(self):
|
||||||
|
"""Тест базового создания блокировки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_block_duplicate(self):
|
||||||
|
"""Тест создания дублирующейся блокировки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_block_self_block(self):
|
||||||
|
"""Тест попытки заблокировать самого себя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_block_existing(self):
|
||||||
|
"""Тест получения существующей блокировки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_block_nonexistent(self):
|
||||||
|
"""Тест получения несуществующей блокировки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_block_existing(self):
|
||||||
|
"""Тест удаления существующей блокировки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_block_nonexistent(self):
|
||||||
|
"""Тест удаления несуществующей блокировки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_blocks_by_blocker(self):
|
||||||
|
"""Тест получения блокировок по блокирующему"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_blocks_by_blocked(self):
|
||||||
|
"""Тест получения блокировок по заблокированному"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserSettingsCRUD:
|
||||||
|
"""Тесты для UserSettingsCRUD"""
|
||||||
|
|
||||||
|
def test_create_settings_basic(self):
|
||||||
|
"""Тест базового создания настроек"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_create_settings_duplicate_user(self):
|
||||||
|
"""Тест создания дублирующихся настроек для пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_user_id_existing(self):
|
||||||
|
"""Тест получения настроек по user_id - существующие"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_by_user_id_nonexistent(self):
|
||||||
|
"""Тест получения настроек по user_id - несуществующие"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_update_settings_existing(self):
|
||||||
|
"""Тест обновления существующих настроек"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_update_settings_nonexistent(self):
|
||||||
|
"""Тест обновления несуществующих настроек"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_settings_existing(self):
|
||||||
|
"""Тест удаления существующих настроек"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_settings_nonexistent(self):
|
||||||
|
"""Тест удаления несуществующих настроек"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabaseErrors:
|
||||||
|
"""Тесты для обработки ошибок БД"""
|
||||||
|
|
||||||
|
def test_connection_error_handling(self):
|
||||||
|
"""Тест обработки ошибок подключения"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_sql_error_handling(self):
|
||||||
|
"""Тест обработки SQL ошибок"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_transaction_rollback(self):
|
||||||
|
"""Тест отката транзакций при ошибках"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
3
tests/unit/handlers/__init__.py
Normal file
3
tests/unit/handlers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Unit тесты для обработчиков
|
||||||
|
"""
|
||||||
143
tests/unit/handlers/test_admin.py
Normal file
143
tests/unit/handlers/test_admin.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Тесты для админских обработчиков
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Обработка админских команд
|
||||||
|
- Управление пользователями
|
||||||
|
- Назначение/снятие суперпользователей
|
||||||
|
- Статистика бота
|
||||||
|
- Управление rate limiting
|
||||||
|
- Callback обработчики для админки
|
||||||
|
- Проверка прав доступа
|
||||||
|
- Форматирование админских данных
|
||||||
|
- Обработка ошибок
|
||||||
|
- Интеграция с сервисами
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from aiogram.types import Message, User, Chat, CallbackQuery
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from handlers.admin import (
|
||||||
|
admin_menu, admin_stats, admin_users,
|
||||||
|
assign_superuser_callback, confirm_superuser_callback,
|
||||||
|
remove_superuser_callback, admin_rate_limit_menu
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminHandlers:
|
||||||
|
"""Тесты для админских обработчиков"""
|
||||||
|
|
||||||
|
def test_admin_menu_basic(self):
|
||||||
|
"""Тест базового админского меню"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_admin_menu_non_admin_user(self):
|
||||||
|
"""Тест админского меню для не-админа"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_admin_stats_basic(self):
|
||||||
|
"""Тест базовой админской статистики"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_admin_stats_with_data(self):
|
||||||
|
"""Тест админской статистики с данными"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_admin_users_basic(self):
|
||||||
|
"""Тест базового списка пользователей"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_admin_users_pagination(self):
|
||||||
|
"""Тест пагинации списка пользователей"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_assign_superuser_callback_valid(self):
|
||||||
|
"""Тест callback 'Назначить суперпользователя' - валидный"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_assign_superuser_callback_invalid_user_id(self):
|
||||||
|
"""Тест callback 'Назначить суперпользователя' - невалидный ID пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_assign_superuser_callback_nonexistent_user(self):
|
||||||
|
"""Тест callback 'Назначить суперпользователя' - несуществующий пользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_confirm_superuser_callback_valid(self):
|
||||||
|
"""Тест callback 'Подтвердить суперпользователя' - валидный"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_confirm_superuser_callback_invalid_user_id(self):
|
||||||
|
"""Тест callback 'Подтвердить суперпользователя' - невалидный ID пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_remove_superuser_callback_valid(self):
|
||||||
|
"""Тест callback 'Снять суперпользователя' - валидный"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_remove_superuser_callback_invalid_user_id(self):
|
||||||
|
"""Тест callback 'Снять суперпользователя' - невалидный ID пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_admin_rate_limit_menu_basic(self):
|
||||||
|
"""Тест базового меню rate limiting"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_admin_rate_limit_menu_with_stats(self):
|
||||||
|
"""Тест меню rate limiting со статистикой"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_permission_checking_admin_required(self):
|
||||||
|
"""Тест проверки прав - требуется админ"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_permission_checking_superuser_required(self):
|
||||||
|
"""Тест проверки прав - требуется суперпользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_format_admin_stats_basic(self):
|
||||||
|
"""Тест базового форматирования админской статистики"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_format_users_list_basic(self):
|
||||||
|
"""Тест базового форматирования списка пользователей"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_format_users_list_with_pagination(self):
|
||||||
|
"""Тест форматирования списка пользователей с пагинацией"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_error_handling_admin(self):
|
||||||
|
"""Тест обработки ошибок в админке"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_integration_with_auth_service(self):
|
||||||
|
"""Тест интеграции с AuthService"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_integration_with_database_service(self):
|
||||||
|
"""Тест интеграции с DatabaseService"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
127
tests/unit/handlers/test_answers.py
Normal file
127
tests/unit/handlers/test_answers.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
Тесты для обработчиков ответов
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Обработка новых ответов
|
||||||
|
- Редактирование ответов
|
||||||
|
- Просмотр вопросов
|
||||||
|
- Callback обработчики для ответов
|
||||||
|
- FSM состояния для ответов
|
||||||
|
- Валидация текста ответов
|
||||||
|
- Форматирование ответов
|
||||||
|
- Обработка ошибок
|
||||||
|
- Интеграция с сервисами
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from aiogram.types import Message, User, Chat, CallbackQuery
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from handlers.answers import (
|
||||||
|
process_new_answer, process_edited_answer,
|
||||||
|
view_question_callback, edit_answer_callback,
|
||||||
|
delete_answer_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnswerHandlers:
|
||||||
|
"""Тесты для обработчиков ответов"""
|
||||||
|
|
||||||
|
def test_process_new_answer_valid(self):
|
||||||
|
"""Тест обработки валидного нового ответа"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_new_answer_invalid_text(self):
|
||||||
|
"""Тест обработки невалидного текста ответа"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_new_answer_too_long(self):
|
||||||
|
"""Тест обработки слишком длинного ответа"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_new_answer_too_short(self):
|
||||||
|
"""Тест обработки слишком короткого ответа"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_new_answer_spam(self):
|
||||||
|
"""Тест обработки спам-ответа"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_edited_answer_valid(self):
|
||||||
|
"""Тест обработки валидного редактированного ответа"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_edited_answer_invalid_text(self):
|
||||||
|
"""Тест обработки невалидного текста редактированного ответа"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_view_question_callback_valid(self):
|
||||||
|
"""Тест callback 'Просмотр вопроса' - валидный"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_view_question_callback_invalid_question_id(self):
|
||||||
|
"""Тест callback 'Просмотр вопроса' - невалидный ID вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_view_question_callback_nonexistent_question(self):
|
||||||
|
"""Тест callback 'Просмотр вопроса' - несуществующий вопрос"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_edit_answer_callback_valid(self):
|
||||||
|
"""Тест callback 'Редактировать ответ' - валидный"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_edit_answer_callback_invalid_question_id(self):
|
||||||
|
"""Тест callback 'Редактировать ответ' - невалидный ID вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_answer_callback_valid(self):
|
||||||
|
"""Тест callback 'Удалить ответ' - валидный"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_answer_callback_invalid_question_id(self):
|
||||||
|
"""Тест callback 'Удалить ответ' - невалидный ID вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_format_answer_info_basic(self):
|
||||||
|
"""Тест базового форматирования информации об ответе"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_format_answer_info_with_question(self):
|
||||||
|
"""Тест форматирования информации об ответе с вопросом"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_fsm_state_management_answers(self):
|
||||||
|
"""Тест управления FSM состояниями для ответов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_validation_integration_answers(self):
|
||||||
|
"""Тест интеграции с валидацией для ответов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_error_handling_answers(self):
|
||||||
|
"""Тест обработки ошибок в ответах"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_integration_with_question_service(self):
|
||||||
|
"""Тест интеграции с QuestionService"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
142
tests/unit/handlers/test_questions.py
Normal file
142
tests/unit/handlers/test_questions.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"""
|
||||||
|
Тесты для обработчиков вопросов
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Обработка анонимных вопросов
|
||||||
|
- Отображение списка вопросов
|
||||||
|
- Пагинация вопросов
|
||||||
|
- Callback обработчики (ответить, отклонить, удалить)
|
||||||
|
- FSM состояния для вопросов
|
||||||
|
- Валидация текста вопросов
|
||||||
|
- Форматирование списка вопросов
|
||||||
|
- Обработка ошибок
|
||||||
|
- Интеграция с сервисами
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from aiogram.types import Message, User, Chat, CallbackQuery
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from handlers.questions import (
|
||||||
|
process_anonymous_question, my_questions_button,
|
||||||
|
answer_question_callback, reject_question_callback,
|
||||||
|
delete_question_callback, block_user_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuestionHandlers:
|
||||||
|
"""Тесты для обработчиков вопросов"""
|
||||||
|
|
||||||
|
def test_process_anonymous_question_valid(self):
|
||||||
|
"""Тест обработки валидного анонимного вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_anonymous_question_invalid_text(self):
|
||||||
|
"""Тест обработки невалидного текста вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_anonymous_question_too_long(self):
|
||||||
|
"""Тест обработки слишком длинного вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_anonymous_question_too_short(self):
|
||||||
|
"""Тест обработки слишком короткого вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_anonymous_question_spam(self):
|
||||||
|
"""Тест обработки спам-вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_my_questions_button_with_questions(self):
|
||||||
|
"""Тест кнопки 'Мои вопросы' с существующими вопросами"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_my_questions_button_no_questions(self):
|
||||||
|
"""Тест кнопки 'Мои вопросы' без вопросов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_my_questions_button_pagination(self):
|
||||||
|
"""Тест пагинации в списке вопросов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_answer_question_callback_valid(self):
|
||||||
|
"""Тест callback 'Ответить' - валидный"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_answer_question_callback_invalid_question_id(self):
|
||||||
|
"""Тест callback 'Ответить' - невалидный ID вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_answer_question_callback_nonexistent_question(self):
|
||||||
|
"""Тест callback 'Ответить' - несуществующий вопрос"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_reject_question_callback_valid(self):
|
||||||
|
"""Тест callback 'Отклонить' - валидный"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_reject_question_callback_invalid_question_id(self):
|
||||||
|
"""Тест callback 'Отклонить' - невалидный ID вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_question_callback_valid(self):
|
||||||
|
"""Тест callback 'Удалить' - валидный"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_delete_question_callback_invalid_question_id(self):
|
||||||
|
"""Тест callback 'Удалить' - невалидный ID вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_block_user_callback_valid(self):
|
||||||
|
"""Тест callback 'Заблокировать пользователя' - валидный"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_block_user_callback_invalid_user_id(self):
|
||||||
|
"""Тест callback 'Заблокировать пользователя' - невалидный ID пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_format_questions_list_basic(self):
|
||||||
|
"""Тест базового форматирования списка вопросов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_format_questions_list_with_authors(self):
|
||||||
|
"""Тест форматирования списка вопросов с авторами"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_format_questions_list_empty(self):
|
||||||
|
"""Тест форматирования пустого списка вопросов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_fsm_state_management_questions(self):
|
||||||
|
"""Тест управления FSM состояниями для вопросов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_validation_integration(self):
|
||||||
|
"""Тест интеграции с валидацией"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_error_handling_questions(self):
|
||||||
|
"""Тест обработки ошибок в вопросах"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
104
tests/unit/handlers/test_start.py
Normal file
104
tests/unit/handlers/test_start.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
Тесты для обработчиков /start
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Обработка команды /start без аргументов
|
||||||
|
- Обработка команды /start с deep link
|
||||||
|
- Обработка команды /help
|
||||||
|
- Создание/обновление пользователя
|
||||||
|
- Генерация приветственного сообщения
|
||||||
|
- Обработка deep links
|
||||||
|
- Валидация входных данных
|
||||||
|
- FSM состояния
|
||||||
|
- Обработка ошибок
|
||||||
|
- Интеграция с сервисами
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from aiogram.types import Message, User, Chat, CallbackQuery
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from handlers.start import cmd_start, cmd_help, handle_deep_link, _process_start_command
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartHandlers:
|
||||||
|
"""Тесты для обработчиков /start"""
|
||||||
|
|
||||||
|
def test_cmd_start_basic(self):
|
||||||
|
"""Тест базовой команды /start"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_cmd_start_with_deep_link(self):
|
||||||
|
"""Тест команды /start с deep link"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_cmd_start_database_error(self):
|
||||||
|
"""Тест обработки ошибки БД в команде /start"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_cmd_help_basic(self):
|
||||||
|
"""Тест базовой команды /help"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_cmd_help_with_user_info(self):
|
||||||
|
"""Тест команды /help с информацией о пользователе"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_handle_deep_link_valid(self):
|
||||||
|
"""Тест обработки валидного deep link"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_handle_deep_link_invalid(self):
|
||||||
|
"""Тест обработки невалидного deep link"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_handle_deep_link_nonexistent_user(self):
|
||||||
|
"""Тест обработки deep link для несуществующего пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_start_command_new_user(self):
|
||||||
|
"""Тест обработки команды /start для нового пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_start_command_existing_user(self):
|
||||||
|
"""Тест обработки команды /start для существующего пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_process_start_command_validation_error(self):
|
||||||
|
"""Тест обработки ошибки валидации в команде /start"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_welcome_message_generation(self):
|
||||||
|
"""Тест генерации приветственного сообщения"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_welcome_message_with_referral_link(self):
|
||||||
|
"""Тест приветственного сообщения со ссылкой для рефералов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_fsm_state_management(self):
|
||||||
|
"""Тест управления FSM состояниями"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_error_handling_global(self):
|
||||||
|
"""Тест глобальной обработки ошибок"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_integration_with_services(self):
|
||||||
|
"""Тест интеграции с сервисами"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
3
tests/unit/middlewares/__init__.py
Normal file
3
tests/unit/middlewares/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Unit тесты для middleware
|
||||||
|
"""
|
||||||
83
tests/unit/middlewares/test_rate_limit_middleware.py
Normal file
83
tests/unit/middlewares/test_rate_limit_middleware.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
Тесты для RateLimitMiddleware
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Инициализация middleware
|
||||||
|
- Применение rate limiting к сообщениям
|
||||||
|
- Пропуск других типов событий
|
||||||
|
- Обработка ошибок rate limiting
|
||||||
|
- Интеграция с telegram_rate_limiter
|
||||||
|
- Логирование rate limit событий
|
||||||
|
- Производительность middleware
|
||||||
|
- Обработка TelegramRetryAfter
|
||||||
|
- Обработка TelegramAPIError
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from aiogram.types import Message, User, Chat, CallbackQuery, Update
|
||||||
|
from aiogram.exceptions import TelegramRetryAfter, TelegramAPIError
|
||||||
|
from middlewares.rate_limit_middleware import RateLimitMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimitMiddleware:
|
||||||
|
"""Тесты для RateLimitMiddleware"""
|
||||||
|
|
||||||
|
def test_middleware_initialization(self):
|
||||||
|
"""Тест инициализации middleware"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_apply_rate_limit_to_message(self):
|
||||||
|
"""Тест применения rate limiting к сообщению"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_skip_rate_limit_for_callback_query(self):
|
||||||
|
"""Тест пропуска rate limiting для CallbackQuery"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_skip_rate_limit_for_update(self):
|
||||||
|
"""Тест пропуска rate limiting для Update"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_handle_telegram_retry_after(self):
|
||||||
|
"""Тест обработки TelegramRetryAfter"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_handle_telegram_api_error(self):
|
||||||
|
"""Тест обработки TelegramAPIError"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_rate_limit_success(self):
|
||||||
|
"""Тест успешного rate limiting"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_rate_limit_exceeded(self):
|
||||||
|
"""Тест превышения rate limit"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_middleware_with_none_message(self):
|
||||||
|
"""Тест middleware с None сообщением"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_middleware_with_none_chat_id(self):
|
||||||
|
"""Тест middleware с None chat_id"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_performance_with_high_frequency(self):
|
||||||
|
"""Тест производительности при высокой частоте"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_integration_with_telegram_rate_limiter(self):
|
||||||
|
"""Тест интеграции с telegram_rate_limiter"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
94
tests/unit/middlewares/test_validation_middleware.py
Normal file
94
tests/unit/middlewares/test_validation_middleware.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Тесты для ValidationMiddleware
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Инициализация middleware
|
||||||
|
- Валидация CallbackQuery
|
||||||
|
- Валидация Message
|
||||||
|
- Обработка ошибок валидации
|
||||||
|
- Пропуск невалидных данных
|
||||||
|
- Логирование ошибок
|
||||||
|
- Интеграция с InputValidator
|
||||||
|
- Обработка различных типов событий
|
||||||
|
- Возврат санитизированных данных
|
||||||
|
- Производительность middleware
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from aiogram.types import CallbackQuery, Message, User, Chat, Update
|
||||||
|
from middlewares.validation_middleware import ValidationMiddleware, ValidationError
|
||||||
|
from services.validation import InputValidator, ValidationResult
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidationMiddleware:
|
||||||
|
"""Тесты для ValidationMiddleware"""
|
||||||
|
|
||||||
|
def test_middleware_initialization(self):
|
||||||
|
"""Тест инициализации middleware"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_validate_callback_query_valid(self):
|
||||||
|
"""Тест валидации корректного CallbackQuery"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_validate_callback_query_invalid(self):
|
||||||
|
"""Тест валидации некорректного CallbackQuery"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_validate_message_valid(self):
|
||||||
|
"""Тест валидации корректного Message"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_validate_message_invalid(self):
|
||||||
|
"""Тест валидации некорректного Message"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_validation_error_handling(self):
|
||||||
|
"""Тест обработки ошибок валидации"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_validation_error_response(self):
|
||||||
|
"""Тест ответа на ошибку валидации"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_unsupported_event_type(self):
|
||||||
|
"""Тест обработки неподдерживаемого типа события"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_sanitized_data_injection(self):
|
||||||
|
"""Тест инъекции санитизированных данных"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_validator_injection(self):
|
||||||
|
"""Тест инъекции валидатора в данные"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_handler_continuation_on_valid_data(self):
|
||||||
|
"""Тест продолжения обработки при валидных данных"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_handler_stop_on_invalid_data(self):
|
||||||
|
"""Тест остановки обработки при невалидных данных"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_performance_with_large_data(self):
|
||||||
|
"""Тест производительности с большими данными"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_middleware_with_none_validator(self):
|
||||||
|
"""Тест middleware с None валидатором"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
3
tests/unit/models/__init__.py
Normal file
3
tests/unit/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Unit тесты для моделей данных
|
||||||
|
"""
|
||||||
126
tests/unit/models/test_question.py
Normal file
126
tests/unit/models/test_question.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
Тесты для модели Question и QuestionStatus
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Создание объекта Question
|
||||||
|
- QuestionStatus enum (все значения)
|
||||||
|
- Валидация полей (message_text, answer_text, etc.)
|
||||||
|
- Методы форматирования
|
||||||
|
- Статусы вопросов (pending, answered, rejected, deleted)
|
||||||
|
- Временные поля (created_at, answered_at)
|
||||||
|
- Анонимность (is_anonymous)
|
||||||
|
- Связи с пользователями (from_user_id, to_user_id)
|
||||||
|
- Методы изменения статуса
|
||||||
|
- Валидация длины текста
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from models.question import Question, QuestionStatus
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuestionStatus:
|
||||||
|
"""Тесты для enum QuestionStatus"""
|
||||||
|
|
||||||
|
def test_question_status_values(self):
|
||||||
|
"""Тест всех значений QuestionStatus"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_status_string_values(self):
|
||||||
|
"""Тест строковых значений QuestionStatus"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestQuestion:
|
||||||
|
"""Тесты для модели Question"""
|
||||||
|
|
||||||
|
def test_question_creation_basic(self):
|
||||||
|
"""Тест базового создания вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_creation_with_all_fields(self):
|
||||||
|
"""Тест создания вопроса со всеми полями"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_validation_message_text_required(self):
|
||||||
|
"""Тест обязательности message_text"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_validation_to_user_id_required(self):
|
||||||
|
"""Тест обязательности to_user_id"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_default_status(self):
|
||||||
|
"""Тест статуса по умолчанию"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_default_anonymous(self):
|
||||||
|
"""Тест анонимности по умолчанию"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_default_is_read(self):
|
||||||
|
"""Тест флага is_read по умолчанию"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_created_at_timestamp(self):
|
||||||
|
"""Тест временной метки создания"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_answer_timestamp(self):
|
||||||
|
"""Тест временной метки ответа"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_mark_as_answered(self):
|
||||||
|
"""Тест метода mark_as_answered"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_mark_as_rejected(self):
|
||||||
|
"""Тест метода mark_as_rejected"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_mark_as_deleted(self):
|
||||||
|
"""Тест метода mark_as_deleted"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_mark_as_read(self):
|
||||||
|
"""Тест метода mark_as_read"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_formatting_methods(self):
|
||||||
|
"""Тест методов форматирования"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_validation_message_length(self):
|
||||||
|
"""Тест валидации длины сообщения"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_validation_answer_length(self):
|
||||||
|
"""Тест валидации длины ответа"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_serialization(self):
|
||||||
|
"""Тест сериализации вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_question_deserialization(self):
|
||||||
|
"""Тест десериализации вопроса"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
119
tests/unit/models/test_user.py
Normal file
119
tests/unit/models/test_user.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""
|
||||||
|
Тесты для модели User
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Создание объекта User
|
||||||
|
- Валидация полей (telegram_id, username, first_name, etc.)
|
||||||
|
- Методы форматирования (get_display_name, get_profile_link)
|
||||||
|
- HTML экранирование (escape_html)
|
||||||
|
- Сериализация/десериализация
|
||||||
|
- Обработка None значений
|
||||||
|
- Валидация email (если есть)
|
||||||
|
- Проверка is_active, is_superuser флагов
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from models.user import User, escape_html
|
||||||
|
|
||||||
|
|
||||||
|
class TestUser:
|
||||||
|
"""Тесты для модели User"""
|
||||||
|
|
||||||
|
def test_user_creation_basic(self):
|
||||||
|
"""Тест базового создания пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_creation_with_all_fields(self):
|
||||||
|
"""Тест создания пользователя со всеми полями"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_creation_minimal_required_fields(self):
|
||||||
|
"""Тест создания пользователя с минимальными обязательными полями"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_validation_telegram_id(self):
|
||||||
|
"""Тест валидации telegram_id"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_validation_username(self):
|
||||||
|
"""Тест валидации username"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_validation_first_name_required(self):
|
||||||
|
"""Тест обязательности first_name"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_display_name(self):
|
||||||
|
"""Тест метода get_display_name"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_profile_link_generation(self):
|
||||||
|
"""Тест генерации ссылки профиля"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_html_escaping(self):
|
||||||
|
"""Тест HTML экранирования"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_serialization(self):
|
||||||
|
"""Тест сериализации пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_deserialization(self):
|
||||||
|
"""Тест десериализации пользователя"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_none_handling(self):
|
||||||
|
"""Тест обработки None значений"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_is_active_flag(self):
|
||||||
|
"""Тест флага is_active"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_is_superuser_flag(self):
|
||||||
|
"""Тест флага is_superuser"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_ban_fields(self):
|
||||||
|
"""Тест полей бана (banned_until, ban_reason)"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscapeHtml:
|
||||||
|
"""Тесты для функции escape_html"""
|
||||||
|
|
||||||
|
def test_escape_html_basic(self):
|
||||||
|
"""Тест базового HTML экранирования"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_escape_html_special_characters(self):
|
||||||
|
"""Тест экранирования специальных символов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_escape_html_none_input(self):
|
||||||
|
"""Тест обработки None входных данных"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_escape_html_empty_string(self):
|
||||||
|
"""Тест обработки пустой строки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
74
tests/unit/models/test_user_block.py
Normal file
74
tests/unit/models/test_user_block.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Тесты для модели UserBlock
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Создание объекта UserBlock
|
||||||
|
- Валидация полей (blocker_id, blocked_id)
|
||||||
|
- Временные поля (created_at)
|
||||||
|
- Уникальность пары (blocker_id, blocked_id)
|
||||||
|
- Валидация ID пользователей
|
||||||
|
- Сериализация/десериализация
|
||||||
|
- Обработка None значений
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from models.user_block import UserBlock
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserBlock:
|
||||||
|
"""Тесты для модели UserBlock"""
|
||||||
|
|
||||||
|
def test_user_block_creation_basic(self):
|
||||||
|
"""Тест базового создания блокировки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_creation_with_timestamp(self):
|
||||||
|
"""Тест создания блокировки с временной меткой"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_validation_blocker_id_required(self):
|
||||||
|
"""Тест обязательности blocker_id"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_validation_blocked_id_required(self):
|
||||||
|
"""Тест обязательности blocked_id"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_validation_different_ids(self):
|
||||||
|
"""Тест валидации разных ID (нельзя заблокировать себя)"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_validation_positive_ids(self):
|
||||||
|
"""Тест валидации положительных ID"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_created_at_timestamp(self):
|
||||||
|
"""Тест временной метки создания"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_serialization(self):
|
||||||
|
"""Тест сериализации блокировки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_deserialization(self):
|
||||||
|
"""Тест десериализации блокировки"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_equality(self):
|
||||||
|
"""Тест сравнения блокировок"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_block_string_representation(self):
|
||||||
|
"""Тест строкового представления"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
252
tests/unit/models/test_user_example.py
Normal file
252
tests/unit/models/test_user_example.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"""
|
||||||
|
Пример теста для модели User
|
||||||
|
|
||||||
|
Этот файл демонстрирует, как должны выглядеть реальные тесты.
|
||||||
|
Остальные файлы содержат только заглушки с TODO комментариями.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from models.user import User, escape_html
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserExample:
|
||||||
|
"""Пример тестов для модели User"""
|
||||||
|
|
||||||
|
def test_user_creation_basic(self):
|
||||||
|
"""Тест базового создания пользователя"""
|
||||||
|
# Arrange
|
||||||
|
telegram_id = 123456789
|
||||||
|
first_name = "Test"
|
||||||
|
chat_id = 123456789
|
||||||
|
profile_link = "test_link"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
user = User(
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
first_name=first_name,
|
||||||
|
chat_id=chat_id,
|
||||||
|
profile_link=profile_link
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert user.telegram_id == telegram_id
|
||||||
|
assert user.first_name == first_name
|
||||||
|
assert user.chat_id == chat_id
|
||||||
|
assert user.profile_link == profile_link
|
||||||
|
assert user.is_active is True
|
||||||
|
assert user.is_superuser is False
|
||||||
|
assert user.created_at is not None
|
||||||
|
assert user.updated_at is not None
|
||||||
|
|
||||||
|
def test_user_creation_with_all_fields(self):
|
||||||
|
"""Тест создания пользователя со всеми полями"""
|
||||||
|
# Arrange
|
||||||
|
telegram_id = 123456789
|
||||||
|
username = "testuser"
|
||||||
|
first_name = "Test"
|
||||||
|
last_name = "User"
|
||||||
|
chat_id = 123456789
|
||||||
|
profile_link = "test_link"
|
||||||
|
is_active = True
|
||||||
|
is_superuser = False
|
||||||
|
created_at = datetime.now()
|
||||||
|
updated_at = datetime.now()
|
||||||
|
banned_until = None
|
||||||
|
ban_reason = None
|
||||||
|
|
||||||
|
# Act
|
||||||
|
user = User(
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
username=username,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
chat_id=chat_id,
|
||||||
|
profile_link=profile_link,
|
||||||
|
is_active=is_active,
|
||||||
|
is_superuser=is_superuser,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=updated_at,
|
||||||
|
banned_until=banned_until,
|
||||||
|
ban_reason=ban_reason
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert user.telegram_id == telegram_id
|
||||||
|
assert user.username == username
|
||||||
|
assert user.first_name == first_name
|
||||||
|
assert user.last_name == last_name
|
||||||
|
assert user.chat_id == chat_id
|
||||||
|
assert user.profile_link == profile_link
|
||||||
|
assert user.is_active == is_active
|
||||||
|
assert user.is_superuser == is_superuser
|
||||||
|
assert user.created_at == created_at
|
||||||
|
assert user.updated_at == updated_at
|
||||||
|
assert user.banned_until == banned_until
|
||||||
|
assert user.ban_reason == ban_reason
|
||||||
|
|
||||||
|
def test_user_validation_telegram_id_positive(self):
|
||||||
|
"""Тест валидации положительного Telegram ID"""
|
||||||
|
# Arrange
|
||||||
|
telegram_id = 123456789
|
||||||
|
|
||||||
|
# Act
|
||||||
|
user = User(
|
||||||
|
telegram_id=telegram_id,
|
||||||
|
first_name="Test",
|
||||||
|
chat_id=123456789,
|
||||||
|
profile_link="test_link"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert user.telegram_id == telegram_id
|
||||||
|
assert user.telegram_id > 0
|
||||||
|
|
||||||
|
def test_user_display_name_with_username(self):
|
||||||
|
"""Тест метода get_display_name с username"""
|
||||||
|
# Arrange
|
||||||
|
user = User(
|
||||||
|
telegram_id=123456789,
|
||||||
|
username="testuser",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
chat_id=123456789,
|
||||||
|
profile_link="test_link"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
display_name = user.get_display_name()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert display_name == "@testuser"
|
||||||
|
|
||||||
|
def test_user_display_name_without_username(self):
|
||||||
|
"""Тест метода get_display_name без username"""
|
||||||
|
# Arrange
|
||||||
|
user = User(
|
||||||
|
telegram_id=123456789,
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
chat_id=123456789,
|
||||||
|
profile_link="test_link"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
display_name = user.get_display_name()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert display_name == "Test User"
|
||||||
|
|
||||||
|
def test_user_display_name_first_name_only(self):
|
||||||
|
"""Тест метода get_display_name только с first_name"""
|
||||||
|
# Arrange
|
||||||
|
user = User(
|
||||||
|
telegram_id=123456789,
|
||||||
|
first_name="Test",
|
||||||
|
chat_id=123456789,
|
||||||
|
profile_link="test_link"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
display_name = user.get_display_name()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert display_name == "Test"
|
||||||
|
|
||||||
|
def test_user_profile_link_generation(self):
|
||||||
|
"""Тест генерации ссылки профиля"""
|
||||||
|
# Arrange
|
||||||
|
user = User(
|
||||||
|
telegram_id=123456789,
|
||||||
|
first_name="Test",
|
||||||
|
chat_id=123456789,
|
||||||
|
profile_link="test_link"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
profile_link = user.get_profile_link()
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert profile_link == "test_link"
|
||||||
|
assert profile_link.startswith("https://t.me/")
|
||||||
|
|
||||||
|
def test_user_string_representation(self):
|
||||||
|
"""Тест строкового представления пользователя"""
|
||||||
|
# Arrange
|
||||||
|
user = User(
|
||||||
|
telegram_id=123456789,
|
||||||
|
username="testuser",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
chat_id=123456789,
|
||||||
|
profile_link="test_link"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Act
|
||||||
|
str_repr = str(user)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert "User" in str_repr
|
||||||
|
assert "123456789" in str_repr
|
||||||
|
assert "testuser" in str_repr
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscapeHtmlExample:
|
||||||
|
"""Пример тестов для функции escape_html"""
|
||||||
|
|
||||||
|
def test_escape_html_basic(self):
|
||||||
|
"""Тест базового HTML экранирования"""
|
||||||
|
# Arrange
|
||||||
|
text = "<script>alert('xss')</script>"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
escaped = escape_html(text)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert escaped == "<script>alert('xss')</script>"
|
||||||
|
assert "<" not in escaped
|
||||||
|
assert ">" not in escaped
|
||||||
|
assert "'" not in escaped
|
||||||
|
|
||||||
|
def test_escape_html_special_characters(self):
|
||||||
|
"""Тест экранирования специальных символов"""
|
||||||
|
# Arrange
|
||||||
|
text = "Test & < > \" '"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
escaped = escape_html(text)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert escaped == "Test & < > " '"
|
||||||
|
|
||||||
|
def test_escape_html_none_input(self):
|
||||||
|
"""Тест обработки None входных данных"""
|
||||||
|
# Arrange
|
||||||
|
text = None
|
||||||
|
|
||||||
|
# Act
|
||||||
|
escaped = escape_html(text)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert escaped is None
|
||||||
|
|
||||||
|
def test_escape_html_empty_string(self):
|
||||||
|
"""Тест обработки пустой строки"""
|
||||||
|
# Arrange
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
# Act
|
||||||
|
escaped = escape_html(text)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert escaped == ""
|
||||||
|
|
||||||
|
def test_escape_html_already_escaped(self):
|
||||||
|
"""Тест обработки уже экранированного текста"""
|
||||||
|
# Arrange
|
||||||
|
text = "<script>"
|
||||||
|
|
||||||
|
# Act
|
||||||
|
escaped = escape_html(text)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert escaped == "&lt;script&gt;"
|
||||||
91
tests/unit/models/test_user_settings.py
Normal file
91
tests/unit/models/test_user_settings.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
Тесты для модели UserSettings
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Создание объекта UserSettings
|
||||||
|
- Валидация полей (user_id, allow_questions, etc.)
|
||||||
|
- Булевы флаги (allow_questions, notify_new_questions, notify_answers)
|
||||||
|
- Языковые настройки (language)
|
||||||
|
- Временные поля (created_at, updated_at)
|
||||||
|
- Связь с пользователем (user_id)
|
||||||
|
- Сериализация/десериализация
|
||||||
|
- Обработка None значений
|
||||||
|
- Валидация языка
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime
|
||||||
|
from models.user_settings import UserSettings
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserSettings:
|
||||||
|
"""Тесты для модели UserSettings"""
|
||||||
|
|
||||||
|
def test_user_settings_creation_basic(self):
|
||||||
|
"""Тест базового создания настроек"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_creation_with_all_fields(self):
|
||||||
|
"""Тест создания настроек со всеми полями"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_validation_user_id_required(self):
|
||||||
|
"""Тест обязательности user_id"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_default_allow_questions(self):
|
||||||
|
"""Тест значения по умолчанию для allow_questions"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_default_notify_new_questions(self):
|
||||||
|
"""Тест значения по умолчанию для notify_new_questions"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_default_notify_answers(self):
|
||||||
|
"""Тест значения по умолчанию для notify_answers"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_default_language(self):
|
||||||
|
"""Тест языка по умолчанию"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_validation_language(self):
|
||||||
|
"""Тест валидации языка"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_created_at_timestamp(self):
|
||||||
|
"""Тест временной метки создания"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_updated_at_timestamp(self):
|
||||||
|
"""Тест временной метки обновления"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_serialization(self):
|
||||||
|
"""Тест сериализации настроек"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_deserialization(self):
|
||||||
|
"""Тест десериализации настроек"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_boolean_flags(self):
|
||||||
|
"""Тест булевых флагов"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user_settings_none_handling(self):
|
||||||
|
"""Тест обработки None значений"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
3
tests/unit/services/__init__.py
Normal file
3
tests/unit/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Unit тесты для сервисов
|
||||||
|
"""
|
||||||
3
tests/unit/services/auth/__init__.py
Normal file
3
tests/unit/services/auth/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Unit тесты для сервисов авторизации
|
||||||
|
"""
|
||||||
111
tests/unit/services/auth/test_auth_service.py
Normal file
111
tests/unit/services/auth/test_auth_service.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Тесты для AuthService
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Проверка администраторов (is_admin)
|
||||||
|
- Проверка суперпользователей (is_superuser)
|
||||||
|
- Получение роли пользователя (get_user_role)
|
||||||
|
- Проверка разрешений (has_permission)
|
||||||
|
- Обработка ошибок БД
|
||||||
|
- Интеграция с системой разрешений
|
||||||
|
- Кэширование результатов
|
||||||
|
- Граничные случаи (несуществующие пользователи)
|
||||||
|
- Валидация входных параметров
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from services.auth.auth_new import AuthService
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthService:
|
||||||
|
"""Тесты для AuthService"""
|
||||||
|
|
||||||
|
def test_is_admin_valid_admin(self):
|
||||||
|
"""Тест проверки администратора - валидный админ"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_is_admin_invalid_admin(self):
|
||||||
|
"""Тест проверки администратора - не админ"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_is_admin_none_user_id(self):
|
||||||
|
"""Тест проверки администратора - None user_id"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_is_superuser_valid_superuser(self):
|
||||||
|
"""Тест проверки суперпользователя - валидный суперпользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_is_superuser_invalid_superuser(self):
|
||||||
|
"""Тест проверки суперпользователя - не суперпользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_is_superuser_nonexistent_user(self):
|
||||||
|
"""Тест проверки суперпользователя - несуществующий пользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_is_superuser_database_error(self):
|
||||||
|
"""Тест проверки суперпользователя - ошибка БД"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_user_role_admin(self):
|
||||||
|
"""Тест получения роли - администратор"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_user_role_superuser(self):
|
||||||
|
"""Тест получения роли - суперпользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_user_role_regular_user(self):
|
||||||
|
"""Тест получения роли - обычный пользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_user_role_nonexistent_user(self):
|
||||||
|
"""Тест получения роли - несуществующий пользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_has_permission_valid_permission(self):
|
||||||
|
"""Тест проверки разрешения - валидное разрешение"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_has_permission_invalid_permission(self):
|
||||||
|
"""Тест проверки разрешения - невалидное разрешение"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_has_permission_nonexistent_user(self):
|
||||||
|
"""Тест проверки разрешения - несуществующий пользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_has_permission_database_error(self):
|
||||||
|
"""Тест проверки разрешения - ошибка БД"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_auth_service_initialization(self):
|
||||||
|
"""Тест инициализации AuthService"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_auth_service_with_none_database(self):
|
||||||
|
"""Тест AuthService с None базой данных"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_auth_service_with_none_config(self):
|
||||||
|
"""Тест AuthService с None конфигурацией"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
139
tests/unit/services/auth/test_permissions.py
Normal file
139
tests/unit/services/auth/test_permissions.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
Тесты для системы разрешений
|
||||||
|
|
||||||
|
Что тестировать:
|
||||||
|
- Базовый класс Permission
|
||||||
|
- Конкретные разрешения (AdminPermission, SuperuserPermission)
|
||||||
|
- Реестр разрешений (PermissionRegistry)
|
||||||
|
- Декораторы проверки разрешений
|
||||||
|
- Инициализация разрешений
|
||||||
|
- Проверка разрешений для разных ролей
|
||||||
|
- Обработка ошибок в разрешениях
|
||||||
|
- Кэширование результатов проверки
|
||||||
|
- Интеграция с AuthService
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from services.permissions.base import Permission, PermissionRegistry
|
||||||
|
from services.permissions.permissions import AdminPermission, SuperuserPermission
|
||||||
|
from services.permissions.decorators import require_permission
|
||||||
|
from services.permissions.init_permissions import init_all_permissions
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermission:
|
||||||
|
"""Тесты для базового класса Permission"""
|
||||||
|
|
||||||
|
def test_permission_creation(self):
|
||||||
|
"""Тест создания разрешения"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_permission_abstract_method(self):
|
||||||
|
"""Тест абстрактного метода check"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_permission_string_representation(self):
|
||||||
|
"""Тест строкового представления разрешения"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdminPermission:
|
||||||
|
"""Тесты для AdminPermission"""
|
||||||
|
|
||||||
|
def test_admin_permission_check_valid_admin(self):
|
||||||
|
"""Тест проверки разрешения - валидный админ"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_admin_permission_check_invalid_admin(self):
|
||||||
|
"""Тест проверки разрешения - не админ"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_admin_permission_check_none_user_id(self):
|
||||||
|
"""Тест проверки разрешения - None user_id"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestSuperuserPermission:
|
||||||
|
"""Тесты для SuperuserPermission"""
|
||||||
|
|
||||||
|
def test_superuser_permission_check_valid_superuser(self):
|
||||||
|
"""Тест проверки разрешения - валидный суперпользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_superuser_permission_check_invalid_superuser(self):
|
||||||
|
"""Тест проверки разрешения - не суперпользователь"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_superuser_permission_check_database_error(self):
|
||||||
|
"""Тест проверки разрешения - ошибка БД"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestPermissionRegistry:
|
||||||
|
"""Тесты для PermissionRegistry"""
|
||||||
|
|
||||||
|
def test_permission_registry_creation(self):
|
||||||
|
"""Тест создания реестра разрешений"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_register_permission(self):
|
||||||
|
"""Тест регистрации разрешения"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_permission_existing(self):
|
||||||
|
"""Тест получения существующего разрешения"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_permission_nonexistent(self):
|
||||||
|
"""Тест получения несуществующего разрешения"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_list_permissions(self):
|
||||||
|
"""Тест получения списка разрешений"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequirePermissionDecorator:
|
||||||
|
"""Тесты для декоратора require_permission"""
|
||||||
|
|
||||||
|
def test_require_permission_valid_permission(self):
|
||||||
|
"""Тест декоратора - валидное разрешение"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_require_permission_invalid_permission(self):
|
||||||
|
"""Тест декоратора - невалидное разрешение"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_require_permission_error_message(self):
|
||||||
|
"""Тест декоратора - сообщение об ошибке"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestInitPermissions:
|
||||||
|
"""Тесты для инициализации разрешений"""
|
||||||
|
|
||||||
|
def test_init_all_permissions(self):
|
||||||
|
"""Тест инициализации всех разрешений"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_get_available_permissions(self):
|
||||||
|
"""Тест получения доступных разрешений"""
|
||||||
|
# TODO: Реализовать тест
|
||||||
|
pass
|
||||||
3
tests/unit/services/business/__init__.py
Normal file
3
tests/unit/services/business/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Unit тесты для бизнес-сервисов
|
||||||
|
"""
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user