From 747964bee66de0faaf6ad05ddbad831fae00b3ae Mon Sep 17 00:00:00 2001 From: intervisionlord Date: Wed, 29 Oct 2025 09:08:08 +0300 Subject: [PATCH 1/7] =?UTF-8?q?docs(APP):=20=D0=94=D0=BE=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D1=88=D0=B8=D1=85=D1=81=D1=8F=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #15 --- main.py | 18 +++++++++++++----- models/model_board.py | 37 +++++++++++-------------------------- models/model_stack.py | 12 ++++-------- routes/routes_base.py | 2 ++ routes/routes_webhook.py | 18 ++++-------------- 5 files changed, 34 insertions(+), 53 deletions(-) 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 index 1f64197..8a13bab 100644 --- a/models/model_board.py +++ b/models/model_board.py @@ -1,40 +1,21 @@ -from pydantic import BaseModel +"""Модели данных для досок NextCloud Deck""" + 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 +from pydantic import BaseModel class Owner(BaseModel): + """Модель владельца доски""" + primaryKey: str uid: str displayname: str class Permissions(BaseModel): + """Модель разрешений доски""" + PERMISSION_READ: bool PERMISSION_EDIT: bool PERMISSION_MANAGE: bool @@ -42,11 +23,15 @@ class Permissions(BaseModel): class Settings(BaseModel): + """Модель настроек доски""" + notify_due: str calendar: bool class ModelBoard(BaseModel): + """Основная модель доски""" + title: str owner: Owner color: str diff --git a/models/model_stack.py b/models/model_stack.py index 8296882..d02438c 100644 --- a/models/model_stack.py +++ b/models/model_stack.py @@ -1,17 +1,13 @@ +"""Модели данных для стеков NextCloud Deck""" + 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 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", - } From 720bca053b6e5983e1eb5b56e10efcf724028d20 Mon Sep 17 00:00:00 2001 From: intervisionlord Date: Wed, 29 Oct 2025 09:12:08 +0300 Subject: [PATCH 2/7] =?UTF-8?q?docs(APP):=20=D0=98=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE=D0=BA=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BD=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #15 --- handlers/handler_bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/handlers/handler_bot.py b/handlers/handler_bot.py index ddb06a3..d44cbc9 100644 --- a/handlers/handler_bot.py +++ b/handlers/handler_bot.py @@ -21,7 +21,7 @@ async def process_update(update_data: dict) -> None: """ Обработка обновления от Telegram - :param update_data: Описание + :param update_data: Полученное обновление от Telegram :type update_data: dict """ if not BOT_INSTANCE: @@ -36,7 +36,7 @@ async def setup_webhook(webhook_url: str) -> None: """ Настройка вебхука - :param webhook_url: Описание + :param webhook_url: Адрес вебхука бота :type webhook_url: str """ if not BOT_INSTANCE: From c034cb492a80c12e2fae0749be714edfb5cad8bb Mon Sep 17 00:00:00 2001 From: intervisionlord Date: Wed, 29 Oct 2025 09:12:41 +0300 Subject: [PATCH 3/7] =?UTF-8?q?docs(maintenance):=20=D0=94=D0=BE=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D1=80=D0=B8=D0=BD=D0=B3=D0=BE=D0=B2=20=D0=B2?= =?UTF-8?q?=20=D1=81=D0=BB=D1=83=D0=B6=D0=B5=D0=B1=D0=BD=D0=BE=D0=BC=20?= =?UTF-8?q?=D1=81=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .scripts/project_tree.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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="Отображение древовидной структуры проекта" ) From 7d7982ba3449aa8f54186a3e5ae8429f9aa27d4c Mon Sep 17 00:00:00 2001 From: intervisionlord Date: Thu, 30 Oct 2025 00:30:30 +0300 Subject: [PATCH 4/7] =?UTF-8?q?feat(Bot):=20=D0=9F=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D0=BC=D0=BE=D1=82=D1=80=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?.=20=D0=92=D1=8B=D0=B1=D0=BE=D1=80=D0=BA=D0=B0=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B8=D0=B7=20=D1=83=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #12 --- .env.example | 8 ++++++-- config/settings.py | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) 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/config/settings.py b/config/settings.py index 81df07d..08cc567 100644 --- a/config/settings.py +++ b/config/settings.py @@ -9,7 +9,7 @@ class Settings(BaseSettings): """Конфигурация приложения""" - VERSION: str = Field("0.0.2.2") + VERSION: str = Field("0.0.3.0") # Токен бота Telegram BOT_TOKEN: Optional[str] = Field(None, description="Токен бота от @BotFather") @@ -26,11 +26,15 @@ class Settings(BaseSettings): # Настройки вебхука (опционально для продакшена) WEBHOOK_HOST: Optional[str] = Field(None, description="Публичный URL для вебхука") - # Настройки 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 = Field(1, description="ID доски для списка покупок") + DECK_STACK_ID: int = Field(1, description="ID стека для списка покупок") + # Настройки логирования LOG_LEVEL: str = Field("INFO", description="Уровень логирования") From ec02e2a948e55a0ef88bd027cdacbb5255a4f320 Mon Sep 17 00:00:00 2001 From: intervisionlord Date: Thu, 30 Oct 2025 00:43:58 +0300 Subject: [PATCH 5/7] =?UTF-8?q?refactor(Bot):=20=D0=93=D0=BB=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B5=20=D0=B8=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Выборка осуществляется только по указанной доске и стеку - Отображение деталей карточки - Добавление покупок в карточку - Удаление покупок из карточки - Отметка о выполненной покупке - Разбиение логики на отдельные суброуты - Упрощение моделей и отказ от лишних моделей - Отказ от лишних хендлеров #12 #13 #9 --- handlers/bot_routes/route_common.py | 67 ++++++++++ handlers/bot_routes/route_deck_boards.py | 51 -------- handlers/bot_routes/route_deck_cards.py | 32 ----- handlers/bot_routes/route_deck_stacks.py | 73 ----------- handlers/bot_routes/route_edit_items.py | 122 ++++++++++++++++++ handlers/bot_routes/route_list_cards.py | 67 ++++++++++ handlers/bot_routes/route_states.py | 11 ++ handlers/bot_routes/route_utils.py | 13 ++ handlers/bot_routes/route_view_card.py | 94 ++++++++++++++ handlers/bot_routes/routes_deck.py | 157 ++--------------------- handlers/handler_bot.py | 8 +- handlers/handler_nc_deck.py | 131 ++++++++++++------- handlers/handler_requests.py | 63 +++++---- models/model_board.py | 47 ------- models/model_card.py | 62 +++++++++ models/model_stack.py | 17 --- 16 files changed, 567 insertions(+), 448 deletions(-) create mode 100644 handlers/bot_routes/route_common.py delete mode 100644 handlers/bot_routes/route_deck_boards.py delete mode 100644 handlers/bot_routes/route_deck_cards.py delete mode 100644 handlers/bot_routes/route_deck_stacks.py create mode 100644 handlers/bot_routes/route_edit_items.py create mode 100644 handlers/bot_routes/route_list_cards.py create mode 100644 handlers/bot_routes/route_states.py create mode 100644 handlers/bot_routes/route_utils.py create mode 100644 handlers/bot_routes/route_view_card.py delete mode 100644 models/model_board.py create mode 100644 models/model_card.py delete mode 100644 models/model_stack.py diff --git a/handlers/bot_routes/route_common.py b/handlers/bot_routes/route_common.py new file mode 100644 index 0000000..d1ed7eb --- /dev/null +++ b/handlers/bot_routes/route_common.py @@ -0,0 +1,67 @@ +"""Общие функции для роутов бота""" + +from aiogram import types +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.route_states import CardCallback + + +async def get_card_by_id(card_id: int): + """Получить карточку по ID (с загрузкой карточек)""" + cards = await get_shopping_cards() + if not cards: + return None + return next((c for c in cards if c.id == card_id), None) + + +def create_card_keyboard(card, items: list) -> InlineKeyboardBuilder: + """Создает клавиатуру для карточки""" + keyboard_builder = InlineKeyboardBuilder() + + # Элементы списка как инлайн кнопки + for index, item in enumerate(items): + description = card.description or "" + lines = description.split("\n") + emoji = "✅" if index < len(lines) and "[x]" in lines[index] else "⭕" + + keyboard_builder.button( + text=f"{emoji} {item}", + callback_data=CardCallback( + action="toggle", card_id=card.id, item_index=index + ), + ) + + 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) + return keyboard_builder + + +async def show_card_view(message: types.Message, card_id: int) -> None: + """Показать представление карточки""" + card = await get_card_by_id(card_id) + if not card: + await message.answer("❌ Карточка не найдена") + return + + items = card.get_list_items() + keyboard_builder = create_card_keyboard(card, items) + + card_text = f"{card.title}\n\n" + if items: + card_text += f"Элементов: {len(items)}\n\n" + else: + card_text += "Список пуст\n\n" + + card_text += "💡 Просто введите новые элементы сообщением:\n" + card_text += "• Один элемент\n" + card_text += "• Или несколько через запятую" + + await message.answer( + card_text, reply_markup=keyboard_builder.as_markup(), parse_mode="HTML" + ) 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..55f28ec --- /dev/null +++ b/handlers/bot_routes/route_edit_items.py @@ -0,0 +1,122 @@ +"""Логика редактирования элементов карточки""" + +from aiogram import Router, types, F +from aiogram.utils.keyboard import InlineKeyboardBuilder + +# from handlers.handler_logging import logger +from handlers.handler_nc_deck import get_shopping_cards, update_card_description +from handlers.bot_routes.route_states import CardCallback +from handlers.bot_routes.route_view_card import view_card_handler + +edit_router = Router() + + +@edit_router.callback_query(CardCallback.filter(F.action == "toggle")) +async def toggle_item_handler( + callback: types.CallbackQuery, callback_data: CardCallback +) -> None: + """Переключить статус элемента списка""" + cards = await get_shopping_cards() + if not cards: + await callback.answer("❌ Карточка не найдена") + return + + card = next((c for c in cards if c.id == callback_data.card_id), None) + if not card or not card.description: + await callback.answer("❌ Карточка не найдена") + return + + lines = 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]", "[ ]") + + success = await update_card_description(card.id, "\n".join(lines)) + if success: + await callback.answer("✅ Статус обновлен") + await view_card_handler(callback, callback_data) + 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: + """Начать удаление элементов""" + cards = await get_shopping_cards() + if not cards: + await callback.answer("❌ Карточка не найдена") + return + + card = next((c for c in cards if c.id == callback_data.card_id), None) + if not card: + await callback.answer("❌ Карточка не найдена") + return + + items = card.get_list_items() + if not items: + await callback.answer("❌ Список пуст") + return + + keyboard_builder = InlineKeyboardBuilder() + for index, item in enumerate(items): + keyboard_builder.button( + text=f"🗑️ {item}", + callback_data=CardCallback( + action="delete", card_id=card.id, item_index=index + ), + ) + + keyboard_builder.button( + text="⬅️ Назад", callback_data=CardCallback(action="view", card_id=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: + """Удалить элемент из списка""" + cards = await get_shopping_cards() + if not cards: + await callback.answer("❌ Карточка не найдена") + return + + card = next((c for c in cards if c.id == callback_data.card_id), None) + if not card or not card.description: + await callback.answer("❌ Карточка не найдена") + return + + lines = card.description.split("\n") + if 0 <= callback_data.item_index < len(lines): + lines.pop(callback_data.item_index) + success = await update_card_description(card.id, "\n".join(lines)) + + if success: + await callback.answer("✅ Элемент удален") + # Перезагружаем карточки для актуальных данных + updated_cards = await get_shopping_cards() + updated_card = next((c for c in updated_cards if c.id == card.id), None) + if updated_card: + await remove_items_handler(callback, callback_data) + else: + await callback.answer("❌ Ошибка обновления данных") + 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..76b4096 --- /dev/null +++ b/handlers/bot_routes/route_list_cards.py @@ -0,0 +1,67 @@ +"""Логика отображения списка карточек""" + +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.route_states import CardCallback + +list_router = Router() + + +@list_router.message(CommandStart()) +@list_router.message(Command("help")) +async def start_help_handler(message: types.Message) -> None: + """Обработчик команд /start и /help""" + help_text = """ +🛒 Бот для списка покупок + +Команды: +/list - Показать список покупок +/help - Эта справка + +💡 Просто введите элементы покупок сообщением, чтобы добавить их в список! +""" + await message.answer(help_text) + + +@list_router.message(Command("list")) +async def list_handler(message: types.Message) -> None: + """Показать список покупок в виде инлайн кнопок""" + logger.info(f"Пользователь {message.from_user.id} запросил список покупок") + + # Всегда загружаем свежие данные + 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: + """Вернуться к списку карточек""" + del callback_data + await callback.answer() + await list_handler(callback.message) diff --git a/handlers/bot_routes/route_states.py b/handlers/bot_routes/route_states.py new file mode 100644 index 0000000..326cf26 --- /dev/null +++ b/handlers/bot_routes/route_states.py @@ -0,0 +1,11 @@ +"""Состояния и callback данные для бота списка покупок""" + +from aiogram.filters.callback_data import CallbackData + + +class CardCallback(CallbackData, prefix="card"): + """Callback для навигации по карточкам""" + + action: str # 'view', 'back', 'toggle', 'delete' + card_id: int = 0 + item_index: int = -1 diff --git a/handlers/bot_routes/route_utils.py b/handlers/bot_routes/route_utils.py new file mode 100644 index 0000000..c3d44e3 --- /dev/null +++ b/handlers/bot_routes/route_utils.py @@ -0,0 +1,13 @@ +"""Вспомогательные функции для роутов бота""" + + +def parse_new_items(text: str) -> list: + """Парсит новые элементы из текста""" + new_items_text = text.strip() + if not new_items_text: + return [] + + if "," in new_items_text: + return [item.strip() for item in new_items_text.split(",") if item.strip()] + else: + return [new_items_text] diff --git a/handlers/bot_routes/route_view_card.py b/handlers/bot_routes/route_view_card.py new file mode 100644 index 0000000..2f8f158 --- /dev/null +++ b/handlers/bot_routes/route_view_card.py @@ -0,0 +1,94 @@ +"""Логика просмотра и управления карточкой""" + +from aiogram import Router, types, F + +# from handlers.handler_logging import logger +from handlers.handler_nc_deck import get_shopping_cards, update_card_description +from handlers.bot_routes.route_utils import parse_new_items +from handlers.bot_routes.route_states import CardCallback +from handlers.bot_routes.route_list_cards import list_handler +from handlers.bot_routes.route_common import create_card_keyboard + +view_router = Router() + + +@view_router.callback_query(CardCallback.filter(F.action == "view")) +async def view_card_handler( + callback: types.CallbackQuery, callback_data: CardCallback +) -> None: + """Показать детали карточки с элементами списка""" + cards = await get_shopping_cards() + if not cards: + await callback.message.edit_text("❌ Карточка не найдена") + return + + card = next((c for c in cards if c.id == callback_data.card_id), None) + if not card: + await callback.message.edit_text("❌ Карточка не найдена") + return + + items = card.get_list_items() + keyboard_builder = create_card_keyboard(card, items) + + card_text = f"{card.title}\n\n" + if items: + card_text += f"Элементов: {len(items)}\n\n" + else: + card_text += "Список пуст\n\n" + + card_text += "💡 Просто введите новые элементы сообщением:\n" + card_text += "• Один элемент\n" + card_text += "• Или несколько через запятую" + + await callback.message.edit_text( + card_text, reply_markup=keyboard_builder.as_markup(), parse_mode="HTML" + ) + await callback.answer() + + +@view_router.message() +async def handle_message_input(message: types.Message) -> None: + """Обработчик текстовых сообщений для автоматического добавления элементов""" + # Проверяем, не является ли сообщение командой + if message.text.startswith("/"): + return + + # Получаем все карточки один раз + cards = await get_shopping_cards() + if not cards: + await message.answer("❌ Нет доступных карточек") + return + + # Если есть только одна карточка, добавляем в нее + if len(cards) == 1: + card = cards[0] + await _add_items_to_card(message, card) + return + + # Если карточек несколько, используем первую как fallback + card = cards[0] + await _add_items_to_card(message, card) + + +async def _add_items_to_card(message: types.Message, card) -> None: + """Добавить элементы в карточку""" + # Парсим новые элементы + new_items = parse_new_items(message.text) + if not new_items: + await message.answer("❌ Не указаны элементы для добавления") + return + + # Добавляем новые элементы + current_items = card.get_list_items() + updated_items = current_items + new_items + + # Обновляем описание карточки + new_description = card.update_list_items(updated_items) + success = await update_card_description(card.id, new_description) + + if success: + await message.answer(f"✅ Добавлено {len(new_items)} элементов в '{card.title}'") + # Просто вызываем list_handler который покажет актуальный список + await list_handler(message) + else: + await message.answer("❌ Ошибка при добавлении элементов") diff --git a/handlers/bot_routes/routes_deck.py b/handlers/bot_routes/routes_deck.py index 0d12103..a7cee54 100644 --- a/handlers/bot_routes/routes_deck.py +++ b/handlers/bot_routes/routes_deck.py @@ -1,152 +1,13 @@ -"""Главный роутер для всех команд бота (базовые + 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() - - -# ========== БАЗОВЫЕ КОМАНДЫ ========== -@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) +nc_deck_router.include_router(list_router) +nc_deck_router.include_router(view_router) +nc_deck_router.include_router(edit_router) diff --git a/handlers/handler_bot.py b/handlers/handler_bot.py index d44cbc9..d6c1bc6 100644 --- a/handlers/handler_bot.py +++ b/handlers/handler_bot.py @@ -14,15 +14,12 @@ # Главный роутер dispatcher.include_router(nc_deck_router) -logger.info("Бот инициализирован") +logger.info("Бот списка покупок инициализирован") async def process_update(update_data: dict) -> None: """ Обработка обновления от Telegram - - :param update_data: Полученное обновление от Telegram - :type update_data: dict """ if not BOT_INSTANCE: logger.warning("Получено обновление, но бот не инициализирован") @@ -35,9 +32,6 @@ 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("Попытка настроить вебхук без бота") diff --git a/handlers/handler_nc_deck.py b/handlers/handler_nc_deck.py index fe673e6..07d5d14 100644 --- a/handlers/handler_nc_deck.py +++ b/handlers/handler_nc_deck.py @@ -1,58 +1,95 @@ -"""Обработчик получения досок и данных в них""" +"""Обработчик для работы с карточками списка покупок""" 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 ShoppingCard DECK_ENDPOINT = "/apps/deck/api/v1.0/boards" -async def get_boards() -> Optional[List[ModelBoard]]: - """ - Получение всех Дэк - - :return: JSON со списком Дэк - :rtype: List[ModelBoard] | None - """ - boards_endpoint = f"{settings.NC_URL}{DECK_ENDPOINT}" - decklist = await send_request(url=boards_endpoint, method="GET") - if decklist is not None: - try: - decks = [ModelBoard(**deck) for deck in decklist] - return decks - except ValidationError as err: - logger.critical(f"Получены невалидные данные: {err}") - else: - logger.critical("Данные по доскам не получены") - return None - - -async def get_stacks(board_id: int) -> Optional[List[ModelStack]]: - """ - Получение стеков в доске - - :param board_id: ID доски - :type board_id: int - :return: Список стеков в доске - :rtype: List[ModelStack] | None - """ - 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: +async def get_shopping_cards() -> Optional[List[ShoppingCard]]: + """Получение карточек из стека списка покупок""" + try: + stacks_endpoint = ( + f"{settings.NC_URL}{DECK_ENDPOINT}/{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", []) + if not cards_data: + logger.info("В стеке нет карточек") + return [] + + cards = [] + for card_data in cards_data: try: - stacks = [ModelStack(**stack) for stack in stacklist] - return stacks - except ValidationError as err: - logger.critical(f"Получены невалидные данные: {err}") + card = ShoppingCard( + 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(card) + except Exception as e: + logger.warning( + f"Ошибка преобразования карточки {card_data.get('id')}: {e}" + ) + continue + + logger.info(f"Успешно загружено {len(cards)} карточек") + return cards + + except Exception as e: + logger.error(f"Ошибка получения карточек: {e}") + return None + + +async def update_card_description(card_id: int, description: str) -> bool: + """Основная функция обновления карточки""" + try: + card_endpoint = ( + f"{settings.NC_URL}{DECK_ENDPOINT}/" + f"{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 else: - logger.critical("Данные по стекам не получены") - else: - logger.critical('"board_id" не задан') - return None + logger.error(f"Ошибка обновления карточки {card_id}") + return False + + except Exception as e: + logger.error(f"Ошибка при обновлении карточки {card_id}: {e}") + return False diff --git a/handlers/handler_requests.py b/handlers/handler_requests.py index 0862be0..fd4c729 100644 --- a/handlers/handler_requests.py +++ b/handlers/handler_requests.py @@ -1,8 +1,8 @@ """Обработчик отправки запросов к серверу NextCloud""" from typing import Any, Dict, Optional - -from httpx import AsyncClient, BasicAuth, RequestError +import json +from httpx import AsyncClient, BasicAuth, HTTPStatusError, RequestError from config.settings import settings from handlers.handler_logging import logger @@ -10,36 +10,47 @@ 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]]: - """ - Клиент для отправки запросов в API NextCloud - - :param url: Полный адрес вебхука куда отправляются данные - :type url: str - :param method: Используемый метод (пока GET / POST) - :type method: str - :return: JSON ответа от сервера - :rtype: Dict[Any, Any] | None - """ +async def send_request( + url: str, method: str, data: Optional[Dict] = None +) -> Optional[Dict[Any, Any]]: + """Клиент для отправки запросов в API NextCloud""" 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(): - case "GET": - response = await client.get(url=url) - case "POST": - response = await client.post(url=url) - case _: - logger.critical(f"Неподдерживаемый метод: {method}") - return None + method_upper = method.upper() + if method_upper == "GET": + response = await client.get(url=url) + elif method_upper == "POST": + response = await client.post(url=url, json=data) + elif method_upper == "PUT": + response = await client.put(url=url, json=data) + elif method_upper == "DELETE": + response = await client.delete(url=url) + else: + logger.error(f"Неподдерживаемый метод: {method}") + return None + response.raise_for_status() - return response.json() + + # Пытаемся прочитать ответ как JSON + try: + return response.json() + except json.JSONDecodeError as e: + logger.error(f"Ошибка декодирования JSON от {url}: {e}") + logger.error(f"Response text: {response.text}") + return None + except HTTPStatusError as e: + logger.error(f"Неожиданная ошибка при обработке ответа от {url}: {e}") + return None + except RequestError as err: - logger.critical(f'Ошибка запроса к "{settings.NC_URL}": {err}') - return None + logger.error(f'Ошибка запроса к "{url}": {err}') + return None diff --git a/models/model_board.py b/models/model_board.py deleted file mode 100644 index 8a13bab..0000000 --- a/models/model_board.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Модели данных для досок NextCloud Deck""" - -from typing import Dict, List, Optional, Union - -from pydantic import BaseModel - - -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..d245b86 --- /dev/null +++ b/models/model_card.py @@ -0,0 +1,62 @@ +"""Модели данных для карточек NextCloud Deck""" + +from typing import Optional, List +from pydantic import BaseModel + + +class ShoppingCard(BaseModel): + """Упрощенная модель карточки для списка покупок""" + + id: int + title: str + description: Optional[str] = None + stack_id: int + + @property + def short_title(self) -> str: + """Сокращенный заголовок для кнопок""" + if len(self.title) > 30: + return self.title[:27] + "..." + return self.title + + def get_list_items(self) -> List[str]: + """Получить элементы списка из описания""" + if not self.description: + return [] + + items = [] + lines = self.description.split("\n") + for line in lines: + line = line.strip() + if line and ( + line.startswith("- ") + or line.startswith("* ") + or line.startswith("- [ ] ") + or line.startswith("- [x] ") + or line[0].isdigit() + and ". " in line + ): + # Очищаем от маркеров списка + clean_line = line + if line.startswith("- [ ] ") or line.startswith("- [x] "): + clean_line = line[6:] # Убираем "- [ ] " или "- [x] " + elif line.startswith("- ") or line.startswith("* "): + clean_line = line[2:] # Убираем "- " или "* " + elif ". " in line and line[0].isdigit(): + clean_line = line.split(". ", 1)[1] # Убираем "1. " + + items.append(clean_line.strip()) + + return items + + def update_list_items(self, items: List[str]) -> str: + """Обновить элементы списка в формате задач""" + if not items: + return "" + + list_lines = [] + for item in items: + # Форматируем как задачи + list_lines.append(f"- [ ] {item}") # noqa: E201 + + return "\n".join(list_lines) diff --git a/models/model_stack.py b/models/model_stack.py deleted file mode 100644 index d02438c..0000000 --- a/models/model_stack.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Модели данных для стеков NextCloud Deck""" - -from typing import List, Optional - -from pydantic import BaseModel - - -class ModelStack(BaseModel): - """Модель стека карточек""" - - title: str - boardId: int - deletedAt: int - lastModified: int - cards: Optional[List] = None - order: int - id: int From f0e07e722af84a56dc679a0ab130c09a4f2254af Mon Sep 17 00:00:00 2001 From: intervisionlord Date: Thu, 30 Oct 2025 09:17:32 +0300 Subject: [PATCH 6/7] =?UTF-8?q?refactor(Bot):=20=D0=93=D0=BB=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20(=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D0=B0=D1=86=D0=B8=D1=8F=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Оптимизация кода, чистка, упрощение - Убран flake --- .pre-commit-config.yaml | 6 - config/settings.py | 25 ++- handlers/bot_routes/route_common.py | 67 -------- handlers/bot_routes/route_edit_items.py | 111 ++++++++----- handlers/bot_routes/route_list_cards.py | 48 ++++-- handlers/bot_routes/route_states.py | 11 -- handlers/bot_routes/route_utils.py | 13 -- handlers/bot_routes/route_view_card.py | 211 +++++++++++++++++++----- handlers/bot_routes/routes_deck.py | 15 +- handlers/bot_routes/states.py | 20 +++ handlers/handler_bot.py | 14 +- handlers/handler_logging.py | 5 +- handlers/handler_nc_deck.py | 156 +++++++++--------- handlers/handler_requests.py | 57 ++++--- models/model_card.py | 87 +++++++--- 15 files changed, 489 insertions(+), 357 deletions(-) delete mode 100644 handlers/bot_routes/route_common.py delete mode 100644 handlers/bot_routes/route_states.py delete mode 100644 handlers/bot_routes/route_utils.py create mode 100644 handlers/bot_routes/states.py 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/config/settings.py b/config/settings.py index 08cc567..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,22 +8,22 @@ class Settings(BaseSettings): """Конфигурация приложения""" - VERSION: str = Field("0.0.3.0") + 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 Deck NC_URL: Optional[str] = Field(None, description="Адрес Nextcloud") @@ -32,13 +31,13 @@ class Settings(BaseSettings): NC_PASSWORD: Optional[str] = Field(None, description="Пароль Nextcloud") # ID доски и стека для списка покупок - DECK_BOARD_ID: int = Field(1, description="ID доски для списка покупок") - DECK_STACK_ID: int = Field(1, description="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_common.py b/handlers/bot_routes/route_common.py deleted file mode 100644 index d1ed7eb..0000000 --- a/handlers/bot_routes/route_common.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Общие функции для роутов бота""" - -from aiogram import types -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.route_states import CardCallback - - -async def get_card_by_id(card_id: int): - """Получить карточку по ID (с загрузкой карточек)""" - cards = await get_shopping_cards() - if not cards: - return None - return next((c for c in cards if c.id == card_id), None) - - -def create_card_keyboard(card, items: list) -> InlineKeyboardBuilder: - """Создает клавиатуру для карточки""" - keyboard_builder = InlineKeyboardBuilder() - - # Элементы списка как инлайн кнопки - for index, item in enumerate(items): - description = card.description or "" - lines = description.split("\n") - emoji = "✅" if index < len(lines) and "[x]" in lines[index] else "⭕" - - keyboard_builder.button( - text=f"{emoji} {item}", - callback_data=CardCallback( - action="toggle", card_id=card.id, item_index=index - ), - ) - - 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) - return keyboard_builder - - -async def show_card_view(message: types.Message, card_id: int) -> None: - """Показать представление карточки""" - card = await get_card_by_id(card_id) - if not card: - await message.answer("❌ Карточка не найдена") - return - - items = card.get_list_items() - keyboard_builder = create_card_keyboard(card, items) - - card_text = f"{card.title}\n\n" - if items: - card_text += f"Элементов: {len(items)}\n\n" - else: - card_text += "Список пуст\n\n" - - card_text += "💡 Просто введите новые элементы сообщением:\n" - card_text += "• Один элемент\n" - card_text += "• Или несколько через запятую" - - await message.answer( - card_text, reply_markup=keyboard_builder.as_markup(), parse_mode="HTML" - ) diff --git a/handlers/bot_routes/route_edit_items.py b/handlers/bot_routes/route_edit_items.py index 55f28ec..64f3e3f 100644 --- a/handlers/bot_routes/route_edit_items.py +++ b/handlers/bot_routes/route_edit_items.py @@ -3,10 +3,15 @@ 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 -from handlers.handler_nc_deck import get_shopping_cards, update_card_description -from handlers.bot_routes.route_states import CardCallback -from handlers.bot_routes.route_view_card import view_card_handler edit_router = Router() @@ -15,18 +20,22 @@ async def toggle_item_handler( callback: types.CallbackQuery, callback_data: CardCallback ) -> None: - """Переключить статус элемента списка""" - cards = await get_shopping_cards() - if not cards: - await callback.answer("❌ Карточка не найдена") - return - - card = next((c for c in cards if c.id == callback_data.card_id), None) - if not card or not card.description: + """ + Переключить статус элемента списка + + :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 = card.description.split("\n") + lines = target_card.description.split("\n") if 0 <= callback_data.item_index < len(lines): line = lines[callback_data.item_index] @@ -35,10 +44,14 @@ async def toggle_item_handler( elif "[x]" in line: lines[callback_data.item_index] = line.replace("[x]", "[ ]") - success = await update_card_description(card.id, "\n".join(lines)) + new_description = "\n".join(lines) + success = await update_card_description(target_card.id, new_description) + if success: await callback.answer("✅ Статус обновлен") - await view_card_handler(callback, callback_data) + # Обновляем кэш и показываем обновленную карточку + target_card.description = new_description + await _show_card_view(callback.message, target_card) else: await callback.answer("❌ Ошибка обновления") else: @@ -49,20 +62,25 @@ async def toggle_item_handler( async def remove_items_handler( callback: types.CallbackQuery, callback_data: CardCallback ) -> None: - """Начать удаление элементов""" - cards = await get_shopping_cards() - if not cards: - await callback.answer("❌ Карточка не найдена") - return - - card = next((c for c in cards if c.id == callback_data.card_id), None) - if not card: + """ + Начать удаление элементов + + :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 = card.get_list_items() + items = target_card.get_list_items() if not items: - await callback.answer("❌ Список пуст") + await callback.answer("📝 Список пуст") + await list_handler(callback.message) return keyboard_builder = InlineKeyboardBuilder() @@ -70,12 +88,13 @@ async def remove_items_handler( keyboard_builder.button( text=f"🗑️ {item}", callback_data=CardCallback( - action="delete", card_id=card.id, item_index=index + action="delete", card_id=target_card.id, item_index=index ), ) keyboard_builder.button( - text="⬅️ Назад", callback_data=CardCallback(action="view", card_id=card.id) + text="⬅️ Назад", + callback_data=CardCallback(action="view", card_id=target_card.id), ) keyboard_builder.adjust(1) @@ -91,31 +110,41 @@ async def remove_items_handler( async def delete_item_handler( callback: types.CallbackQuery, callback_data: CardCallback ) -> None: - """Удалить элемент из списка""" - cards = await get_shopping_cards() - if not cards: - await callback.answer("❌ Карточка не найдена") - return - - card = next((c for c in cards if c.id == callback_data.card_id), None) - if not card or not card.description: + """ + Удалить элемент из списка + + :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 = card.description.split("\n") + lines = target_card.description.split("\n") if 0 <= callback_data.item_index < len(lines): lines.pop(callback_data.item_index) - success = await update_card_description(card.id, "\n".join(lines)) + + new_description = "\n".join(lines) if lines else "" + success = await update_card_description(target_card.id, new_description) if success: await callback.answer("✅ Элемент удален") - # Перезагружаем карточки для актуальных данных - updated_cards = await get_shopping_cards() - updated_card = next((c for c in updated_cards if c.id == card.id), None) - if updated_card: + + # Обновляем кэш + target_card.description = new_description + items = target_card.get_list_items() + + if items: await remove_items_handler(callback, callback_data) else: - await callback.answer("❌ Ошибка обновления данных") + await callback.answer("📝 Список пуст") + callback_data.action = "view" + await _show_card_view(callback.message, target_card) else: await callback.answer("❌ Ошибка удаления") else: diff --git a/handlers/bot_routes/route_list_cards.py b/handlers/bot_routes/route_list_cards.py index 76b4096..6451a45 100644 --- a/handlers/bot_routes/route_list_cards.py +++ b/handlers/bot_routes/route_list_cards.py @@ -6,17 +6,23 @@ from handlers.handler_logging import logger from handlers.handler_nc_deck import get_shopping_cards -from handlers.bot_routes.route_states import CardCallback +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""" - help_text = """ -🛒 Бот для списка покупок + """ + Обработчик команд /start и /help + + :param message: Входящее сообщение + :type message: types.Message + """ + help_text = """🛒 Бот для списка покупок Команды: /list - Показать список покупок @@ -29,10 +35,16 @@ async def start_help_handler(message: types.Message) -> None: @list_router.message(Command("list")) async def list_handler(message: types.Message) -> None: - """Показать список покупок в виде инлайн кнопок""" - logger.info(f"Пользователь {message.from_user.id} запросил список покупок") - - # Всегда загружаем свежие данные + """ + Показать список покупок в виде инлайн кнопок + + :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: @@ -61,7 +73,19 @@ async def list_handler(message: types.Message) -> None: async def back_to_list_handler( callback: types.CallbackQuery, callback_data: CardCallback ) -> None: - """Вернуться к списку карточек""" - del callback_data - await callback.answer() - await list_handler(callback.message) + """ + Вернуться к списку карточек + + :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_states.py b/handlers/bot_routes/route_states.py deleted file mode 100644 index 326cf26..0000000 --- a/handlers/bot_routes/route_states.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Состояния и callback данные для бота списка покупок""" - -from aiogram.filters.callback_data import CallbackData - - -class CardCallback(CallbackData, prefix="card"): - """Callback для навигации по карточкам""" - - action: str # 'view', 'back', 'toggle', 'delete' - card_id: int = 0 - item_index: int = -1 diff --git a/handlers/bot_routes/route_utils.py b/handlers/bot_routes/route_utils.py deleted file mode 100644 index c3d44e3..0000000 --- a/handlers/bot_routes/route_utils.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Вспомогательные функции для роутов бота""" - - -def parse_new_items(text: str) -> list: - """Парсит новые элементы из текста""" - new_items_text = text.strip() - if not new_items_text: - return [] - - if "," in new_items_text: - return [item.strip() for item in new_items_text.split(",") if item.strip()] - else: - return [new_items_text] diff --git a/handlers/bot_routes/route_view_card.py b/handlers/bot_routes/route_view_card.py index 2f8f158..a50fa30 100644 --- a/handlers/bot_routes/route_view_card.py +++ b/handlers/bot_routes/route_view_card.py @@ -1,94 +1,223 @@ """Логика просмотра и управления карточкой""" from aiogram import Router, types, F +from aiogram.utils.keyboard import InlineKeyboardBuilder -# from handlers.handler_logging import logger from handlers.handler_nc_deck import get_shopping_cards, update_card_description -from handlers.bot_routes.route_utils import parse_new_items -from handlers.bot_routes.route_states import CardCallback +from handlers.bot_routes.states import CardCallback from handlers.bot_routes.route_list_cards import list_handler -from handlers.bot_routes.route_common import create_card_keyboard +from handlers.handler_logging import logger view_router = Router() +MAX_ITEM_PREVIEW_LENGTH = 50 +ELLIPSIS_LENGTH = 3 + +# Простой кэш карточек на время сессии +_card_cache = {} + @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 - card = next((c for c in cards if c.id == callback_data.card_id), None) - if not card: + 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 + + 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 = create_card_keyboard(card, 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 + ), + ) - card_text = f"{card.title}\n\n" if items: - card_text += f"Элементов: {len(items)}\n\n" + 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: - card_text += "Список пуст\n\n" + 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" - card_text += "💡 Просто введите новые элементы сообщением:\n" - card_text += "• Один элемент\n" - card_text += "• Или несколько через запятую" + return f"""{title} - await callback.message.edit_text( - card_text, reply_markup=keyboard_builder.as_markup(), parse_mode="HTML" - ) - await callback.answer() +{items_text} +💡 Просто введите новые элементы сообщением: +• Один элемент +• Или несколько через запятую""" @view_router.message() async def handle_message_input(message: types.Message) -> None: - """Обработчик текстовых сообщений для автоматического добавления элементов""" - # Проверяем, не является ли сообщение командой - if message.text.startswith("/"): - return + """ + Обработчик текстовых сообщений для автоматического добавления элементов - # Получаем все карточки один раз - cards = await get_shopping_cards() - if not cards: - await message.answer("❌ Нет доступных карточек") + :param message: Входящее сообщение + :type message: types.Message + """ + if message.text.startswith("/"): return - # Если есть только одна карточка, добавляем в нее - if len(cards) == 1: - card = cards[0] - await _add_items_to_card(message, card) - return + logger.info(f"Получено сообщение для добавления: {message.text}") - # Если карточек несколько, используем первую как fallback - card = cards[0] - await _add_items_to_card(message, card) + # Используем первую карточку из кэша или загружаем заново + if _card_cache: + # Берем первую карточку из кэша (самый частый случай - одна карточка) + target_card = next(iter(_card_cache.values())) + await _add_items_to_card(message, target_card) + 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) + + +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: - """Добавить элементы в карточку""" - # Парсим новые элементы - new_items = parse_new_items(message.text) + """ + Добавить элементы в карточку + + :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 = card.get_list_items() updated_items = current_items + new_items - - # Обновляем описание карточки new_description = card.update_list_items(updated_items) + + logger.info(f"Обновление карточки {card.id}: {len(updated_items)} элементов") + success = await update_card_description(card.id, new_description) if success: await message.answer(f"✅ Добавлено {len(new_items)} элементов в '{card.title}'") - # Просто вызываем list_handler который покажет актуальный список + # Обновляем кэш + 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) diff --git a/handlers/bot_routes/routes_deck.py b/handlers/bot_routes/routes_deck.py index a7cee54..c105b34 100644 --- a/handlers/bot_routes/routes_deck.py +++ b/handlers/bot_routes/routes_deck.py @@ -1,4 +1,4 @@ -"""Основной роутер для бота списка покупок (сборка всех подроутеров)""" +"""Главный роутер для бота списка покупок""" from aiogram import Router @@ -6,8 +6,13 @@ from handlers.bot_routes.route_view_card import view_router from handlers.bot_routes.route_edit_items import edit_router -# Создаем главный роутер и включаем все подроутеры nc_deck_router = Router() -nc_deck_router.include_router(list_router) -nc_deck_router.include_router(view_router) -nc_deck_router.include_router(edit_router) + +ROUTERS = ( + list_router, + view_router, + edit_router, +) + +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 d6c1bc6..bfa823f 100644 --- a/handlers/handler_bot.py +++ b/handlers/handler_bot.py @@ -10,17 +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("Бот списка покупок инициализирован") async def process_update(update_data: dict) -> None: - """ - Обработка обновления от Telegram - """ + """Обработка обновления от Telegram""" if not BOT_INSTANCE: logger.warning("Получено обновление, но бот не инициализирован") return @@ -30,9 +26,7 @@ async def process_update(update_data: dict) -> None: async def setup_webhook(webhook_url: str) -> None: - """ - Настройка вебхука - """ + """Настройка вебхука""" if not BOT_INSTANCE: logger.warning("Попытка настроить вебхук без бота") return @@ -43,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 07d5d14..9a1cae9 100644 --- a/handlers/handler_nc_deck.py +++ b/handlers/handler_nc_deck.py @@ -4,92 +4,88 @@ from config.settings import settings from handlers.handler_logging import logger from handlers.handler_requests import send_request -from models.model_card import ShoppingCard +from models.model_card import ModelCard DECK_ENDPOINT = "/apps/deck/api/v1.0/boards" -async def get_shopping_cards() -> Optional[List[ShoppingCard]]: - """Получение карточек из стека списка покупок""" - try: - stacks_endpoint = ( - f"{settings.NC_URL}{DECK_ENDPOINT}/{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", []) - if not cards_data: - logger.info("В стеке нет карточек") - return [] - - cards = [] - for card_data in cards_data: - try: - card = ShoppingCard( - 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(card) - except Exception as e: - logger.warning( - f"Ошибка преобразования карточки {card_data.get('id')}: {e}" - ) - continue - - logger.info(f"Успешно загружено {len(cards)} карточек") - return cards - - except Exception as e: - logger.error(f"Ошибка получения карточек: {e}") +async def get_shopping_cards() -> Optional[List[ModelCard]]: + """ + Получение карточек из стека списка покупок + + :return: Список карточек или None при ошибке + :rtype: Optional[List[ShoppingCard]] + """ + 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: + 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 update_card_description(card_id: int, description: str) -> bool: - """Основная функция обновления карточки""" - try: - card_endpoint = ( - f"{settings.NC_URL}{DECK_ENDPOINT}/" - f"{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 - else: - logger.error(f"Ошибка обновления карточки {card_id}") - return False - - except Exception as e: - logger.error(f"Ошибка при обновлении карточки {card_id}: {e}") + """ + Обновление описания карточки + + :param card_id: ID карточки для обновления + :type card_id: int + :param description: Новое описание карточки + :type description: str + :return: True если обновление успешно, иначе False + :rtype: bool + """ + 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 fd4c729..e1bd91c 100644 --- a/handlers/handler_requests.py +++ b/handlers/handler_requests.py @@ -1,12 +1,13 @@ """Обработчик отправки запросов к серверу NextCloud""" from typing import Any, Dict, Optional -import json 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", @@ -18,7 +19,18 @@ async def send_request( url: str, method: str, data: Optional[Dict] = None ) -> Optional[Dict[Any, Any]]: - """Клиент для отправки запросов в API NextCloud""" + """ + Клиент для отправки запросов в API NextCloud + + :param url: Полный адрес вебхука куда отправляются данные + :type url: str + :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.error("Не определен URL!") return None @@ -26,31 +38,18 @@ async def send_request( async with AsyncClient(auth=auth, headers=headers, timeout=30.0) as client: try: method_upper = method.upper() - if method_upper == "GET": - response = await client.get(url=url) - elif method_upper == "POST": - response = await client.post(url=url, json=data) - elif method_upper == "PUT": - response = await client.put(url=url, json=data) - elif method_upper == "DELETE": - response = await client.delete(url=url) - else: - logger.error(f"Неподдерживаемый метод: {method}") - return None - + match method_upper: + case "GET": + response = await client.get(url=url) + case "POST": + response = await client.post(url=url, json=data) + case "PUT": + response = await client.put(url=url, json=data) + case _: + logger.error(f"Неподдерживаемый метод: {method}") + return None response.raise_for_status() - - # Пытаемся прочитать ответ как JSON - try: - return response.json() - except json.JSONDecodeError as e: - logger.error(f"Ошибка декодирования JSON от {url}: {e}") - logger.error(f"Response text: {response.text}") - return None - except HTTPStatusError as e: - logger.error(f"Неожиданная ошибка при обработке ответа от {url}: {e}") - return None - - except RequestError as err: - logger.error(f'Ошибка запроса к "{url}": {err}') - return None + return response.json() + except (RequestError, HTTPStatusError) as error: + logger.error(f'Ошибка запроса к "{url}" : {error}') + return None diff --git a/models/model_card.py b/models/model_card.py index d245b86..2bd800e 100644 --- a/models/model_card.py +++ b/models/model_card.py @@ -4,8 +4,19 @@ from pydantic import BaseModel -class ShoppingCard(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 @@ -15,48 +26,72 @@ class ShoppingCard(BaseModel): @property def short_title(self) -> str: """Сокращенный заголовок для кнопок""" - if len(self.title) > 30: - return self.title[:27] + "..." + 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 line and ( - line.startswith("- ") - or line.startswith("* ") - or line.startswith("- [ ] ") - or line.startswith("- [x] ") - or line[0].isdigit() - and ". " in line - ): - # Очищаем от маркеров списка - clean_line = line - if line.startswith("- [ ] ") or line.startswith("- [x] "): - clean_line = line[6:] # Убираем "- [ ] " или "- [x] " - elif line.startswith("- ") or line.startswith("* "): - clean_line = line[2:] # Убираем "- " или "* " - elif ". " in line and line[0].isdigit(): - clean_line = line.split(". ", 1)[1] # Убираем "1. " - - items.append(clean_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 "" list_lines = [] + CHECKBOX_TEMPLATE = "- [ ] {item}" + for item in items: - # Форматируем как задачи - list_lines.append(f"- [ ] {item}") # noqa: E201 + list_lines.append(CHECKBOX_TEMPLATE.format(item=item)) return "\n".join(list_lines) From b48cd77816c89369d4631d9f67eb183246a94747 Mon Sep 17 00:00:00 2001 From: intervisionlord Date: Sun, 23 Nov 2025 18:23:43 +0300 Subject: [PATCH 7/7] =?UTF-8?q?fix(Bot):=20=D0=A0=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=20=D1=81=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=BA=D0=B8=D0=BC=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавление элементов в текущую открытую карточку - Отслеживание нахождения в конкретной карточке --- handlers/bot_routes/route_view_card.py | 69 ++++++++++++++++++--- models/model_card.py | 83 ++++++++++++++++++++++++-- 2 files changed, 141 insertions(+), 11 deletions(-) diff --git a/handlers/bot_routes/route_view_card.py b/handlers/bot_routes/route_view_card.py index a50fa30..e0110c1 100644 --- a/handlers/bot_routes/route_view_card.py +++ b/handlers/bot_routes/route_view_card.py @@ -16,6 +16,9 @@ # Простой кэш карточек на время сессии _card_cache = {} +# Кэш для отслеживания активной карточки по пользователю +_user_active_card = {} + @view_router.callback_query(CardCallback.filter(F.action == "view")) async def view_card_handler( @@ -44,6 +47,10 @@ async def view_card_handler( # Кэшируем карточку _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() @@ -140,11 +147,21 @@ async def handle_message_input(message: types.Message) -> None: logger.info(f"Получено сообщение для добавления: {message.text}") - # Используем первую карточку из кэша или загружаем заново - if _card_cache: - # Берем первую карточку из кэша (самый частый случай - одна карточка) + # Получаем активную карточку для пользователя + 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() @@ -153,6 +170,9 @@ async def handle_message_input(message: types.Message) -> None: 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: @@ -194,11 +214,22 @@ async def _add_items_to_card(message: types.Message, card) -> None: logger.info(f"Парсинг элементов: {new_items}") - current_items = card.get_list_items() - updated_items = current_items + new_items - new_description = card.update_list_items(updated_items) + # Получаем текущие элементы с их состояниями + current_items_with_states = card.get_list_items_with_states() + current_item_texts = [item["text"] for item in current_items_with_states] - logger.info(f"Обновление карточки {card.id}: {len(updated_items)} элементов") + # Добавляем только новые элементы (исключаем дубликаты) + 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) @@ -221,3 +252,27 @@ async def get_cached_card(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/models/model_card.py b/models/model_card.py index 2bd800e..c86a77b 100644 --- a/models/model_card.py +++ b/models/model_card.py @@ -78,7 +78,7 @@ def _clean_list_item(self, line: str) -> Optional[str]: def update_list_items(self, items: List[str]) -> str: """ - Обновить элементы списка в формате задач + Обновить элементы списка в формате задач, сохраняя состояние выполненных элементов :param items: Список элементов для добавления :type items: List[str] @@ -88,10 +88,85 @@ def update_list_items(self, items: List[str]) -> 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 = [] - CHECKBOX_TEMPLATE = "- [ ] {item}" - for item in items: - list_lines.append(CHECKBOX_TEMPLATE.format(item=item)) + # Используем сохраненное состояние или создаем новый 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