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
|
||||
*.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/
|
||||
@@ -169,3 +154,16 @@ cython_debug/
|
||||
|
||||
# PyPI configuration file
|
||||
.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}")
|
||||
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("""
|
||||
INSERT INTO questions
|
||||
(from_user_id, to_user_id, message_text, answer_text, is_anonymous,
|
||||
message_id, created_at, answered_at, is_read, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
message_id, created_at, answered_at, is_read, status, user_question_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
question.from_user_id, question.to_user_id, question.message_text,
|
||||
question.answer_text, question.is_anonymous, question.message_id,
|
||||
question.created_at.isoformat() if question.created_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
|
||||
await conn.commit()
|
||||
logger.info(f"✅ Вопрос создан с ID: {question.id}")
|
||||
logger.info(f"✅ Вопрос создан с ID: {question.id}, номер для пользователя: {question.user_question_number}")
|
||||
return question
|
||||
|
||||
async def create_batch(self, questions: List[Question]) -> List[Question]:
|
||||
@@ -356,6 +366,27 @@ class QuestionCRUD(BaseCRUD):
|
||||
logger.info(f"📦 Создание {len(questions)} вопросов batch операцией")
|
||||
async with self.get_connection() as conn:
|
||||
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_data = []
|
||||
for question in questions:
|
||||
@@ -364,15 +395,15 @@ class QuestionCRUD(BaseCRUD):
|
||||
question.answer_text, question.is_anonymous, question.message_id,
|
||||
question.created_at.isoformat() if question.created_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 вставку
|
||||
cursor = await conn.executemany("""
|
||||
INSERT INTO questions
|
||||
(from_user_id, to_user_id, message_text, answer_text, is_anonymous,
|
||||
message_id, created_at, answered_at, is_read, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
message_id, created_at, answered_at, is_read, status, user_question_number)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", batch_data)
|
||||
|
||||
# Обновляем ID для всех созданных вопросов
|
||||
@@ -393,7 +424,11 @@ class QuestionCRUD(BaseCRUD):
|
||||
"""Получение вопроса по ID"""
|
||||
async with self.get_connection() as conn:
|
||||
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:
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
@@ -408,7 +443,7 @@ class QuestionCRUD(BaseCRUD):
|
||||
SELECT
|
||||
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_read, q.status
|
||||
q.is_read, q.status, q.user_question_number
|
||||
FROM questions q
|
||||
WHERE q.to_user_id = ?
|
||||
"""
|
||||
@@ -418,7 +453,7 @@ class QuestionCRUD(BaseCRUD):
|
||||
query += " AND q.status = ?"
|
||||
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])
|
||||
|
||||
async with conn.execute(query, params) as cursor:
|
||||
@@ -455,7 +490,7 @@ class QuestionCRUD(BaseCRUD):
|
||||
query += " AND q.status = ?"
|
||||
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])
|
||||
|
||||
async with conn.execute(query, params) as cursor:
|
||||
@@ -467,9 +502,6 @@ class QuestionCRUD(BaseCRUD):
|
||||
if question is None:
|
||||
print(f"Предупреждение: вопрос не создан для строки {row[:11]}")
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Ошибка при создании вопроса из строки {row[:11]}: {e}")
|
||||
continue
|
||||
|
||||
author = None
|
||||
if row[11]: # Если есть author_id
|
||||
@@ -489,6 +521,9 @@ class QuestionCRUD(BaseCRUD):
|
||||
ban_reason=row[23]
|
||||
)
|
||||
result.append((question, author))
|
||||
except Exception as e:
|
||||
print(f"Ошибка при создании вопроса из строки {row[:11]}: {e}")
|
||||
continue
|
||||
return result
|
||||
|
||||
async def get_by_to_user_cursor(
|
||||
@@ -506,7 +541,7 @@ class QuestionCRUD(BaseCRUD):
|
||||
SELECT
|
||||
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_read, q.status
|
||||
q.is_read, q.status, q.user_question_number
|
||||
FROM questions q
|
||||
WHERE q.to_user_id = ?
|
||||
AND (q.created_at < ? OR (q.created_at = ? AND q.id < ?))
|
||||
@@ -519,7 +554,7 @@ class QuestionCRUD(BaseCRUD):
|
||||
SELECT
|
||||
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_read, q.status
|
||||
q.is_read, q.status, q.user_question_number
|
||||
FROM questions q
|
||||
WHERE q.to_user_id = ?
|
||||
AND (q.created_at > ? OR (q.created_at = ? AND q.id > ?))
|
||||
@@ -555,7 +590,7 @@ class QuestionCRUD(BaseCRUD):
|
||||
query += " AND q.status = ?"
|
||||
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])
|
||||
|
||||
async with conn.execute(query, params) as cursor:
|
||||
@@ -566,6 +601,46 @@ class QuestionCRUD(BaseCRUD):
|
||||
"""Обновление вопроса"""
|
||||
logger.info(f"📝 Обновление вопроса {question.id} (статус: {question.status.value})")
|
||||
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("""
|
||||
UPDATE questions SET
|
||||
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.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()
|
||||
logger.info(f"✅ Вопрос {question.id} обновлен")
|
||||
return question
|
||||
|
||||
async def delete(self, question_id: int) -> bool:
|
||||
"""Удаление вопроса"""
|
||||
"""Удаление вопроса с пересчетом user_question_number"""
|
||||
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("""
|
||||
DELETE FROM questions WHERE 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()
|
||||
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:
|
||||
"""Получение количества непрочитанных вопросов"""
|
||||
@@ -641,7 +754,7 @@ class QuestionCRUD(BaseCRUD):
|
||||
"""Преобразование строки БД в объект Question"""
|
||||
# Проверяем, что все необходимые поля присутствуют
|
||||
if len(row) < 11:
|
||||
raise ValueError(f"Недостаточно данных в строке БД: {len(row)} колонок, ожидается 11")
|
||||
raise ValueError(f"Недостаточно данных в строке БД: {len(row)} колонок, ожидается минимум 11")
|
||||
|
||||
# Проверяем статус
|
||||
status_value = row[10]
|
||||
@@ -665,7 +778,8 @@ class QuestionCRUD(BaseCRUD):
|
||||
created_at=self._parse_datetime(row[7]),
|
||||
answered_at=self._parse_datetime(row[8]),
|
||||
is_read=bool(row[9]),
|
||||
status=status
|
||||
status=status,
|
||||
user_question_number=row[11] if len(row) > 11 else None
|
||||
)
|
||||
return question
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ CREATE TABLE questions (
|
||||
answered_at DATETIME,
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
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,
|
||||
@@ -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_created_at ON questions(created_at);
|
||||
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_blocked_id ON user_blocks(blocked_id);
|
||||
@@ -106,3 +109,39 @@ FOR EACH ROW
|
||||
BEGIN
|
||||
INSERT OR IGNORE INTO user_settings (user_id) VALUES (NEW.telegram_id);
|
||||
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