diff --git a/.env.example b/.env.example index fb1983e..97849da 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,11 @@ SSL_ENABLED=True SSL_KEY_PATH="" SSL_CERT_PATH="" -# Параметры для Nextcloud +# Параметры для Nextcloud Deck NC_URL="https://yourdomain" NC_LOGIN="" -NC_PASSWORD="" \ No newline at end of file +NC_PASSWORD="" + +# ID доски и стека для списка покупок +DECK_BOARD_ID=1 +DECK_STACK_ID=1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c032bde..8082d76 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,9 +20,3 @@ repos: # hooks: # - id: isort # # сортирует импорты в Python - - - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - args: [--max-line-length=88] diff --git a/.scripts/project_tree.py b/.scripts/project_tree.py index c01fb98..6c07a16 100644 --- a/.scripts/project_tree.py +++ b/.scripts/project_tree.py @@ -37,7 +37,10 @@ def print_tree(directory: Path, ignore_dirs: list[str] = None, prefix: str = "") yield from print_tree(path, ignore_dirs, prefix=prefix + extension) -def main(): +def main() -> None: + """ + Основная функция запуска отрисовки дерева файлов + """ parser = argparse.ArgumentParser( description="Отображение древовидной структуры проекта" ) diff --git a/config/settings.py b/config/settings.py index 81df07d..39eba72 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1,7 +1,6 @@ """Файл настроек приложения""" from typing import Optional - from pydantic import Field from pydantic_settings import BaseSettings @@ -9,32 +8,36 @@ class Settings(BaseSettings): """Конфигурация приложения""" - VERSION: str = Field("0.0.2.2") + VERSION: str = "0.0.3.0" # Токен бота Telegram BOT_TOKEN: Optional[str] = Field(None, description="Токен бота от @BotFather") # Настройки сервера - HOST: str = Field("0.0.0.0", description="Хост для запуска сервера") - PORT: int = Field(8443, description="Порт для запуска сервера") + HOST: str = "0.0.0.0" + PORT: int = 8443 # SSL - SSL_ENABLED: bool = Field(False, description="Использование локального SSL") - SSL_KEY_PATH: Optional[str] = Field(None, description="Ключ сертификата") - SSL_CERT_PATH: Optional[str] = Field(None, description="Сертификат") + SSL_ENABLED: bool = False + SSL_KEY_PATH: Optional[str] = None + SSL_CERT_PATH: Optional[str] = None - # Настройки вебхука (опционально для продакшена) - WEBHOOK_HOST: Optional[str] = Field(None, description="Публичный URL для вебхука") + # Настройки вебхука + WEBHOOK_HOST: Optional[str] = None - # Настройки NextCloud + # Настройки NextCloud Deck NC_URL: Optional[str] = Field(None, description="Адрес Nextcloud") NC_LOGIN: Optional[str] = Field(None, description="Логин Nextcloud") NC_PASSWORD: Optional[str] = Field(None, description="Пароль Nextcloud") + # ID доски и стека для списка покупок + DECK_BOARD_ID: int = 1 + DECK_STACK_ID: int = 1 + # Настройки логирования - LOG_LEVEL: str = Field("INFO", description="Уровень логирования") + LOG_LEVEL: str = "INFO" - class Config: # pylint: disable=R0903 + class Config: """Конфигурация Pydantic""" env_file = ".env" diff --git a/handlers/bot_routes/route_deck_boards.py b/handlers/bot_routes/route_deck_boards.py deleted file mode 100644 index 5b49565..0000000 --- a/handlers/bot_routes/route_deck_boards.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Обработчик для работы с досками""" - -from aiogram import types -from aiogram.filters.callback_data import CallbackData -from aiogram.utils.keyboard import InlineKeyboardBuilder - -from handlers.handler_logging import logger -from handlers.handler_nc_deck import get_boards - -KBD_COLUMNS = 1 - - -class NavigationCallback(CallbackData, prefix="nav"): - """Универсальный callback для навигации по доскам и стекам""" - - screen: str # 'boards_list', 'stacks_list', 'cards_list' - target_id: int = 0 # ID доски или стека - - -async def show_boards_command(message: types.Message) -> None: - """ - Показывает список досок в виде инлайн-кнопок - - :param message: Сообщение от пользователя - :type message: types.Message - - Пользователь -> /decks -> - Бот показывает кнопки с досками - """ - logger.info(f"Пользователь {message.from_user.id} запросил список досок") - decks = await get_boards() - - if not decks: - await message.answer("❌ Доски не найдены") - return - - keyboard_builder = InlineKeyboardBuilder() - - for deck in decks: - keyboard_builder.button( - text=f"🗂️ {deck.title}", - callback_data=NavigationCallback(screen="stacks_list", target_id=deck.id), - ) - - keyboard_builder.adjust(KBD_COLUMNS) - - await message.answer( - "🗃️ Выберите доску:", - reply_markup=keyboard_builder.as_markup(), - parse_mode="HTML", - ) diff --git a/handlers/bot_routes/route_deck_cards.py b/handlers/bot_routes/route_deck_cards.py deleted file mode 100644 index 15bb253..0000000 --- a/handlers/bot_routes/route_deck_cards.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Обработчик для работы с карточками""" - -from aiogram import types - -from handlers.bot_routes.route_deck_boards import NavigationCallback -from handlers.handler_logging import logger - - -async def show_cards_in_stack( - callback: types.CallbackQuery, callback_data: NavigationCallback -) -> None: - """ - Показывает карточки в стеке (заглушка) - - :param callback: Callback от нажатой кнопки в предыдущем меню - :type callback: types.CallbackQuery - :param callback_data: Данные callback'а - :type callback_data: NavigationCallback - - Пользователь нажимает на кнопку стека -> - Бот показывает карточки (пока заглушка) - """ - stack_id = callback_data.target_id - logger.info(f"Пользователь {callback.from_user.id} выбрал стек {stack_id}") - - # NOTE: Здесь будет логика получения и отображения карточек - await callback.message.edit_text( - f"📋 Карточки стека {stack_id}\n\n" - f"Функционал отображения карточек в разработке...", - parse_mode="HTML", - ) - await callback.answer() diff --git a/handlers/bot_routes/route_deck_stacks.py b/handlers/bot_routes/route_deck_stacks.py deleted file mode 100644 index 14d1c0f..0000000 --- a/handlers/bot_routes/route_deck_stacks.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Обработчик для работы со стеками""" - -from aiogram import types -from aiogram.utils.keyboard import InlineKeyboardBuilder - -from handlers.bot_routes.route_deck_boards import ( - NavigationCallback, - show_boards_command, -) -from handlers.handler_logging import logger -from handlers.handler_nc_deck import get_stacks - -KBD_COLUMNS = 1 - - -async def show_stacks_for_board( - callback: types.CallbackQuery, callback_data: NavigationCallback -) -> None: - """ - Показывает стеки выбранной доски - - :param callback: Callback от нажатой кнопки в предыдущем меню - :type callback: types.CallbackQuery - :param callback_data: Данные callback'а - :type callback_data: NavigationCallback - - Пользователь нажимает на кнопку доски -> - Бот показывает кнопки со стеками этой доски - """ - board_id = callback_data.target_id - logger.info(f"Пользователь {callback.from_user.id} выбрал доску {board_id}") - stacks = await get_stacks(board_id) - - if not stacks: - await callback.message.edit_text("❌ Стеки не найдены для этой доски") - await callback.answer() - return - - keyboard_builder = InlineKeyboardBuilder() - - for stack in stacks: - keyboard_builder.button( - text=f"📑 {stack.title}", - callback_data=NavigationCallback(screen="cards_list", target_id=stack.id), - ) - - keyboard_builder.button( - text="⬅️ Назад к доскам", callback_data=NavigationCallback(screen="boards_list") - ) - - keyboard_builder.adjust(KBD_COLUMNS) - - await callback.message.edit_text( - "📚 Стеки выбранной доски:", - reply_markup=keyboard_builder.as_markup(), - parse_mode="HTML", - ) - await callback.answer() - - -async def back_to_boards_list(callback: types.CallbackQuery) -> None: - """ - Возвращает к списку досок - - :param callback: Callback от нажатия кнопки "Назад" - :type callback: types.CallbackQuery - - Пользователь нажимает "Назад" -> - Бот снова показывает список досок - """ - - await show_boards_command(callback.message) - await callback.answer() diff --git a/handlers/bot_routes/route_edit_items.py b/handlers/bot_routes/route_edit_items.py new file mode 100644 index 0000000..64f3e3f --- /dev/null +++ b/handlers/bot_routes/route_edit_items.py @@ -0,0 +1,151 @@ +"""Логика редактирования элементов карточки""" + +from aiogram import Router, types, F +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from handlers.handler_nc_deck import update_card_description +from handlers.bot_routes.states import CardCallback +from handlers.bot_routes.route_view_card import ( + get_cached_card, + _show_card_view, +) +from handlers.bot_routes.route_list_cards import list_handler + +# from handlers.handler_logging import logger + +edit_router = Router() + + +@edit_router.callback_query(CardCallback.filter(F.action == "toggle")) +async def toggle_item_handler( + callback: types.CallbackQuery, callback_data: CardCallback +) -> None: + """ + Переключить статус элемента списка + + :param callback: Callback запрос + :type callback: types.CallbackQuery + :param callback_data: Данные callback + :type callback_data: CardCallback + """ + # Пытаемся получить карточку из кэша + target_card = await get_cached_card(callback_data.card_id) + + if not target_card or not target_card.description: + await callback.answer("❌ Карточка не найдена") + return + + lines = target_card.description.split("\n") + if 0 <= callback_data.item_index < len(lines): + line = lines[callback_data.item_index] + + if "[ ]" in line: + lines[callback_data.item_index] = line.replace("[ ]", "[x]") + elif "[x]" in line: + lines[callback_data.item_index] = line.replace("[x]", "[ ]") + + new_description = "\n".join(lines) + success = await update_card_description(target_card.id, new_description) + + if success: + await callback.answer("✅ Статус обновлен") + # Обновляем кэш и показываем обновленную карточку + target_card.description = new_description + await _show_card_view(callback.message, target_card) + else: + await callback.answer("❌ Ошибка обновления") + else: + await callback.answer("❌ Элемент не найден") + + +@edit_router.callback_query(CardCallback.filter(F.action == "remove")) +async def remove_items_handler( + callback: types.CallbackQuery, callback_data: CardCallback +) -> None: + """ + Начать удаление элементов + + :param callback: Callback запрос + :type callback: types.CallbackQuery + :param callback_data: Данные callback + :type callback_data: CardCallback + """ + # Пытаемся получить карточку из кэша + target_card = await get_cached_card(callback_data.card_id) + + if not target_card: + await callback.answer("❌ Карточка не найдена") + return + + items = target_card.get_list_items() + if not items: + await callback.answer("📝 Список пуст") + await list_handler(callback.message) + return + + keyboard_builder = InlineKeyboardBuilder() + for index, item in enumerate(items): + keyboard_builder.button( + text=f"🗑️ {item}", + callback_data=CardCallback( + action="delete", card_id=target_card.id, item_index=index + ), + ) + + keyboard_builder.button( + text="⬅️ Назад", + callback_data=CardCallback(action="view", card_id=target_card.id), + ) + keyboard_builder.adjust(1) + + await callback.message.edit_text( + "🗑️ Выберите элементы для удаления:", + reply_markup=keyboard_builder.as_markup(), + parse_mode="HTML", + ) + await callback.answer() + + +@edit_router.callback_query(CardCallback.filter(F.action == "delete")) +async def delete_item_handler( + callback: types.CallbackQuery, callback_data: CardCallback +) -> None: + """ + Удалить элемент из списка + + :param callback: Callback запрос + :type callback: types.CallbackQuery + :param callback_data: Данные callback + :type callback_data: CardCallback + """ + # Пытаемся получить карточку из кэша + target_card = await get_cached_card(callback_data.card_id) + + if not target_card or not target_card.description: + await callback.answer("❌ Карточка не найдена") + return + + lines = target_card.description.split("\n") + if 0 <= callback_data.item_index < len(lines): + lines.pop(callback_data.item_index) + + new_description = "\n".join(lines) if lines else "" + success = await update_card_description(target_card.id, new_description) + + if success: + await callback.answer("✅ Элемент удален") + + # Обновляем кэш + target_card.description = new_description + items = target_card.get_list_items() + + if items: + await remove_items_handler(callback, callback_data) + else: + await callback.answer("📝 Список пуст") + callback_data.action = "view" + await _show_card_view(callback.message, target_card) + else: + await callback.answer("❌ Ошибка удаления") + else: + await callback.answer("❌ Элемент не найден") diff --git a/handlers/bot_routes/route_list_cards.py b/handlers/bot_routes/route_list_cards.py new file mode 100644 index 0000000..6451a45 --- /dev/null +++ b/handlers/bot_routes/route_list_cards.py @@ -0,0 +1,91 @@ +"""Логика отображения списка карточек""" + +from aiogram import Router, types, F +from aiogram.filters import Command, CommandStart +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from handlers.handler_logging import logger +from handlers.handler_nc_deck import get_shopping_cards +from handlers.bot_routes.states import CardCallback + +list_router = Router() + +MAX_TITLE_LENGTH = 30 + + +@list_router.message(CommandStart()) +@list_router.message(Command("help")) +async def start_help_handler(message: types.Message) -> None: + """ + Обработчик команд /start и /help + + :param message: Входящее сообщение + :type message: types.Message + """ + help_text = """🛒 Бот для списка покупок + +Команды: +/list - Показать список покупок +/help - Эта справка + +💡 Просто введите элементы покупок сообщением, чтобы добавить их в список! +""" + await message.answer(help_text) + + +@list_router.message(Command("list")) +async def list_handler(message: types.Message) -> None: + """ + Показать список покупок в виде инлайн кнопок + + :param message: Входящее сообщение + :type message: types.Message + """ + logger.info( + f"Пользователь {message.from_user.id} " + f"({message.from_user.username}) запросил список покупок" + ) + cards = await get_shopping_cards() + + if cards is None: + await message.answer("❌ Ошибка загрузки списка покупок") + return + + if not cards: + await message.answer("📝 Список покупок пуст") + return + + keyboard_builder = InlineKeyboardBuilder() + for card in cards: + keyboard_builder.button( + text=f"📋 {card.short_title}", + callback_data=CardCallback(action="view", card_id=card.id), + ) + + keyboard_builder.adjust(1) + await message.answer( + f"🛒 Список покупок ({len(cards)}):", # noqa: E231 + reply_markup=keyboard_builder.as_markup(), + ) + + +@list_router.callback_query(CardCallback.filter(F.action == "back")) +async def back_to_list_handler( + callback: types.CallbackQuery, callback_data: CardCallback +) -> None: + """ + Вернуться к списку карточек + + :param callback: Callback запрос + :type callback: types.CallbackQuery + :param callback_data: Данные callback + :type callback_data: CardCallback + """ + try: + del callback_data + await callback.answer() + await list_handler(callback.message) + + except Exception as error: + logger.error(f"Ошибка в back_to_list_handler: {error}") + await callback.answer("❌ Ошибка возврата к списку") diff --git a/handlers/bot_routes/route_view_card.py b/handlers/bot_routes/route_view_card.py new file mode 100644 index 0000000..e0110c1 --- /dev/null +++ b/handlers/bot_routes/route_view_card.py @@ -0,0 +1,278 @@ +"""Логика просмотра и управления карточкой""" + +from aiogram import Router, types, F +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from handlers.handler_nc_deck import get_shopping_cards, update_card_description +from handlers.bot_routes.states import CardCallback +from handlers.bot_routes.route_list_cards import list_handler +from handlers.handler_logging import logger + +view_router = Router() + +MAX_ITEM_PREVIEW_LENGTH = 50 +ELLIPSIS_LENGTH = 3 + +# Простой кэш карточек на время сессии +_card_cache = {} + +# Кэш для отслеживания активной карточки по пользователю +_user_active_card = {} + + +@view_router.callback_query(CardCallback.filter(F.action == "view")) +async def view_card_handler( + callback: types.CallbackQuery, callback_data: CardCallback +) -> None: + """ + Показать детали карточки с элементами списка + + :param callback: Callback запрос + :type callback: types.CallbackQuery + :param callback_data: Данные callback + :type callback_data: CardCallback + """ + cards = await get_shopping_cards() + if not cards: + await callback.message.edit_text("❌ Карточка не найдена") + return + + target_card = next( + (card for card in cards if card.id == callback_data.card_id), None + ) + if not target_card: + await callback.message.edit_text("❌ Карточка не найдена") + return + + # Кэшируем карточку + _card_cache[callback_data.card_id] = target_card + + # Сохраняем активную карточку для пользователя + user_id = callback.from_user.id + _user_active_card[user_id] = callback_data.card_id + + await _show_card_view(callback.message, target_card) + await callback.answer() + + +async def _show_card_view(message: types.Message, card) -> None: + """ + Показать представление карточки + + :param message: Сообщение для редактирования/отправки + :type message: types.Message + :param card: Карточка для отображения + """ + items = card.get_list_items() + keyboard_builder = InlineKeyboardBuilder() + + for index, item in enumerate(items): + description = card.description or "" + lines = description.split("\n") + EMOJI_CHECKED = "✅" + EMOJI_UNCHECKED = "🔳" + + emoji = ( + EMOJI_CHECKED + if index < len(lines) and "[x]" in lines[index] + else EMOJI_UNCHECKED + ) + + display_item = item + if len(item) > MAX_ITEM_PREVIEW_LENGTH: + display_item = item[: MAX_ITEM_PREVIEW_LENGTH - ELLIPSIS_LENGTH] + "..." + + keyboard_builder.button( + text=f"{emoji} {display_item}", + callback_data=CardCallback( + action="toggle", card_id=card.id, item_index=index + ), + ) + + if items: + keyboard_builder.button( + text="🗑️ Удалить", + callback_data=CardCallback(action="remove", card_id=card.id), + ) + + keyboard_builder.button(text="⬅️ Назад", callback_data=CardCallback(action="back")) + keyboard_builder.adjust(1) + + card_text = await _generate_card_text(card.title, items) + + if hasattr(message, "edit_text"): + await message.edit_text( + card_text, reply_markup=keyboard_builder.as_markup(), parse_mode="HTML" + ) + else: + await message.answer( + card_text, reply_markup=keyboard_builder.as_markup(), parse_mode="HTML" + ) + + +async def _generate_card_text(title: str, items: list) -> str: + """ + Сгенерировать текст для отображения карточки + + :param title: Заголовок карточки + :type title: str + :param items: Список элементов + :type items: list + :return: Сформированный текст + :rtype: str + """ + if items: + items_text = f"Элементов: {len(items)}\n\n" + else: + items_text = "📝 Список пуст\n\n" + + return f"""{title} + +{items_text} +💡 Просто введите новые элементы сообщением: +• Один элемент +• Или несколько через запятую""" + + +@view_router.message() +async def handle_message_input(message: types.Message) -> None: + """ + Обработчик текстовых сообщений для автоматического добавления элементов + + :param message: Входящее сообщение + :type message: types.Message + """ + if message.text.startswith("/"): + return + + logger.info(f"Получено сообщение для добавления: {message.text}") + + # Получаем активную карточку для пользователя + user_id = message.from_user.id + active_card_id = _user_active_card.get(user_id) + + if active_card_id and active_card_id in _card_cache: + # Используем активную карточку пользователя + target_card = _card_cache[active_card_id] + await _add_items_to_card(message, target_card) + elif _card_cache: + # Fallback - берем первую карточку из кэша + target_card = next(iter(_card_cache.values())) + await _add_items_to_card(message, target_card) + logger.warning( + f"Активная карточка не найдена для пользователя {user_id}, использована первая из кэша" + ) + else: + # Fallback - загружаем карточки если кэш пуст + cards = await get_shopping_cards() + if not cards: + await message.answer("❌ Нет доступных карточек") + return + target_card = cards[0] + await _add_items_to_card(message, target_card) + logger.warning( + f"Кэш пуст для пользователя {user_id}, использована первая карточка" + ) + + +async def _parse_new_items(text: str) -> list: + """ + Парсит новые элементы из текста + + :param text: Текст с элементами для парсинга + :type text: str + :return: Список очищенных элементов + :rtype: list + """ + ITEM_SEPARATOR = "," + + new_items_text = text.strip() + if not new_items_text: + return [] + + if ITEM_SEPARATOR in new_items_text: + return [ + item.strip() + for item in new_items_text.split(ITEM_SEPARATOR) + if item.strip() + ] + return [new_items_text] + + +async def _add_items_to_card(message: types.Message, card) -> None: + """ + Добавить элементы в карточку + + :param message: Сообщение с элементами + :type message: types.Message + :param card: Карточка для обновления + """ + new_items = await _parse_new_items(message.text) + if not new_items: + await message.answer("❌ Не указаны элементы для добавления") + return + + logger.info(f"Парсинг элементов: {new_items}") + + # Получаем текущие элементы с их состояниями + current_items_with_states = card.get_list_items_with_states() + current_item_texts = [item["text"] for item in current_items_with_states] + + # Добавляем только новые элементы (исключаем дубликаты) + for new_item in new_items: + if new_item not in current_item_texts: + current_items_with_states.append({"text": new_item, "checked": False}) + + # Создаем обновленный список текстов элементов для обновления + updated_item_texts = [item["text"] for item in current_items_with_states] + + # Обновляем описание карточки + new_description = card.update_list_items(updated_item_texts) + + logger.info(f"Обновление карточки {card.id}: {len(updated_item_texts)} элементов") + + success = await update_card_description(card.id, new_description) + + if success: + await message.answer(f"✅ Добавлено {len(new_items)} элементов в '{card.title}'") + # Обновляем кэш + card.description = new_description + _card_cache[card.id] = card + await list_handler(message) + else: + await message.answer("❌ Ошибка при добавлении элементов") + + +async def get_cached_card(card_id: int): + """ + Получить карточку из кэша + + :param card_id: ID карточки + :type card_id: int + :return: Карточка из кэша или None + """ + return _card_cache.get(card_id) + + +def set_user_active_card(user_id: int, card_id: int) -> None: + """ + Установить активную карточку для пользователя + + :param user_id: ID пользователя + :type user_id: int + :param card_id: ID карточки + :type card_id: int + """ + _user_active_card[user_id] = card_id + + +def get_user_active_card(user_id: int) -> int: + """ + Получить активную карточку пользователя + + :param user_id: ID пользователя + :type user_id: int + :return: ID активной карточки или None + :rtype: int + """ + return _user_active_card.get(user_id) diff --git a/handlers/bot_routes/routes_deck.py b/handlers/bot_routes/routes_deck.py index 0d12103..c105b34 100644 --- a/handlers/bot_routes/routes_deck.py +++ b/handlers/bot_routes/routes_deck.py @@ -1,152 +1,18 @@ -"""Главный роутер для всех команд бота (базовые + deck)""" +"""Главный роутер для бота списка покупок""" -from aiogram import F, Router, types -from aiogram.filters import Command, CommandStart +from aiogram import Router -from handlers.bot_routes.route_deck_boards import ( - NavigationCallback, - show_boards_command, -) -from handlers.bot_routes.route_deck_cards import show_cards_in_stack -from handlers.bot_routes.route_deck_stacks import ( - back_to_boards_list, - show_stacks_for_board, -) -from handlers.handler_logging import logger +from handlers.bot_routes.route_list_cards import list_router +from handlers.bot_routes.route_view_card import view_router +from handlers.bot_routes.route_edit_items import edit_router nc_deck_router = Router() +ROUTERS = ( + list_router, + view_router, + edit_router, +) -# ========== БАЗОВЫЕ КОМАНДЫ ========== -@nc_deck_router.message(CommandStart()) -async def start_handler(message: types.Message) -> None: - """ - Обработчик команды /start - - :param message: Сообщение пользователя - :type message: types.Message - """ - await message.answer("🚀 Бот запущен! Используйте /help для списка команд") - logger.info(f"Пользователь {message.from_user.id} запустил бота") - - -@nc_deck_router.message(Command("help")) -async def help_handler(message: types.Message) -> None: - """ - Обработчик команды /help - - :param message: Сообщение от пользователя - :type message: types.Message - """ - help_text = """ -📋 Доступные команды: - -/start - Запуск бота -/help - Помощь -/test - Тестовая команда -/about - О боте -/decks - Показать доски - -Просто напишите "test" для проверки текстового хендлера - """ - await message.answer(help_text) - - -@nc_deck_router.message(Command("test")) -async def test_command_handler(message: types.Message) -> None: - """ - Обработчик команды /test - - :param message: Сообщение от пользователя - :type message: types.Message - - """ - await message.answer("✅ Тест пройден! Бот работает корректно!") - logger.info(f"Пользователь {message.from_user.id} выполнил тест-команду") - - -@nc_deck_router.message(Command("about")) -async def about_handler(message: types.Message) -> None: - """ - Обработчик команды /about - - :param message: Сообщение от пользователя - :type message: types.Message - - """ - await message.answer( - "🤖 Это тестовый бот с модульной архитектурой\n\n" - "⚡ Быстрое масштабирование\n" - "📁 Чистая структура проекта\n" - "🔧 Легкое обслуживание" - ) - - -@nc_deck_router.message(lambda message: message.text and message.text.lower() == "test") -async def test_text_handler(message: types.Message) -> None: - """ - Обработчик текстового сообщения 'test' - - :param message: Сообщение от пользователя - :type message: types.Message - """ - await message.answer("🔤 Вы написали 'test'! Текстовые хендлеры работают!") - logger.info(f"Пользователь {message.from_user.id} отправил текстовый тест") - - -# ========== DECK КОМАНДЫ ========== -@nc_deck_router.message(Command("decks")) -async def decks_handler(message: types.Message) -> None: - """ - Обработчик команды /decks - - :param message: Сообщение от пользователя - :type message: types.Message - """ - await show_boards_command(message) - - -@nc_deck_router.callback_query(NavigationCallback.filter(F.screen == "stacks_list")) -async def stacks_handler( - callback: types.CallbackQuery, callback_data: NavigationCallback -) -> None: - """ - Обработчик выбора доски - - :param callback: Callback от нажатой кнопки меню - :type callback: types.CallbackQuery - :param callback_data: Callback данные - :type callback_data: NavigationCallback - """ - await show_stacks_for_board(callback, callback_data) - - -@nc_deck_router.callback_query(NavigationCallback.filter(F.screen == "boards_list")) -async def boards_handler( - callback: types.CallbackQuery, callback_data: NavigationCallback -) -> None: - """ - Обработчик возврата к доскам - - :param callback: Callback от нажатой кнопки - :type callback: types.CallbackQuery - :param callback_data: Callback данные - :type callback_data: NavigationCallback - """ - del callback_data - await back_to_boards_list(callback) - - -@nc_deck_router.callback_query(NavigationCallback.filter(F.screen == "cards_list")) -async def cards_handler( - callback: types.CallbackQuery, callback_data: NavigationCallback -) -> None: - """ - Обработчик выбора стека - - :param callback: Callback от нажатой кнопки - :type callback: types.CallbackQuery - :param callback_data: Callback данные - :type callback_data: NavigationCallback - """ - await show_cards_in_stack(callback, callback_data) +for router in ROUTERS: + nc_deck_router.include_router(router) diff --git a/handlers/bot_routes/states.py b/handlers/bot_routes/states.py new file mode 100644 index 0000000..34b2a7a --- /dev/null +++ b/handlers/bot_routes/states.py @@ -0,0 +1,20 @@ +"""Состояния и callback данные для бота списка покупок""" + +from aiogram.filters.callback_data import CallbackData + + +class CardCallback(CallbackData, prefix="card"): + """ + Callback для навигации по карточкам + + :param action: Действие (view, back, toggle, delete) + :type action: str + :param card_id: ID карточки + :type card_id: int + :param item_index: Индекс элемента + :type item_index: int + """ + + action: str # 'view', 'back', 'toggle', 'delete' + card_id: int = 0 + item_index: int = -1 diff --git a/handlers/handler_bot.py b/handlers/handler_bot.py index ddb06a3..bfa823f 100644 --- a/handlers/handler_bot.py +++ b/handlers/handler_bot.py @@ -10,20 +10,13 @@ # Инициализация бота BOT_INSTANCE = Bot(token=settings.BOT_TOKEN) if settings.BOT_TOKEN else None dispatcher = Dispatcher() - -# Главный роутер dispatcher.include_router(nc_deck_router) -logger.info("Бот инициализирован") +logger.info("Бот списка покупок инициализирован") async def process_update(update_data: dict) -> None: - """ - Обработка обновления от Telegram - - :param update_data: Описание - :type update_data: dict - """ + """Обработка обновления от Telegram""" if not BOT_INSTANCE: logger.warning("Получено обновление, но бот не инициализирован") return @@ -33,12 +26,7 @@ async def process_update(update_data: dict) -> None: async def setup_webhook(webhook_url: str) -> None: - """ - Настройка вебхука - - :param webhook_url: Описание - :type webhook_url: str - """ + """Настройка вебхука""" if not BOT_INSTANCE: logger.warning("Попытка настроить вебхук без бота") return @@ -49,8 +37,6 @@ async def setup_webhook(webhook_url: str) -> None: async def close_bot_session() -> None: - """ - Закрытие сессии бота - """ + """Закрытие сессии бота""" if BOT_INSTANCE: await BOT_INSTANCE.session.close() diff --git a/handlers/handler_logging.py b/handlers/handler_logging.py index 16e09cd..e81b28a 100644 --- a/handlers/handler_logging.py +++ b/handlers/handler_logging.py @@ -3,16 +3,17 @@ import sys from loguru import logger +from config.settings import settings logger.remove() logger.add( sys.stderr, - format="{time:YYYY-MM-DD HH:mm:ss} | " + format="{time:DD-MM-YYYY HH:mm:ss} | " "{level} | " "{module}:{function}:{line} - " "{message}", colorize=True, - level="DEBUG", + level=settings.LOG_LEVEL.upper(), ) logger.level("DEBUG", color="") diff --git a/handlers/handler_nc_deck.py b/handlers/handler_nc_deck.py index fe673e6..9a1cae9 100644 --- a/handlers/handler_nc_deck.py +++ b/handlers/handler_nc_deck.py @@ -1,58 +1,91 @@ -"""Обработчик получения досок и данных в них""" +"""Обработчик для работы с карточками списка покупок""" from typing import List, Optional - -from pydantic import ValidationError - from config.settings import settings from handlers.handler_logging import logger from handlers.handler_requests import send_request -from models.model_board import ModelBoard -from models.model_stack import ModelStack +from models.model_card import ModelCard DECK_ENDPOINT = "/apps/deck/api/v1.0/boards" -async def get_boards() -> Optional[List[ModelBoard]]: +async def get_shopping_cards() -> Optional[List[ModelCard]]: """ - Получение всех Дэк + Получение карточек из стека списка покупок - :return: JSON со списком Дэк - :rtype: List[ModelBoard] | None + :return: Список карточек или None при ошибке + :rtype: Optional[List[ShoppingCard]] """ - boards_endpoint = f"{settings.NC_URL}{DECK_ENDPOINT}" - decklist = await send_request(url=boards_endpoint, method="GET") - if decklist is not None: + base_url = f"{settings.NC_URL}{DECK_ENDPOINT}" + stacks_endpoint = f"{base_url}/{settings.DECK_BOARD_ID}/stacks" + + stacks_data = await send_request(url=stacks_endpoint, method="GET") + + if not stacks_data: + logger.error("Не удалось получить данные стеков") + return None + + target_stack = next( + (stack for stack in stacks_data if stack.get("id") == settings.DECK_STACK_ID), + None, + ) + + if not target_stack: + logger.error(f"Стек {settings.DECK_STACK_ID} не найден") + return None + + cards_data = target_stack.get("cards", []) + cards = [] + + for card_data in cards_data: try: - decks = [ModelBoard(**deck) for deck in decklist] - return decks - except ValidationError as err: - logger.critical(f"Получены невалидные данные: {err}") - else: - logger.critical("Данные по доскам не получены") - return None + shopping_card = ModelCard( + id=card_data.get("id", 0), + title=card_data.get("title", ""), + description=card_data.get("description"), + stack_id=card_data.get("stackId", 0), + ) + cards.append(shopping_card) + except Exception as error: + logger.warning( + f"Ошибка преобразования карточки {card_data.get('id')}: {error}" + ) + continue + + logger.info(f"Успешно загружено {len(cards)} карточек") + return cards -async def get_stacks(board_id: int) -> Optional[List[ModelStack]]: +async def update_card_description(card_id: int, description: str) -> bool: """ - Получение стеков в доске + Обновление описания карточки - :param board_id: ID доски - :type board_id: int - :return: Список стеков в доске - :rtype: List[ModelStack] | None + :param card_id: ID карточки для обновления + :type card_id: int + :param description: Новое описание карточки + :type description: str + :return: True если обновление успешно, иначе False + :rtype: bool """ - if board_id is not None: - stacks_endpoint = f"{settings.NC_URL}{DECK_ENDPOINT}/{board_id}/stacks" - stacklist = await send_request(url=stacks_endpoint, method="GET") - if stacklist is not None: - try: - stacks = [ModelStack(**stack) for stack in stacklist] - return stacks - except ValidationError as err: - logger.critical(f"Получены невалидные данные: {err}") - else: - logger.critical("Данные по стекам не получены") - else: - logger.critical('"board_id" не задан') - return None + base_url = f"{settings.NC_URL}{DECK_ENDPOINT}" + card_endpoint = ( + f"{base_url}/{settings.DECK_BOARD_ID}/stacks/" + f"{settings.DECK_STACK_ID}/cards/{card_id}" + ) + + # Получаем текущие данные карточки для сохранения всех полей + card_data = await send_request(url=card_endpoint, method="GET") + if not card_data: + logger.error(f"Не удалось получить данные карточки {card_id}") + return False + + # Обновляем только описание, сохраняя остальные поля + update_data = {**card_data, "description": description} + result = await send_request(url=card_endpoint, method="PUT", data=update_data) + + if result: + logger.info(f"Карточка {card_id} успешно обновлена") + return True + + logger.error(f"Ошибка обновления карточки {card_id}") + return False diff --git a/handlers/handler_requests.py b/handlers/handler_requests.py index 0862be0..e1bd91c 100644 --- a/handlers/handler_requests.py +++ b/handlers/handler_requests.py @@ -1,45 +1,55 @@ """Обработчик отправки запросов к серверу NextCloud""" from typing import Any, Dict, Optional - -from httpx import AsyncClient, BasicAuth, RequestError +from httpx import AsyncClient, BasicAuth, HTTPStatusError, RequestError from config.settings import settings from handlers.handler_logging import logger +TIMEOUT = 20 + headers = { "OCS-APIRequest": "true", "accept": "application/json", + "Content-Type": "application/json", } auth = BasicAuth(username=settings.NC_LOGIN, password=settings.NC_PASSWORD) -async def send_request(url: str, method: str) -> Optional[Dict[Any, Any]]: +async def send_request( + url: str, method: str, data: Optional[Dict] = None +) -> Optional[Dict[Any, Any]]: """ Клиент для отправки запросов в API NextCloud :param url: Полный адрес вебхука куда отправляются данные :type url: str - :param method: Используемый метод (пока GET / POST) + :param method: Используемый метод (GET/POST/PUT) :type method: str + :param data: Данные для отправки (опционально) + :type data: Optional[Dict] :return: JSON ответа от сервера :rtype: Dict[Any, Any] | None """ if not url: - logger.critical("Не определен URL!") + logger.error("Не определен URL!") return None - async with AsyncClient(auth=auth, headers=headers) as client: + + async with AsyncClient(auth=auth, headers=headers, timeout=30.0) as client: try: - match method.upper(): + method_upper = method.upper() + match method_upper: case "GET": response = await client.get(url=url) case "POST": - response = await client.post(url=url) + response = await client.post(url=url, json=data) + case "PUT": + response = await client.put(url=url, json=data) case _: - logger.critical(f"Неподдерживаемый метод: {method}") + logger.error(f"Неподдерживаемый метод: {method}") return None response.raise_for_status() return response.json() - except RequestError as err: - logger.critical(f'Ошибка запроса к "{settings.NC_URL}": {err}') - return None + except (RequestError, HTTPStatusError) as error: + logger.error(f'Ошибка запроса к "{url}" : {error}') + return None diff --git a/main.py b/main.py index 2e24bd2..046db47 100644 --- a/main.py +++ b/main.py @@ -8,18 +8,22 @@ from handlers.handler_logging import logger from handlers.handler_server import server_app -if __name__ == "__main__": + +def main() -> None: + """ + Основная функция запуска приложения + """ logger.info(f"🚀 Сервер запускается (v.{settings.VERSION})") logger.info(f"📍 Хост: {settings.HOST}, Порт: {settings.PORT}") logger.info(f"🌐 WEBHOOK_HOST: {settings.WEBHOOK_HOST or 'не установлен'}") ssl_keyfile = settings.SSL_KEY_PATH ssl_certfile = settings.SSL_CERT_PATH - SSL_ENABLED = False + ssl_enabled = False if settings.SSL_ENABLED: if os.path.exists(ssl_keyfile) and os.path.exists(ssl_certfile): - SSL_ENABLED = True + ssl_enabled = True logger.success("🔐 SSL сертификаты найдены, запуск с HTTPS") else: logger.warning("⚠️ SSL сертификаты не найдены, запуск с HTTP") @@ -30,6 +34,10 @@ port=settings.PORT, log_level=settings.LOG_LEVEL, access_log=True, - ssl_keyfile=ssl_keyfile if SSL_ENABLED else None, - ssl_certfile=ssl_certfile if SSL_ENABLED else None, + ssl_keyfile=ssl_keyfile if ssl_enabled else None, + ssl_certfile=ssl_certfile if ssl_enabled else None, ) + + +if __name__ == "__main__": + main() diff --git a/models/model_board.py b/models/model_board.py deleted file mode 100644 index 1f64197..0000000 --- a/models/model_board.py +++ /dev/null @@ -1,62 +0,0 @@ -from pydantic import BaseModel -from typing import Dict, List, Optional, Union - -# TODO: Референсные данные - удалить после тестов -# "title": "Board title", -# "owner": { -# "primaryKey": "admin", -# "uid": "admin", -# "displayname": "Administrator" -# }, -# "color": "ff0000", -# "archived": false, -# "labels": [], -# "acl": [], -# "permissions": { -# "PERMISSION_READ": true, -# "PERMISSION_EDIT": true, -# "PERMISSION_MANAGE": true, -# "PERMISSION_SHARE": true -# }, -# "users": [], -# "shared": 0, -# "deletedAt": 0, -# "id": 10, -# "lastModified": 1586269585, -# "settings": { -# "notify-due": "off", -# "calendar": true - - -class Owner(BaseModel): - primaryKey: str - uid: str - displayname: str - - -class Permissions(BaseModel): - PERMISSION_READ: bool - PERMISSION_EDIT: bool - PERMISSION_MANAGE: bool - PERMISSION_SHARE: bool - - -class Settings(BaseModel): - notify_due: str - calendar: bool - - -class ModelBoard(BaseModel): - title: str - owner: Owner - color: str - archived: bool - labels: List[str] - acl: List[Dict] - permissions: Permissions - users: List[Dict] - shared: int - deletedAt: int - id: int - lastModified: int - settings: Optional[Union[Settings, List]] = None diff --git a/models/model_card.py b/models/model_card.py new file mode 100644 index 0000000..c86a77b --- /dev/null +++ b/models/model_card.py @@ -0,0 +1,172 @@ +"""Модели данных для карточек NextCloud Deck""" + +from typing import Optional, List +from pydantic import BaseModel + + +class ModelCard(BaseModel): + """ + Упрощенная модель карточки для списка покупок + + :param id: Уникальный идентификатор карточки + :type id: int + :param title: Заголовок карточки + :type title: str + :param description: Описание карточки (список покупок) + :type description: Optional[str] + :param stack_id: ID стека в котором находится карточка + :type stack_id: int + """ + + id: int + title: str + description: Optional[str] = None + stack_id: int + + @property + def short_title(self) -> str: + """Сокращенный заголовок для кнопок""" + MAX_TITLE_LENGTH = 30 + ELLIPSIS_LENGTH = 3 + + if len(self.title) > MAX_TITLE_LENGTH: + return self.title[: MAX_TITLE_LENGTH - ELLIPSIS_LENGTH] + "..." + return self.title + + def get_list_items(self) -> List[str]: + """ + Получить элементы списка из описания + + :return: Список очищенных элементов + :rtype: List[str] + """ + if not self.description: + return [] + + items = [] + lines = self.description.split("\n") + + for line in lines: + line = line.strip() + if not line: + continue + + # Очищаем от маркеров списка + clean_line = self._clean_list_item(line) + if clean_line: + items.append(clean_line) + + return items + + def _clean_list_item(self, line: str) -> Optional[str]: + """Очистить элемент списка от маркеров""" + MARKER_CHECKBOX_UNCHECKED = "- [ ] " + MARKER_CHECKBOX_CHECKED = "- [x] " + MARKER_DASH = "- " + MARKER_ASTERISK = "* " + + if line.startswith(MARKER_CHECKBOX_UNCHECKED) or line.startswith( + MARKER_CHECKBOX_CHECKED + ): + return line[6:].strip() # Убираем "- [ ] " или "- [x] " + elif line.startswith(MARKER_DASH) or line.startswith(MARKER_ASTERISK): + return line[2:].strip() # Убираем "- " или "* " + elif line[0].isdigit() and ". " in line: + return line.split(". ", 1)[1].strip() # Убираем "1. " + + return line.strip() if line.strip() else None + + def update_list_items(self, items: List[str]) -> str: + """ + Обновить элементы списка в формате задач, сохраняя состояние выполненных элементов + + :param items: Список элементов для добавления + :type items: List[str] + :return: Отформатированное описание карточки + :rtype: str + """ + if not items: + return "" + + # Создаем словарь для отслеживания состояния элементов + item_states = {} + + # Парсим текущее описание, чтобы сохранить состояния + if self.description: + lines = self.description.split("\n") + for line in lines: + line = line.strip() + if not line: + continue + + # Определяем состояние элемента + if line.startswith("- [x] "): + item_text = line[6:].strip() + item_states[item_text] = "checked" + elif line.startswith("- [ ] "): + item_text = line[6:].strip() + item_states[item_text] = "unchecked" + elif line.startswith("- "): + item_text = line[2:].strip() + item_states[item_text] = "unchecked" + elif line.startswith("* "): + item_text = line[2:].strip() + item_states[item_text] = "unchecked" + elif line[0].isdigit() and ". " in line: + item_text = line.split(". ", 1)[1].strip() + item_states[item_text] = "unchecked" + else: + item_text = line.strip() + item_states[item_text] = "unchecked" + + # Создаем новые строки с сохранением состояний + list_lines = [] + for item in items: + # Используем сохраненное состояние или создаем новый unchecked элемент + state = item_states.get(item, "unchecked") + if state == "checked": + list_lines.append(f"- [x] {item}") + else: + list_lines.append(f"- [ ] {item}") + + return "\n".join(list_lines) + + def get_list_items_with_states(self) -> List[dict]: + """ + Получить элементы списка с их состояниями + + :return: Список словарей с элементами и их состояниями + :rtype: List[dict] + """ + if not self.description: + return [] + + items = [] + lines = self.description.split("\n") + + for line in lines: + line = line.strip() + if not line: + continue + + # Определяем состояние элемента + if line.startswith("- [x] "): + item_text = line[6:].strip() + items.append({"text": item_text, "checked": True}) + elif line.startswith("- [ ] "): + item_text = line[6:].strip() + items.append({"text": item_text, "checked": False}) + elif line.startswith("- "): + item_text = line[2:].strip() + items.append({"text": item_text, "checked": False}) + elif line.startswith("* "): + item_text = line[2:].strip() + items.append({"text": item_text, "checked": False}) + elif line[0].isdigit() and ". " in line: + item_text = line.split(". ", 1)[1].strip() + items.append({"text": item_text, "checked": False}) + else: + item_text = line.strip() + items.append({"text": item_text, "checked": False}) + + return items diff --git a/models/model_stack.py b/models/model_stack.py deleted file mode 100644 index 8296882..0000000 --- a/models/model_stack.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import List, Optional - -from pydantic import BaseModel - -# "title": "ToDo", -# "boardId": 2, -# "deletedAt": 0, -# "lastModified": 1541426139, -# "cards": [...], -# "order": 999, -# "id": 4 - - -class ModelStack(BaseModel): - title: str - boardId: int - deletedAt: int - lastModified: int - cards: Optional[List] = None - order: int - id: int diff --git a/routes/routes_base.py b/routes/routes_base.py index 18f6dfb..d0ea323 100644 --- a/routes/routes_base.py +++ b/routes/routes_base.py @@ -1,3 +1,5 @@ +"""Базовые API эндпоинты приложения""" + from fastapi import APIRouter # Создаем роутер для базовых API эндпоинтов diff --git a/routes/routes_webhook.py b/routes/routes_webhook.py index 0b8de28..6132853 100644 --- a/routes/routes_webhook.py +++ b/routes/routes_webhook.py @@ -1,11 +1,11 @@ +"""Роуты для обработки вебхуков Telegram""" + from fastapi import APIRouter, Request -from handlers.handler_logging import logger + from handlers.handler_bot import process_update +from handlers.handler_logging import logger -# Создаем роутер для вебхука webhook_router = APIRouter(tags=["webhook"]) - -# Простой путь без токена WEBHOOK_PATH = "/webhook" @@ -19,13 +19,3 @@ async def telegram_webhook(update: Request): except Exception as e: logger.error(f"Ошибка обработки вебхука: {e}") return {"ok": False, "error": str(e)} - - -@webhook_router.get(WEBHOOK_PATH) -async def webhook_debug(): - """Отладочная ручка для проверки вебхука""" - return { - "message": "Webhook endpoint is active", - "path": WEBHOOK_PATH, - "method": "Use POST for Telegram webhooks", - }