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:
2025-09-06 18:35:12 +03:00
parent 50be010026
commit 596a2fa813
111 changed files with 16847 additions and 65 deletions

View 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']

View 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

View 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

View 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)

View 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 []