From 4f3479433f7a40e284dc301fd2ad62c76e0fea5a Mon Sep 17 00:00:00 2001 From: "@m.danilin" Date: Tue, 9 Dec 2025 00:19:34 +0300 Subject: [PATCH 01/10] Working with players, without an inventory yet --- db/models/__init__.py | 6 +- db/models/character.py | 2 +- handlers/admin/campaign_list.py | 2 +- handlers/admin/campaign_manage.py | 12 +- handlers/admin/character_management.py | 400 +++++++++++++++++++++++++ handlers/admin/edit_campaign.py | 2 +- handlers/admin/states.py | 1 + services/invitation.py | 4 +- 8 files changed, 416 insertions(+), 13 deletions(-) create mode 100644 handlers/admin/character_management.py diff --git a/db/models/__init__.py b/db/models/__init__.py index f3403f7..8c6d4b7 100644 --- a/db/models/__init__.py +++ b/db/models/__init__.py @@ -1,9 +1,9 @@ -from .campaign import Campaign +from .campaign import Campaign # noqa: I001 +from .user import User from .character import Character +from .participation import Participation from .invitation import Invitation from .item import Item -from .participation import Participation -from .user import User __all__ = [ "Campaign", diff --git a/db/models/character.py b/db/models/character.py index 0160235..9a89e5c 100644 --- a/db/models/character.py +++ b/db/models/character.py @@ -3,7 +3,7 @@ from .base import CharacterData, TimestampedModel, UuidModel -class Character(TimestampedModel, CharacterData, UuidModel): +class Character(CharacterData, TimestampedModel, UuidModel): user = fields.ForeignKeyField("models.User") campaign = fields.ForeignKeyField("models.Campaign") diff --git a/handlers/admin/campaign_list.py b/handlers/admin/campaign_list.py index f0f1f66..1c2804d 100644 --- a/handlers/admin/campaign_list.py +++ b/handlers/admin/campaign_list.py @@ -44,7 +44,7 @@ async def on_campaign_selected( # === Окна === campaign_list_window = Window( - Const("🏰 Магическая Академия - Ваши кампейнами\n\n"), + Const("🏰 Магическая Академия - Ваши кампейны\n\n"), Const( "У вас пока нет доступных партий", when=lambda data, widget, dialog_manager: not data.get("has_campaigns", False), diff --git a/handlers/admin/campaign_manage.py b/handlers/admin/campaign_manage.py index 159296b..1c2530d 100644 --- a/handlers/admin/campaign_manage.py +++ b/handlers/admin/campaign_manage.py @@ -56,7 +56,7 @@ async def on_edit_info(callback: CallbackQuery, button: Button, dialog_manager: async def on_manage_characters(callback: CallbackQuery, button: Button, dialog_manager: DialogManager): campaign_id = dialog_manager.dialog_data.get("campaign_id", {}) await dialog_manager.start( - states.ManageCharacters.character_menu, + states.ManageCharacters.character_selection, data={"campaign_id": campaign_id}, ) @@ -84,6 +84,12 @@ async def on_stats( f"📈 Прогресс: 78%" ) await callback.answer(stats_text, show_alert=True) + + + Button( + Const("🤝 Встречи"), + id="meetings", + ), """ # === Окна === @@ -91,10 +97,6 @@ async def on_stats( DynamicMedia("icon"), Format("🎓 Управление: {campaign_title}\n\nОписание: {campaign_description}\nВыберите действие:"), Group( - Button( - Const("🤝 Встречи"), - id="meetings", - ), Button( Const("✏️ Управление кампанией"), id="edit_info", diff --git a/handlers/admin/character_management.py b/handlers/admin/character_management.py new file mode 100644 index 0000000..2201c9e --- /dev/null +++ b/handlers/admin/character_management.py @@ -0,0 +1,400 @@ +import json +import logging +from typing import TYPE_CHECKING +from uuid import UUID + +from aiogram import Router +from aiogram.types import BufferedInputFile, CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, Window +from aiogram_dialog.widgets.input import ManagedTextInput, TextInput +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Group, Row, ScrollingGroup, Select, Start, SwitchTo +from aiogram_dialog.widgets.media import DynamicMedia +from aiogram_dialog.widgets.text import Const, Format, Multi +from tortoise.exceptions import IncompleteInstanceError, IntegrityError, OperationalError + +from db.models.campaign import Campaign +from db.models.character import Character +from db.models.participation import Participation +from db.models.user import User +from services.character import parse_character_data +from services.character_data import character_preview_getter +from services.role import Role + +from . import states + +if TYPE_CHECKING: + from collections.abc import Sequence + + from db.models.base import CharacterData, UuidModel + + +logger = logging.getLogger(__name__) + + +# === Константы === +MAX_LEVEL = 20 # D&D максимальный уровень +MAX_RATING = 1000 + + +# === Гетеры === +async def get_character_data(id_: int | UUID) -> User | Character: + if isinstance(id_, int): + return await User.get(id=id_) + return await Character.get(id=id_).prefetch_related("user") + + +async def get_characters_for_campaign(dialog_manager: DialogManager, **kwargs): + """Получение персонажей в выбранной кампании""" + if "campaign_id" not in dialog_manager.dialog_data and isinstance(dialog_manager.start_data, dict): + dialog_manager.dialog_data["campaign_id"] = dialog_manager.start_data["campaign_id"] + + campaign_id: int = dialog_manager.dialog_data["campaign_id"] + campaign = await Campaign.get(id=campaign_id) + + list_participation = await Participation.filter(campaign=campaign, role=Role.PLAYER).prefetch_related("user").all() + + if list_participation: + characters: Sequence[tuple[CharacterData, User, UuidModel]] = [ + (participation.user, participation.user, participation.user) for participation in list_participation + ] + else: + characters: Sequence[tuple[CharacterData, User, UuidModel]] = [ + (char, char.user, char) + for char in (await Character.filter(campaign=campaign).prefetch_related("user").all()) + ] + + characters_data = [ + (parse_character_data(json.loads(char.data["data"])), user, model_uuid) for char, user, model_uuid in characters + ] + + return { + "characters": characters_data, + "campaign_title": campaign.title, + "has_characters": len(characters_data) > 0, + "is_verified": campaign.verified, + } + + +async def preview_getter(dialog_manager: DialogManager, **kwargs): + character_id = dialog_manager.dialog_data["character_id"] + character = await get_character_data(character_id) + data = json.loads(character.data["data"]) + + if isinstance(character, User): + character_preview = character_preview_getter(character, data) + return { + "profile_link": f"tg://user?id={character.id}", + "user": character, + "is_verified": True, + **character_preview, + } + user = character.user + return { + "profile_link": f"tg://user?id={user.id}", + "user": user, + "is_verified": False, + **character_preview_getter(user, data), + } + + +async def get_level(dialog_manager: DialogManager, **kwargs): + character_id = dialog_manager.dialog_data["character_id"] + character = await get_character_data(character_id) + data = character.data or {} + + try: + json_data = json.loads(data.get("data", "{}")) + level = json_data.get("info", {}).get("level", {}).get("value", 1) + except (json.JSONDecodeError, AttributeError): + level = 1 + + return {"level": level} + + +# === Кнопки (обработчики) === +async def on_character_selected( + callback: CallbackQuery, widget: Select, dialog_manager: DialogManager, character_id: str +): + """Обработчик выбора персонажа""" + if character_id.isdigit(): + dialog_manager.dialog_data["character_id"] = int(character_id) + else: + dialog_manager.dialog_data["character_id"] = UUID(character_id) + await dialog_manager.next() + + +async def on_quick_rating_change(callback: CallbackQuery, widget: Button, dialog_manager: DialogManager, change: int): + """Быстрое изменение рейтинга""" + try: + character_id = dialog_manager.dialog_data["character_id"] + + user = await User.get(id=character_id) + + new_rating = user.rating + change + + new_rating = max(new_rating, 0) + new_rating = min(new_rating, MAX_RATING) + + user.rating = new_rating + await user.save() + + await dialog_manager.show() + + except (IncompleteInstanceError, IntegrityError, OperationalError) as e: + logger.exception("Error in quick rating change", exc_info=e) + await callback.answer("❌ Ошибка при изменении рейтинга", show_alert=True) + + +async def on_rating_input(message: Message, widget: ManagedTextInput, dialog_manager: DialogManager, text: str): + """Обработчик ввода нового рейтинга""" + try: + rating = int(text) + character_id = dialog_manager.dialog_data["character_id"] + + user = await User.get(id=character_id) + + rating = min(max(0, rating), MAX_RATING) + + user.rating = rating + await user.save() + + await message.answer(f"✅ Рейтинг успешно изменен на {rating}") + await dialog_manager.switch_to(states.ManageCharacters.character_menu) + + except ValueError: + await message.answer("❌ Пожалуйста, введите целое число") + except (IncompleteInstanceError, IntegrityError, OperationalError) as e: + logger.exception("Error updating rating", exc_info=e) + await message.answer("❌ Ошибка при обновлении рейтинга") + + +async def on_level_input(message: Message, widget: ManagedTextInput, dialog_manager: DialogManager, text: str): + """Обработчик ввода нового уровня""" + try: + level = int(text) + character_id = dialog_manager.dialog_data["character_id"] + + if level < 1: + await message.answer("❌ Уровень должен быть не меньше 1") + return + if level > MAX_LEVEL: + await message.answer(f"❌ Уровень не может превышать {MAX_LEVEL}") + return + + character = await get_character_data(character_id) + character_data = character.data + + character_data["data"] = character_data.get("data", "") + + new_data = json.loads(character_data["data"]) + new_data["info"] = new_data.get("info", {}) + new_data["info"]["level"] = new_data["info"].get("level", {}) + new_data["info"]["level"]["value"] = level + + character_data["data"] = json.dumps(new_data) + + character.data = character_data + await character.save() + + await message.answer(f"✅ Уровень изменен на {level}") + await dialog_manager.switch_to(states.ManageCharacters.character_menu) + + except ValueError: + await message.answer("❌ Введите целое число") + except (IncompleteInstanceError, IntegrityError, OperationalError) as e: + logger.exception("Error updating level", exc_info=e) + await message.answer("❌ Ошибка при обновлении уровня") + + +async def on_add_character(mes: CallbackQuery, wif: Button, dialog_manager: DialogManager): + campaign_id = dialog_manager.dialog_data["campaign_id"] + + await dialog_manager.start(states.InviteMenu.main, data={"campaign_id": campaign_id}) + + +async def on_download_json(callback: CallbackQuery, button: Button, dialog_manager: DialogManager): + """Обработчик скачивания JSON данных персонажа""" + try: + character_id = dialog_manager.dialog_data["character_id"] + character = await get_character_data(character_id) + + data = character.data or {} + json_str = data.get("data", "{}") + + if isinstance(character, User): + filename = f"character_{character.username or character.id}.json" + else: + filename = f"character_{character.user.username or character.user.id}.json" + + json_bytes = json_str.encode("utf-8") + input_file = BufferedInputFile(json_bytes, filename=filename) + + if callback.message: + await callback.message.answer_document(document=input_file) + await dialog_manager.show() + else: + await callback.answer("❌ Ошибка при выгрузке JSON", show_alert=True) + + except Exception as e: + logger.exception("Error downloading JSON", exc_info=e) + await callback.answer("❌ Ошибка при выгрузке JSON", show_alert=True) + + +# === Окна === +character_selection_window = Window( + Multi( + Format("🎭 Выберите персонажа в кампании: {campaign_title}"), + Const( + "В этой кампании нет персонажей", + when=lambda data, *_: not data.get("has_characters", False), + ), + sep="\n", + ), + ScrollingGroup( + Select( + Format("@{item[1].username} – {item[0].name}"), + id="character_select", + items="characters", + item_id_getter=lambda x: str(x[2].id), + on_click=on_character_selected, + ), + hide_on_single_page=True, + height=5, + id="characters_scroll", + ), + Button(Const("➕ Добавить"), id="add_character", on_click=on_add_character), + Cancel(Const("⬅️ Назад")), + state=states.ManageCharacters.character_selection, + getter=get_characters_for_campaign, +) + +character_detail_window = Window( + DynamicMedia("avatar", when="avatar"), + Format("Игрок: @{user.username}"), + Format("Текущий рейтинг: {user.rating}", when="is_verified"), + Format("{character_data_preview}", when="character_data_preview"), + # TODO(BratLaym): Fix: Url(Const("Перейти в профиль"), Format("{profile_link}")), + # https://github.com/cu-3rd-party/dnd/issues/18#issue-3708051234 + Group( + SwitchTo( + Const("📈 Изменить уровень"), + id="change_level", + state=states.ManageCharacters.change_level, + ), + SwitchTo( + Const("🏆 Изменить рейтинг"), + id="change_rating", + state=states.ManageCharacters.quick_rating, + when="is_verified", + ), + Button( + Const("📥 JSON данные"), + id="download_json", + on_click=on_download_json, + ), + Start( + Const("🎒 Управление инвентарем"), + id="manage_inventory", + state=states.ManageInventory.view_inventory, + ), + width=2, + ), + Back(Const("⬅️ Назад к выбору персонажа")), + Cancel(Const("❌ Выход")), + state=states.ManageCharacters.character_menu, + getter=preview_getter, +) + +change_level_window = Window( + Const(f"📈 Введите новый уровень персонажа (1-{MAX_LEVEL}):"), + Format("Сейчас установлен уровень: {level}"), + TextInput( + id="level_input", + on_success=on_level_input, + ), + SwitchTo(Const("⬅️ Назад"), id="lvl_to_menu", state=states.ManageCharacters.character_menu), + getter=get_level, + state=states.ManageCharacters.change_level, +) + +change_rating_window = Window( + Multi( + Const("🏆 Изменение рейтинга игрока"), + Format("Текущий рейтинг: {user.rating}"), + Const("Введите новый рейтинг:"), + sep="\n", + ), + TextInput( + id="rating_input", + on_success=on_rating_input, + ), + SwitchTo(Const("⬅️ Назад"), id="rat_to_menu", state=states.ManageCharacters.character_menu), + state=states.ManageCharacters.change_rating, + getter=preview_getter, +) + +quick_rating_window = Window( + Multi( + Const("🏆 Быстрое изменение рейтинга"), + Format("Игрок: {user.username}"), + Format("Текущий рейтинг: {user.rating}"), + Const("Выберите изменение:"), + sep="\n", + ), + Group( + Row( + Button( + Const("+1"), + id="rating_plus_1", + on_click=lambda c, b, d: on_quick_rating_change(c, b, d, 1), + ), + Button( + Const("+5"), + id="rating_plus_5", + on_click=lambda c, b, d: on_quick_rating_change(c, b, d, 5), + ), + Button( + Const("+10"), + id="rating_plus_10", + on_click=lambda c, b, d: on_quick_rating_change(c, b, d, 10), + ), + ), + Row( + Button( + Const("-1"), + id="rating_minus_1", + on_click=lambda c, b, d: on_quick_rating_change(c, b, d, -1), + ), + Button( + Const("-5"), + id="rating_minus_5", + on_click=lambda c, b, d: on_quick_rating_change(c, b, d, -5), + ), + Button( + Const("-10"), + id="rating_minus_10", + on_click=lambda c, b, d: on_quick_rating_change(c, b, d, -10), + ), + ), + Button( + Const("✏️ Ввести точное значение"), + id="exact_rating", + on_click=lambda c, b, d: d.switch_to(states.ManageCharacters.change_rating), + ), + ), + SwitchTo(Const("⬅️ Назад"), id="q_rat_to_menu", state=states.ManageCharacters.character_menu), + state=states.ManageCharacters.quick_rating, + getter=preview_getter, +) + +# === Диалог и роутер === +dialog = Dialog( + character_selection_window, + character_detail_window, + change_level_window, + change_rating_window, + quick_rating_window, +) + +router = Router() +router.include_router(dialog) diff --git a/handlers/admin/edit_campaign.py b/handlers/admin/edit_campaign.py index 7e9cc89..9503cb0 100644 --- a/handlers/admin/edit_campaign.py +++ b/handlers/admin/edit_campaign.py @@ -224,7 +224,7 @@ async def on_remove_campaign(callback: CallbackQuery, button: Button, dialog_man Format("🎯 Вы точно хотите удалить {campaign_title}\n ЭТО ДЕЙСТВИЕ НЕ ОТМЕНИТЬ"), Button(Const("🚫 Удалить кампанию"), id="remove_campaign", on_click=on_remove_campaign), SwitchTo( - Const("⬅️ Назад"), + Const("⬅️ Отмена"), id="back", state=states.EditCampaignInfo.select_field, ), diff --git a/handlers/admin/states.py b/handlers/admin/states.py index 225077a..7853b81 100644 --- a/handlers/admin/states.py +++ b/handlers/admin/states.py @@ -45,6 +45,7 @@ class ManageInventory(StatesGroup): edit_inventory_item_name = State() edit_inventory_item_description = State() edit_inventory_item_quantity = State() + accept_delete = State() class EditCampaignInfo(StatesGroup): diff --git a/services/invitation.py b/services/invitation.py index 1765d9f..92a1e0c 100644 --- a/services/invitation.py +++ b/services/invitation.py @@ -19,12 +19,12 @@ async def generate_link(invitation: Invitation) -> str: bot = settings.player_bot if invitation.role == Role.PLAYER else settings.admin_bot if isinstance(bot, Bot): - bot_name = (await bot.get_my_name()).name + bot_name = (await bot.get_me()).username else: msg = "bot is not specified" raise TypeError(msg) - return f"https://t.me/{bot_name}_bot?start={invitation.start_data}" + return f"https://t.me/{bot_name}?start={invitation.start_data}" async def generate_qr(link: str) -> str: From fc7a458d32e214e520110c728175867e798a4112 Mon Sep 17 00:00:00 2001 From: "@m.danilin" Date: Tue, 9 Dec 2025 23:47:22 +0300 Subject: [PATCH 02/10] implemented inventory management --- db/models/item.py | 4 +- handlers/admin/character_management.py | 16 +- handlers/admin/inventory_management.py | 383 +++++++++++++++++++++++++ handlers/admin/invitation.py | 5 +- services/user.py | 2 + 5 files changed, 403 insertions(+), 7 deletions(-) create mode 100644 handlers/admin/inventory_management.py diff --git a/db/models/item.py b/db/models/item.py index ce8ccd3..3712868 100644 --- a/db/models/item.py +++ b/db/models/item.py @@ -57,10 +57,10 @@ class Meta: def clean(self): """Validate model before saving""" - if self.holder_character.id and self.holder_user.id: + if self.holder_character and self.holder_user: raise ItemHeldByBothError - if not self.holder_character.id and not self.holder_user.id: + if not self.holder_character and not self.holder_user: raise NoHolderError async def save(self, *args, **kwargs): diff --git a/handlers/admin/character_management.py b/handlers/admin/character_management.py index 2201c9e..c822782 100644 --- a/handlers/admin/character_management.py +++ b/handlers/admin/character_management.py @@ -7,7 +7,7 @@ from aiogram.types import BufferedInputFile, CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import ManagedTextInput, TextInput -from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Group, Row, ScrollingGroup, Select, Start, SwitchTo +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Group, Row, ScrollingGroup, Select, SwitchTo from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.text import Const, Format, Multi from tortoise.exceptions import IncompleteInstanceError, IntegrityError, OperationalError @@ -240,6 +240,16 @@ async def on_download_json(callback: CallbackQuery, button: Button, dialog_manag await callback.answer("❌ Ошибка при выгрузке JSON", show_alert=True) +async def on_view_inventory(callback: CallbackQuery, button: Button, dialog_manager: DialogManager): + await dialog_manager.start( + state=states.ManageInventory.view_inventory, + data={ + "character_id": dialog_manager.dialog_data["character_id"], + "campaign_id": dialog_manager.dialog_data["campaign_id"], + }, + ) + + # === Окна === character_selection_window = Window( Multi( @@ -292,10 +302,10 @@ async def on_download_json(callback: CallbackQuery, button: Button, dialog_manag id="download_json", on_click=on_download_json, ), - Start( + Button( Const("🎒 Управление инвентарем"), id="manage_inventory", - state=states.ManageInventory.view_inventory, + on_click=on_view_inventory, ), width=2, ), diff --git a/handlers/admin/inventory_management.py b/handlers/admin/inventory_management.py new file mode 100644 index 0000000..0f2ae8b --- /dev/null +++ b/handlers/admin/inventory_management.py @@ -0,0 +1,383 @@ +import logging +from uuid import UUID + +from aiogram import Router +from aiogram.types import CallbackQuery, Message +from aiogram_dialog import Dialog, DialogManager, Window +from aiogram_dialog.widgets.input import ManagedTextInput, TextInput +from aiogram_dialog.widgets.kbd import Button, Cancel, Group, Row, ScrollingGroup, Select, SwitchTo +from aiogram_dialog.widgets.text import Const, Format, Multi +from tortoise.exceptions import OperationalError + +from db.models.character import Character +from db.models.item import Item +from db.models.user import User + +from . import states + +logger = logging.getLogger(__name__) + +# === Константы === +MAX_QUANTITY_ITEM = 1000 + + +# === Гетеры === +async def get_character_inventory(dialog_manager: DialogManager, **kwargs): + """Получение инвентаря персонажа""" + if "character_id" not in dialog_manager.dialog_data and isinstance(dialog_manager.start_data, dict): + dialog_manager.dialog_data["campaign_id"] = dialog_manager.start_data["campaign_id"] + dialog_manager.dialog_data["character_id"] = dialog_manager.start_data["character_id"] + + character_id: int | UUID = dialog_manager.dialog_data["character_id"] + campaign_id = dialog_manager.dialog_data["campaign_id"] + + if isinstance(character_id, int): + items = await Item.filter(holder_user=character_id, campaign_id=campaign_id).all() + else: + items = await Item.filter(holder_character=character_id, campaign_id=campaign_id).all() + + logger.debug("inventory from %s: %s", character_id, items) + + return {"inventory": items, "has_items": len(items) > 0} + + +async def get_inventory_item_data(dialog_manager: DialogManager, **kwargs): + """Получение данных о выбранном предмете""" + item_id = dialog_manager.dialog_data.get("selected_item_id") + if not item_id: + return {"item": None} + + item = await Item.get(id=item_id) + return {"item": item, "has_description": item.description != ""} + + +async def get_item_info(dialog_manager: DialogManager, **kwargs): + return dialog_manager.dialog_data + + +# === Кнопки (обработчики) === +async def on_inventory_item_selected( + callback: CallbackQuery, widget: Select, dialog_manager: DialogManager, item_id: UUID +): + """Обработчик выбора предмета из инвентаря""" + dialog_manager.dialog_data["selected_item_id"] = item_id + await dialog_manager.switch_to(states.ManageInventory.edit_inventory_item) + + +async def on_item_name_input(message: Message, widget: ManagedTextInput, dialog_manager: DialogManager, text: str): + """Обработчик ввода названия предмета""" + if not text.strip(): + await message.answer("❌ Название не может быть пустым") + return + + dialog_manager.dialog_data["new_item_name"] = text.strip() + await dialog_manager.switch_to(states.ManageInventory.add_inventory_item_description) + + +async def on_item_description_input( + message: Message, widget: ManagedTextInput, dialog_manager: DialogManager, text: str +): + """Обработчик ввода описания предмета""" + description = text.strip() if text.strip() != "-" else "" + dialog_manager.dialog_data["new_item_description"] = description + await dialog_manager.switch_to(states.ManageInventory.add_inventory_item_quantity) + + +async def on_item_quantity_input(message: Message, widget: ManagedTextInput, dialog_manager: DialogManager, text: str): + """Обработчик ввода количества предмета""" + try: + quantity = int(text) if text.strip() else 1 + if quantity <= 0: + await message.answer("❌ Количество должно быть положительным числом") + return + if quantity > MAX_QUANTITY_ITEM: + await message.answer(f"❌ Количество не может превышать {MAX_QUANTITY_ITEM}") + return + except ValueError: + await message.answer("❌ Пожалуйста, введите целое число") + return + + # Создаем и сохраняем предмет + character_id = dialog_manager.dialog_data.get("character_id") + campaign_id = dialog_manager.dialog_data.get("campaign_id") + + try: + holder: dict[str, User | Character] + if isinstance(character_id, int): + holder = {"holder_user": await User.get(id=character_id)} + else: + holder = {"holder_character": await Character.get(id=character_id)} + + item = await Item.create( + title=dialog_manager.dialog_data["new_item_name"], + description=dialog_manager.dialog_data.get("new_item_description", ""), + quantity=quantity, + campaign_id=campaign_id, + **holder, # type: ignore # noqa: PGH003 + ) + + await message.answer(f"✅ Предмет '{item.title}' успешно добавлен!") + await dialog_manager.switch_to(states.ManageInventory.view_inventory) + + except Exception as e: + logger.exception("Error adding inventory item", exc_info=e) + await message.answer("❌ Ошибка при добавлении предмета") + + +async def on_edit_item_name(message: Message, widget: ManagedTextInput, dialog_manager: DialogManager, text: str): + """Обработчик изменения названия предмета""" + if not text.strip(): + await message.answer("❌ Название не может быть пустым") + return + + item_id = dialog_manager.dialog_data.get("selected_item_id") + + try: + item = await Item.get(id=item_id) + item.title = text.strip() + await item.save() + + await message.answer(f"✅ Название изменено на: {text.strip()}") + await dialog_manager.switch_to(states.ManageInventory.view_inventory) + + except Exception as e: + logger.exception("Error updating item name: e", exc_info=e) + await message.answer("❌ Ошибка при изменении названия") + + +async def on_edit_item_description( + message: Message, widget: ManagedTextInput, dialog_manager: DialogManager, text: str +): + """Обработчик изменения описания предмета""" + description = text.strip() if text.strip() != "-" else "" + item_id = dialog_manager.dialog_data.get("selected_item_id") + + try: + item = await Item.get(id=item_id) + item.description = description + await item.save() + + await message.answer("✅ Описание изменено") + await dialog_manager.switch_to(states.ManageInventory.view_inventory) + + except Exception as e: + logger.exception("Error updating item description: ", exc_info=e) + await message.answer("❌ Ошибка при изменении описания") + + +async def on_edit_item_quantity(message: Message, widget: ManagedTextInput, dialog_manager: DialogManager, text: str): + """Обработчик изменения количества предмета""" + try: + quantity = int(text) + if quantity <= 0: + await message.answer("❌ Количество должно быть положительным числом") + return + if quantity > MAX_QUANTITY_ITEM: + await message.answer("❌ Количество не может превышать 1000") + return + except ValueError: + await message.answer("❌ Пожалуйста, введите целое число") + return + + item_id = dialog_manager.dialog_data.get("selected_item_id") + + try: + item = await Item.get(id=item_id) + item.quantity = quantity + await item.save() + + await message.answer(f"✅ Количество изменено на: {quantity}") + await dialog_manager.switch_to(states.ManageInventory.view_inventory) + + except Exception as e: + logger.exception("Error updating item quantity", exc_info=e) + await message.answer("❌ Ошибка при изменении количества") + + +async def on_delete_inventory_item(callback: CallbackQuery, button: Button, dialog_manager: DialogManager): + """Обработчик удаления предмета""" + item_id = dialog_manager.dialog_data["selected_item_id"] + + try: + item = await Item.get(id=item_id) + item_title = item.title + await item.delete() + + await callback.answer(f"✅ Предмет '{item_title}' удален", show_alert=True) + await dialog_manager.switch_to(states.ManageInventory.view_inventory) + + except OperationalError as e: + logger.exception("Error deleting inventory item", exc_info=e) + await callback.answer("❌ Ошибка при удалении предмета", show_alert=True) + + +# === Окна === +view_inventory_window = Window( + Multi( + Const("🎒 Инвентарь персонажа"), + Const(""), + Const("Выберите предмет для редактирования:"), + Const("В инвентаре пока нет предметов", when=lambda data, *_: not data.get("has_items", False)), + sep="\n", + ), + ScrollingGroup( + Select( + Format("{item.title} ×{item.quantity}"), + id="inventory_select", + item_id_getter=lambda item: item.id, + items="inventory", + on_click=on_inventory_item_selected, + type_factory=UUID, + ), + id="inventory_scroll", + width=1, + height=10, + hide_on_single_page=True, + when="has_items", + ), + Row( + SwitchTo( + Const("➕ Добавить предмет"), + id="add_item", + state=states.ManageInventory.add_inventory_item, + ), + Cancel(Const("⬅️ Назад")), + ), + state=states.ManageInventory.view_inventory, + getter=get_character_inventory, +) + +add_inventory_item_window = Window( + Const("➕ Добавление нового предмета\n\nВведите название предмета:"), + TextInput( + id="item_name_input", + on_success=on_item_name_input, + ), + SwitchTo(Const("❌ Отмена"), id="cns_inv", state=states.ManageInventory.view_inventory), + state=states.ManageInventory.add_inventory_item, +) + +add_item_description_window = Window( + Format("Название {new_item_name}"), + Const("📝 Введите описание предмета (или '-' чтобы пропустить):"), + TextInput( + id="item_description_input", + on_success=on_item_description_input, + ), + SwitchTo(Const("❌ Отмена"), id="cns_inv", state=states.ManageInventory.view_inventory), + state=states.ManageInventory.add_inventory_item_description, + getter=get_item_info, +) + +add_item_quantity_window = Window( + Format("Название: {new_item_name}"), + Format("Описание {new_item_description}"), + Const("🔢 Введите количество предмета:"), + TextInput( + id="item_quantity_input", + on_success=on_item_quantity_input, + ), + SwitchTo(Const("❌ Отмена"), id="cns_inv", state=states.ManageInventory.view_inventory), + getter=get_item_info, + state=states.ManageInventory.add_inventory_item_quantity, +) + +edit_inventory_item_window = Window( + Multi( + Const("✏️ Редактирование предмета"), + Format("📦 {item.title}"), + Format("📝 Описание: {item.description}", when="has_description"), + Const("📝 Описание отсутствует", when=lambda data, *_: not data.get("item", {}).description), + Format("🔢 Количество: {item.quantity}"), + Const(""), + Const("Выберите что изменить:"), + sep="\n", + ), + Group( + SwitchTo( + Const("✏️ Название"), + id="edit_name", + state=states.ManageInventory.edit_inventory_item_name, + ), + SwitchTo( + Const("📝 Описание"), + id="edit_description", + state=states.ManageInventory.edit_inventory_item_description, + ), + SwitchTo( + Const("🔢 Количество"), + id="edit_quantity", + state=states.ManageInventory.edit_inventory_item_quantity, + ), + SwitchTo( + Const("🗑️ Удалить"), + id="delete_item", + state=states.ManageInventory.accept_delete, + ), + ), + SwitchTo(Const("⬅️ Назад"), id="cns_inv", state=states.ManageInventory.view_inventory), + state=states.ManageInventory.edit_inventory_item, + getter=get_inventory_item_data, +) + +edit_item_name_window = Window( + Format("📦 Текущие название: {item.title}"), + Const("✏️ Введите новое название предмета:"), + TextInput( + id="edit_name_input", + on_success=on_edit_item_name, + ), + SwitchTo(Const("⬅️ Назад"), id="ens_inv", state=states.ManageInventory.edit_inventory_item), + getter=get_inventory_item_data, + state=states.ManageInventory.edit_inventory_item_name, +) + +edit_item_description_window = Window( + Format("📜 Текущие описание: {item.description}", when="has_description"), + Const("📝 Введите новое описание предмета (или '-' чтобы очистить):"), + TextInput( + id="edit_description_input", + on_success=on_edit_item_description, + ), + SwitchTo(Const("⬅️ Назад"), id="ens_inv", state=states.ManageInventory.edit_inventory_item), + getter=get_inventory_item_data, + state=states.ManageInventory.edit_inventory_item_description, +) + +edit_item_quantity_window = Window( + Format("ℹ️ Текущее количество: {item.quantity}"), + Const("🔢 Введите новое количество предмета:"), + TextInput( + id="edit_quantity_input", + on_success=on_edit_item_quantity, + ), + getter=get_inventory_item_data, + state=states.ManageInventory.edit_inventory_item_quantity, +) + +accept_delete_item_window = Window( + Const("🎯 Точно удалить?"), + Button( + Const("🚫 Удалить"), + id="accept_delete", + on_click=on_delete_inventory_item, + ), + SwitchTo(Const("⬅️ Назад"), id="ens_inv", state=states.ManageInventory.edit_inventory_item), + state=states.ManageInventory.accept_delete, +) + +# === Диалог и роутер === +dialog = Dialog( + view_inventory_window, + add_inventory_item_window, + add_item_description_window, + add_item_quantity_window, + edit_inventory_item_window, + edit_item_name_window, + edit_item_description_window, + edit_item_quantity_window, + accept_delete_item_window, +) + +router = Router() +router.include_router(dialog) diff --git a/handlers/admin/invitation.py b/handlers/admin/invitation.py index c401d1b..60e1723 100644 --- a/handlers/admin/invitation.py +++ b/handlers/admin/invitation.py @@ -41,10 +41,11 @@ async def get_link(dialog_manager: DialogManager, **kwargs): campaign = await Campaign.get(id=campaign_id) invite = await Invitation.create(campaign=campaign, role=role, created_by=created_by) - dialog_manager.dialog_data["link"] = await generate_link(invite) + link: str = await generate_link(invite) + dialog_manager.dialog_data["link"] = link dialog_manager.dialog_data["invite_id"] = invite.id - return {"link": dialog_manager.dialog_data["link"]} + return {"link": link} async def get_qr(dialog_manager: DialogManager, **kwargs): diff --git a/services/user.py b/services/user.py index cb6b274..14eb344 100644 --- a/services/user.py +++ b/services/user.py @@ -1,6 +1,7 @@ from aiogram import types from db.models import User +from services.settings import settings async def get_or_create_user(user: types.User): @@ -8,6 +9,7 @@ async def get_or_create_user(user: types.User): id=user.id, defaults={ "id": user.id, + "admin": user.id in settings.ADMIN_IDS, "username": user.username if user.username else None, }, ) From 9d2a6d68054e8e7b7c85aa3ae84a9380417c4b9f Mon Sep 17 00:00:00 2001 From: "@m.danilin" Date: Wed, 10 Dec 2025 00:38:22 +0300 Subject: [PATCH 03/10] =?UTF-8?q?fix=20=D1=85=D1=83=D0=B9=D0=BD=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handlers/admin/character_management.py | 1 - services/invitation.py | 15 ++++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/handlers/admin/character_management.py b/handlers/admin/character_management.py index a39bfda..adb6b85 100644 --- a/handlers/admin/character_management.py +++ b/handlers/admin/character_management.py @@ -15,7 +15,6 @@ Row, ScrollingGroup, Select, - Start, SwitchTo, Url, ) diff --git a/services/invitation.py b/services/invitation.py index b1e9894..dbddaa9 100644 --- a/services/invitation.py +++ b/services/invitation.py @@ -14,13 +14,14 @@ async def invitation_getter(dialog_manager: DialogManager, **kwargs): invite = await Invitation.get_or_none(id=get_invite_id(dialog_manager)).prefetch_related("campaign") - if isinstance(bot, Bot): - bot_name = (await bot.get_me()).username - else: - msg = "bot is not specified" - raise TypeError(msg) - - return f"https://t.me/{bot_name}?start={invitation.start_data}" + if invite is None: + msg = "Invitation not found" + raise ValueError(msg) + + return { + "campaign_title": invite.campaign.title, + "role": invite.role.name, + } async def handle_accept_invitation(m: DialogManager, callback: CallbackQuery, user: User, invitation: Invitation): From fcc893c47c80350d65ec11adeb6c3563718ab09b Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 10 Dec 2025 00:50:04 +0300 Subject: [PATCH 04/10] MAX_ITEM_QUANTITY --- handlers/admin/inventory_management.py | 10 ++++------ services/settings.py | 3 +++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/handlers/admin/inventory_management.py b/handlers/admin/inventory_management.py index 0f2ae8b..b60983c 100644 --- a/handlers/admin/inventory_management.py +++ b/handlers/admin/inventory_management.py @@ -12,14 +12,12 @@ from db.models.character import Character from db.models.item import Item from db.models.user import User +from services.settings import settings from . import states logger = logging.getLogger(__name__) -# === Константы === -MAX_QUANTITY_ITEM = 1000 - # === Гетеры === async def get_character_inventory(dialog_manager: DialogManager, **kwargs): @@ -90,8 +88,8 @@ async def on_item_quantity_input(message: Message, widget: ManagedTextInput, dia if quantity <= 0: await message.answer("❌ Количество должно быть положительным числом") return - if quantity > MAX_QUANTITY_ITEM: - await message.answer(f"❌ Количество не может превышать {MAX_QUANTITY_ITEM}") + if quantity > settings.MAX_ITEM_QUANTITY: + await message.answer(f"❌ Количество не может превышать {settings.MAX_ITEM_QUANTITY}") return except ValueError: await message.answer("❌ Пожалуйста, введите целое число") @@ -172,7 +170,7 @@ async def on_edit_item_quantity(message: Message, widget: ManagedTextInput, dial if quantity <= 0: await message.answer("❌ Количество должно быть положительным числом") return - if quantity > MAX_QUANTITY_ITEM: + if quantity > settings.MAX_ITEM_QUANTITY: await message.answer("❌ Количество не может превышать 1000") return except ValueError: diff --git a/services/settings.py b/services/settings.py index 3f73a92..524d418 100644 --- a/services/settings.py +++ b/services/settings.py @@ -24,6 +24,9 @@ class Settings(BaseSettings): TOKEN_PLAYER: str = "" ADMIN_IDS: set[int] = set() + # ADMIN + MAX_ITEM_QUANTITY: int = 1000 + # ^ PostgreSQL DB_HOST: str = "db" DB_PORT: int = 5432 From 4d9fdbe55e191600f4444ecc919de5bace140fbf Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 10 Dec 2025 00:52:33 +0300 Subject: [PATCH 05/10] max_title_len and max_desc_len --- handlers/admin/create_campaign.py | 9 +++------ handlers/admin/edit_campaign.py | 9 +++------ services/settings.py | 2 ++ 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/handlers/admin/create_campaign.py b/handlers/admin/create_campaign.py index 7e72755..89b2364 100644 --- a/handlers/admin/create_campaign.py +++ b/handlers/admin/create_campaign.py @@ -14,6 +14,7 @@ from db.models.campaign import Campaign from db.models.participation import Participation +from services.settings import settings from utils.role import Role from . import states @@ -23,10 +24,6 @@ logger = logging.getLogger(__name__) -# === Константы === -MAX_TITLE_LEN = 255 -MAX_DESCRIPTION_LEN = 1023 - # === Гетеры === async def get_confirm_data(dialog_manager: DialogManager, **kwargs): @@ -48,7 +45,7 @@ async def on_title_entered( dialog_manager: DialogManager, text: str, ): - if len(text) > MAX_TITLE_LEN: + if len(text) > settings.MAX_TITLE_LEN: await mes.answer("Максимум 255 символов") return dialog_manager.dialog_data["title"] = text @@ -61,7 +58,7 @@ async def on_description_entered( dialog_manager: DialogManager, text: str, ): - if len(text) > MAX_DESCRIPTION_LEN: + if len(text) > settings.MAX_DESCRIPTION_LEN: mes.answer("Максимум 1023 символа, можно пропустить") return dialog_manager.dialog_data["description"] = text diff --git a/handlers/admin/edit_campaign.py b/handlers/admin/edit_campaign.py index 53eb41e..2d3cdf4 100644 --- a/handlers/admin/edit_campaign.py +++ b/handlers/admin/edit_campaign.py @@ -13,16 +13,13 @@ from db.models.campaign import Campaign from db.models.participation import Participation +from services.settings import settings from utils.role import Role from . import states logger = logging.getLogger(__name__) -# === Константы === -MAX_TITLE_LEN = 255 -MAX_DESCRIPTION_LEN = 1023 - # === Гетеры === async def get_campaign_edit_data(dialog_manager: DialogManager, **kwargs): @@ -68,7 +65,7 @@ async def on_title_edited( dialog_manager: DialogManager, text: str, ): - if len(text) > MAX_TITLE_LEN: + if len(text) > settings.MAX_TITLE_LEN: await mes.answer("Название слишком длинное (максимум 255 символов)") return @@ -83,7 +80,7 @@ async def on_description_edited( dialog_manager: DialogManager, text: str, ): - if len(text) > MAX_DESCRIPTION_LEN: + if len(text) > settings.MAX_DESCRIPTION_LEN: await mes.answer("Описание слишком длинное (максимум 1023 символа)") return diff --git a/services/settings.py b/services/settings.py index 524d418..4fdf8f2 100644 --- a/services/settings.py +++ b/services/settings.py @@ -25,6 +25,8 @@ class Settings(BaseSettings): ADMIN_IDS: set[int] = set() # ADMIN + MAX_TITLE_LEN: int = 255 + MAX_DESCRIPTION_LEN: int = 1023 MAX_ITEM_QUANTITY: int = 1000 # ^ PostgreSQL From b07b9e15fceabfaf973b709a3245768eb559b972 Mon Sep 17 00:00:00 2001 From: Igor Date: Wed, 10 Dec 2025 00:54:54 +0300 Subject: [PATCH 06/10] max_level and max_rating --- handlers/admin/character_management.py | 16 ++++++---------- services/settings.py | 2 ++ 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/handlers/admin/character_management.py b/handlers/admin/character_management.py index adb6b85..da8777c 100644 --- a/handlers/admin/character_management.py +++ b/handlers/admin/character_management.py @@ -27,6 +27,7 @@ from db.models.participation import Participation from db.models.user import User from services.character_data import character_preview_getter +from services.settings import settings from utils.character import parse_character_data from utils.role import Role @@ -41,11 +42,6 @@ logger = logging.getLogger(__name__) -# === Константы === -MAX_LEVEL = 20 # D&D максимальный уровень -MAX_RATING = 1000 - - # === Гетеры === async def get_character_data(id_: int | UUID) -> User | Character: if isinstance(id_, int): @@ -149,7 +145,7 @@ async def on_quick_rating_change(callback: CallbackQuery, widget: Button, dialog new_rating = user.rating + change new_rating = max(new_rating, 0) - new_rating = min(new_rating, MAX_RATING) + new_rating = min(new_rating, settings.MAX_RATING) user.rating = new_rating await user.save() @@ -169,7 +165,7 @@ async def on_rating_input(message: Message, widget: ManagedTextInput, dialog_man user = await User.get(id=character_id) - rating = min(max(0, rating), MAX_RATING) + rating = min(max(0, rating), settings.MAX_RATING) user.rating = rating await user.save() @@ -193,8 +189,8 @@ async def on_level_input(message: Message, widget: ManagedTextInput, dialog_mana if level < 1: await message.answer("❌ Уровень должен быть не меньше 1") return - if level > MAX_LEVEL: - await message.answer(f"❌ Уровень не может превышать {MAX_LEVEL}") + if level > settings.MAX_LEVEL: + await message.answer(f"❌ Уровень не может превышать {settings.MAX_LEVEL}") return character = await get_character_data(character_id) @@ -331,7 +327,7 @@ async def on_view_inventory(callback: CallbackQuery, button: Button, dialog_mana ) change_level_window = Window( - Const(f"📈 Введите новый уровень персонажа (1-{MAX_LEVEL}):"), + Const(f"📈 Введите новый уровень персонажа (1-{settings.MAX_LEVEL}):"), Format("Сейчас установлен уровень: {level}"), TextInput( id="level_input", diff --git a/services/settings.py b/services/settings.py index 4fdf8f2..6dbaf30 100644 --- a/services/settings.py +++ b/services/settings.py @@ -27,6 +27,8 @@ class Settings(BaseSettings): # ADMIN MAX_TITLE_LEN: int = 255 MAX_DESCRIPTION_LEN: int = 1023 + MAX_LEVEL: int = 20 # D&D максимальный уровень + MAX_RATING: int = 1000 MAX_ITEM_QUANTITY: int = 1000 # ^ PostgreSQL From 5190e1dddef67047dec2a486e2c2011539969a3e Mon Sep 17 00:00:00 2001 From: "@m.danilin" Date: Wed, 10 Dec 2025 05:36:25 +0300 Subject: [PATCH 07/10] checking dialogs --- handlers/admin/campaign_list.py | 4 +-- handlers/admin/campaign_manage.py | 8 +++--- handlers/admin/character_management.py | 14 +++++----- handlers/admin/create_campaign.py | 10 +++---- handlers/admin/edit_campaign.py | 16 ++++++------ handlers/admin/inventory_management.py | 2 +- handlers/admin/invitation.py | 20 +++++++------- handlers/admin/manage_master.py | 6 +++-- handlers/admin/start.py | 36 +++++++++++++++++++++----- 9 files changed, 71 insertions(+), 45 deletions(-) diff --git a/handlers/admin/campaign_list.py b/handlers/admin/campaign_list.py index 1c2804d..3aa5f3a 100644 --- a/handlers/admin/campaign_list.py +++ b/handlers/admin/campaign_list.py @@ -44,9 +44,9 @@ async def on_campaign_selected( # === Окна === campaign_list_window = Window( - Const("🏰 Магическая Академия - Ваши кампейны\n\n"), + Const("🏰 Ваши кампании\n\n"), Const( - "У вас пока нет доступных партий", + "У вас пока нет доступных кампаний", when=lambda data, widget, dialog_manager: not data.get("has_campaigns", False), ), ScrollingGroup( diff --git a/handlers/admin/campaign_manage.py b/handlers/admin/campaign_manage.py index 27459e8..ee16e4a 100644 --- a/handlers/admin/campaign_manage.py +++ b/handlers/admin/campaign_manage.py @@ -95,10 +95,10 @@ async def on_stats( # === Окна === campaign_manage_window = Window( DynamicMedia("icon"), - Format("🎓 Управление: {campaign_title}\n\nОписание: {campaign_description}\nВыберите действие:"), + Format("🎓 Управление кампанией: {campaign_title}\n\nОписание: {campaign_description}\n\nВыберите действие:"), Group( Button( - Const("✏️ Управление кампанией"), + Const("⚙️ Настройки кампании"), id="edit_info", on_click=on_edit_info, ), @@ -108,14 +108,14 @@ async def on_stats( on_click=on_manage_characters, ), Button( - Const("🧙‍♂️ Управление мастерами"), + Const("👑 Управление мастерами"), id="permissions", on_click=on_permissions, when="is_owner", ), width=1, ), - Cancel(Const("⬅️ Назад")), + Cancel(Const("⬅️ Назад к списку")), state=states.CampaignManage.main, getter=get_campaign_manage_data, ) diff --git a/handlers/admin/character_management.py b/handlers/admin/character_management.py index da8777c..afb7ec2 100644 --- a/handlers/admin/character_management.py +++ b/handlers/admin/character_management.py @@ -194,7 +194,7 @@ async def on_level_input(message: Message, widget: ManagedTextInput, dialog_mana return character = await get_character_data(character_id) - character_data = character.data + character_data = character.data or {} character_data["data"] = character_data.get("data", "") @@ -205,17 +205,15 @@ async def on_level_input(message: Message, widget: ManagedTextInput, dialog_mana character_data["data"] = json.dumps(new_data) - character.data = character_data await character.save() - - await message.answer(f"✅ Уровень изменен на {level}") + await message.answer(f"✅ Уровень персонажа изменен на {level}") await dialog_manager.switch_to(states.ManageCharacters.character_menu) except ValueError: - await message.answer("❌ Введите целое число") - except (IncompleteInstanceError, IntegrityError, OperationalError) as e: - logger.exception("Error updating level", exc_info=e) - await message.answer("❌ Ошибка при обновлении уровня") + await message.answer("❌ Пожалуйста, введите целое число") + except Exception as e: + logger.exception("Ошибка при изменении уровня персонажа", exc_info=e) + await message.answer("❌ Не удалось изменить уровень") async def on_add_character(mes: CallbackQuery, wif: Button, dialog_manager: DialogManager): diff --git a/handlers/admin/create_campaign.py b/handlers/admin/create_campaign.py index 89b2364..f40a9f6 100644 --- a/handlers/admin/create_campaign.py +++ b/handlers/admin/create_campaign.py @@ -111,7 +111,7 @@ async def on_cancel(mes: CallbackQuery, button: Button, dialog_manager: DialogMa # === Окна === title_window = Window( - Const("🏰 Создание компейна\n\nВведите название:\n(максимум 255 символов)"), + Const("🏰 Создание новой кампании\n\nВведите название кампании:\n(максимум 255 символов)"), TextInput( id="title_input", on_success=on_title_entered, @@ -122,7 +122,7 @@ async def on_cancel(mes: CallbackQuery, button: Button, dialog_manager: DialogMa description_window = Window( Multi( - Const("📝 Теперь введите описание:\n"), + Const("📝 Теперь введите описание кампании:\n"), Format("Название: {title}\n"), Const("(максимум 1023 символа, можно пропустить)"), ), @@ -141,7 +141,7 @@ async def on_cancel(mes: CallbackQuery, button: Button, dialog_manager: DialogMa icon_window = Window( Multi( - Const("🎨 Загрузите иконку для вашей:\n"), + Const("🎨 Загрузите иконку для вашей кампании:\n"), Format("Название: {title}\n"), Format("Описание: {description}\n\n"), Const("Отправьте изображение как фото (не файлом)"), @@ -159,14 +159,14 @@ async def on_cancel(mes: CallbackQuery, button: Button, dialog_manager: DialogMa confirm_window = Window( DynamicMedia("icon"), Multi( - Const("✅ Проверьте данные нового кампейна:\n\n"), + Const("✅ Проверьте данные нового кампании:\n\n"), Format("📝 Название: {title}"), Format("📄 Описание: {description}"), Const("Всё верно?"), sep="\n", ), Button( - Const("✅ Создать группу"), + Const("✅ Создать кампанию"), id="confirm_create", on_click=on_confirm, ), diff --git a/handlers/admin/edit_campaign.py b/handlers/admin/edit_campaign.py index 2d3cdf4..a1c52a1 100644 --- a/handlers/admin/edit_campaign.py +++ b/handlers/admin/edit_campaign.py @@ -137,18 +137,18 @@ async def on_remove_campaign(callback: CallbackQuery, button: Button, dialog_man select_field_window = Window( DynamicMedia("icon"), Multi( - Format("✏️ Редактирование: {campaign_title}"), - Format("📜 Описание: {campaign_description}"), - Const("Выберите что хотите изменить:"), + Format("⚙️ Настройки кампании: {campaign_title}"), + Format("📄 Описание: {campaign_description}"), + Const("\nВыберите что хотите изменить:"), ), Column( - Button(Const("📝 Название"), id="title", on_click=on_field_selected), + Button(Const("✏️ Изменить название"), id="title", on_click=on_field_selected), Button( - Const("📄 Описание"), + Const("📝 Изменить описание"), id="description", on_click=on_field_selected, ), - Button(Const("🎨 Иконка"), id="icon", on_click=on_field_selected), + Button(Const("🎨 Изменить иконку"), id="icon", on_click=on_field_selected), Button( Const("🗑️ Удаление кампании"), id="delete", @@ -187,7 +187,7 @@ async def on_remove_campaign(callback: CallbackQuery, button: Button, dialog_man ) edit_icon_window = Window( - Const("🎨 Загрузите иконку для вашей:\nОтправьте изображение как фото (не файлом)"), + Const("🎨 Загрузите иконку для вашей кампании:\nОтправьте изображение как фото (не файлом)"), MessageInput(func=on_icon_entered, content_types=ContentType.PHOTO), SwitchTo( Const("⬅️ Назад"), @@ -218,7 +218,7 @@ async def on_remove_campaign(callback: CallbackQuery, button: Button, dialog_man getter=get_campaign_edit_data, ) confirm_delete_window = Window( - Format("🎯 Вы точно хотите удалить {campaign_title}\n ЭТО ДЕЙСТВИЕ НЕ ОТМЕНИТЬ"), + Format("⚠️ Вы точно хотите удалить кампанию?\n\n{campaign_title}\n\nЭто действие нельзя отменить!"), Button(Const("🚫 Удалить кампанию"), id="remove_campaign", on_click=on_remove_campaign), SwitchTo( Const("⬅️ Отмена"), diff --git a/handlers/admin/inventory_management.py b/handlers/admin/inventory_management.py index b60983c..cacf9e8 100644 --- a/handlers/admin/inventory_management.py +++ b/handlers/admin/inventory_management.py @@ -215,7 +215,7 @@ async def on_delete_inventory_item(callback: CallbackQuery, button: Button, dial Const("🎒 Инвентарь персонажа"), Const(""), Const("Выберите предмет для редактирования:"), - Const("В инвентаре пока нет предметов", when=lambda data, *_: not data.get("has_items", False)), + Const("📭 В инвентаре пока нет предметов", when=lambda data, *_: not data.get("has_items", False)), sep="\n", ), ScrollingGroup( diff --git a/handlers/admin/invitation.py b/handlers/admin/invitation.py index 6f4ff3c..fb559bb 100644 --- a/handlers/admin/invitation.py +++ b/handlers/admin/invitation.py @@ -9,7 +9,7 @@ from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Next from aiogram_dialog.widgets.link_preview import LinkPreview from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Invitation from db.models.campaign import Campaign @@ -145,13 +145,15 @@ async def on_accept(c: CallbackQuery, _: Button, m: DialogManager): # === Окна === invite_menu_window = Window( - Format( - "Отправьте эту ссылку для приглашения: {link}\n" - "Или напишите @username гостя здесь\n" - "(учтите 1 ссылка – 1 приглашение)" + Multi( + Const("✉️ Приглашение в кампанию\n"), + Format("\nСсылка для приглашения: {link}"), + Const("\nИли введите @username пользователя ниже"), + Const("(каждая ссылка работает только один раз)"), + sep="\n", ), LinkPreview(is_disabled=False), - Button(Const("Сгенерировать новую ссылку"), id="regenerate_link", on_click=on_regenerate_link), + Button(Const("🔄 Сгенерировать новую ссылку"), id="regenerate_link", on_click=on_regenerate_link), TextInput( id="username_input", on_success=on_username_entered, @@ -172,9 +174,9 @@ async def on_accept(c: CallbackQuery, _: Button, m: DialogManager): invite_window = Window( - Format("Вас пригласили в кампанию {campaign_title} на роль {role}"), - Button(Const("Присоединиться"), id="accept_admin", on_click=on_accept), - Cancel(Const("Отказаться")), + Format("🎉 Вас пригласили в кампанию!\n\n{campaign_title}\nРоль: {role}"), + Button(Const("✅ Присоединиться"), id="accept_admin", on_click=on_accept), + Cancel(Const("❌ Отказаться")), getter=invitation_getter, state=states.InviteMenu.invite, ) diff --git a/handlers/admin/manage_master.py b/handlers/admin/manage_master.py index 377ab3f..ee6b7b9 100644 --- a/handlers/admin/manage_master.py +++ b/handlers/admin/manage_master.py @@ -92,7 +92,8 @@ async def on_add_master(mes: CallbackQuery, wid: Button, dialog_manager: DialogM # === Окна === permissions_main_window = Window( Multi( - Format("🧙‍♂️ Управление мастерами: {campaign.title}\n"), + Format("👑 Управление мастерами: {campaign.title}\n"), + Const("Мастера могут управлять персонажами в этой кампании"), ), ScrollingGroup( Select( @@ -118,7 +119,8 @@ async def on_add_master(mes: CallbackQuery, wid: Button, dialog_manager: DialogM ) select_permission_window = Window( - Format("🎯 Изменение доступа\n\nМастер: {username}\n"), + Format("👤 Мастер: @{username}\n"), + Const("\nВыберите действие:"), Button(Const("🚫 Удалить мастера"), id="remove_user", on_click=on_remove_user), SwitchTo( Const("⬅️ Назад к списку"), diff --git a/handlers/admin/start.py b/handlers/admin/start.py index 69d8c6a..6f28c01 100644 --- a/handlers/admin/start.py +++ b/handlers/admin/start.py @@ -18,9 +18,14 @@ async def start_args(message: Message, command: CommandObject, dialog_manager: DialogManager, user: User): if not command.args: return + if not is_valid_uuid(command.args): logger.warning("User %s used /start with invalid UUID: %s", user.id, command.args) + await message.reply( + "❌ Неверная ссылка приглашения.\n\nПожалуйста, убедитесь, что ссылка скопирована полностью и корректно." + ) return + invite = await Invitation.get_or_none(start_data=command.args).prefetch_related("user", "campaign") if not invite: logger.warning( @@ -28,7 +33,13 @@ async def start_args(message: Message, command: CommandObject, dialog_manager: D user.id, command.args, ) + await message.reply( + "❌ Приглашение не найдено.\n\n" + "Возможно, ссылка устарела или была отозвана. " + "Попросите мастера отправить новое приглашение." + ) return + if invite.user is None: invite.user = user await invite.save() @@ -39,14 +50,24 @@ async def start_args(message: Message, command: CommandObject, dialog_manager: D command.args, invite.user.id, ) + await message.reply( + "🔒 Это приглашение предназначено другому пользователю.\n\n" + "Каждое приглашение привязано к конкретному Telegram-аккаунту. " + "Попросите мастера отправить вам персональное приглашение." + ) return + logger.info("%s пригласили в игру %s на роль %s", invite.user.id, invite.campaign.id, invite.role.name) if invite.used: await message.reply( - "Сорян, этот инвайт уже был использован.\n\n" - "Если ты его использовал по ошибке, то попроси мастера пригласить тебя еще раз" + "⚠️ Это приглашение уже было использовано.\n\n" + "Если вы хотите присоединиться к кампании, попросите мастера " + "отправить вам новое приглашение.\n\n" + "Если вы уже в этой кампании, используйте обычный /start " + "для доступа к своим кампаниям." ) return + logger.debug( "Такой инвайт был найден. %s пригласили в игру %s на роль %s", invite.user.id, @@ -65,9 +86,13 @@ async def cmd_start(message: Message, dialog_manager: DialogManager): user: User = dialog_manager.middleware_data["user"] welcome_text = ( - f"Приветствую вас, Мастер {user.username}!\n\n" - "Я ваш верный помощник в организации настольных ролевых игр.\n" - "Давайте начнем наше приключение!" + f"👋 Добро пожаловать, {user.username or 'путник'}!\n\n" + "Я бот для организации настольных ролевых игр.\n" + "Здесь вы можете:\n" + "• Создавать кампании 🏰\n" + "• Управлять персонажами 👥\n" + "• Приглашать друзей в приключения ✨\n\n" + "Давайте начнем ваше приключение!" ) await message.answer(welcome_text) @@ -75,5 +100,4 @@ async def cmd_start(message: Message, dialog_manager: DialogManager): await dialog_manager.start( state=states.CampaignList.main, mode=StartMode.RESET_STACK, - data={"user_id": user.id}, ) From 01cd32b69f6bc772b265089ad064143dc416ec24 Mon Sep 17 00:00:00 2001 From: "@m.danilin" Date: Thu, 11 Dec 2025 19:53:00 +0300 Subject: [PATCH 08/10] fixed the work with invites, but there is one unpleasant bug. --- handlers/admin/campaign_list.py | 4 +- handlers/admin/character_management.py | 27 +++++++---- handlers/admin/invitation.py | 32 +++++++------ handlers/admin/start.py | 22 +++++++-- handlers/player/academy.py | 4 +- handlers/player/academy_campaigns.py | 7 ++- handlers/player/invitation.py | 65 +++++++++++++++----------- handlers/player/start.py | 41 ++++++++++++++-- services/invitation.py | 9 ++-- utils/redirect.py | 21 +++++++++ 10 files changed, 169 insertions(+), 63 deletions(-) create mode 100644 utils/redirect.py diff --git a/handlers/admin/campaign_list.py b/handlers/admin/campaign_list.py index 3aa5f3a..6f63e10 100644 --- a/handlers/admin/campaign_list.py +++ b/handlers/admin/campaign_list.py @@ -9,6 +9,7 @@ from aiogram_dialog.widgets.text import Const, Format from db.models.participation import Participation +from utils.redirect import redirect from . import states @@ -77,7 +78,8 @@ async def on_campaign_selected( getter=get_campaigns_data, ) + # === Создание диалога и роутера === -dialog = Dialog(campaign_list_window) +dialog = Dialog(campaign_list_window, on_start=redirect) router = Router() router.include_router(dialog) diff --git a/handlers/admin/character_management.py b/handlers/admin/character_management.py index afb7ec2..2b4cf28 100644 --- a/handlers/admin/character_management.py +++ b/handlers/admin/character_management.py @@ -28,6 +28,7 @@ from db.models.user import User from services.character_data import character_preview_getter from services.settings import settings +from utils.character import CharacterData as CharData from utils.character import parse_character_data from utils.role import Role @@ -69,15 +70,21 @@ async def get_characters_for_campaign(dialog_manager: DialogManager, **kwargs): for char in (await Character.filter(campaign=campaign).prefetch_related("user").all()) ] - characters_data = [ - (parse_character_data(json.loads(char.data["data"])), user, model_uuid) for char, user, model_uuid in characters - ] + characters_data: list[tuple[CharData, User, UuidModel]] = [] + player_without_characters = [] + for char, user, model_uuid in characters: + if char.data: + characters_data.append((parse_character_data(json.loads(char.data["data"])), user, model_uuid)) + else: + player_without_characters.append(user.username) return { "characters": characters_data, + "player_without_characters": " @" + ", @".join(player_without_characters), "campaign_title": campaign.title, "has_characters": len(characters_data) > 0, "is_verified": campaign.verified, + "has_player_without_characters": len(player_without_characters) > 0, } @@ -268,6 +275,9 @@ async def on_view_inventory(callback: CallbackQuery, button: Button, dialog_mana "В этой кампании нет персонажей", when=lambda data, *_: not data.get("has_characters", False), ), + Format( + "Игроки у которых ещё нет персонажей: {player_without_characters}", when="has_player_without_characters" + ), sep="\n", ), ScrollingGroup( @@ -290,15 +300,16 @@ async def on_view_inventory(callback: CallbackQuery, button: Button, dialog_mana character_detail_window = Window( DynamicMedia("avatar", when="avatar"), - Format("Игрок: @{user.username}"), - Format("Текущий рейтинг: {user.rating}", when="is_verified"), + Format("👤 Игрок: @{user.username}"), + Format("🏆 Текущий рейтинг: {user.rating}", when="is_verified"), Format("{character_data_preview}", when="character_data_preview"), - Url(Const("Перейти в профиль"), Format("{profile_link}")), + Url(Const("👤 Перейти в профиль"), Format("{profile_link}")), Group( SwitchTo( Const("📈 Изменить уровень"), id="change_level", state=states.ManageCharacters.change_level, + when="character_data_preview", ), SwitchTo( Const("🏆 Изменить рейтинг"), @@ -318,8 +329,8 @@ async def on_view_inventory(callback: CallbackQuery, button: Button, dialog_mana ), width=2, ), - Back(Const("⬅️ Назад к выбору персонажа")), - Cancel(Const("❌ Выход")), + Back(Const("⬅️ Назад")), + Cancel(Const("🏠 В главное меню")), state=states.ManageCharacters.character_menu, getter=preview_getter, ) diff --git a/handlers/admin/invitation.py b/handlers/admin/invitation.py index fb559bb..82376a4 100644 --- a/handlers/admin/invitation.py +++ b/handlers/admin/invitation.py @@ -3,7 +3,7 @@ from aiogram import Router from aiogram.enums import ContentType from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message -from aiogram_dialog import Dialog, DialogManager, Window +from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.api.entities import MediaAttachment from aiogram_dialog.widgets.input import ManagedTextInput, TextInput from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Next @@ -16,7 +16,6 @@ from db.models.user import User from services.invitation import handle_accept_invitation, invitation_getter from services.settings import settings -from states.academy_campaigns import AcademyCampaignPreview from utils.invitation import generate_link, generate_qr from utils.role import Role @@ -46,7 +45,7 @@ async def get_link(dialog_manager: DialogManager, **_): dialog_manager.dialog_data["link"] = link dialog_manager.dialog_data["invite_id"] = invite.id - return {"link": link} + return {"link": dialog_manager.dialog_data["link"]} async def get_qr(dialog_manager: DialogManager, **_): @@ -115,27 +114,32 @@ async def on_username_entered( await dialog_manager.done() -async def on_accept(c: CallbackQuery, _: Button, m: DialogManager): - invite_id = m.dialog_data.get("invite_id") +async def on_accept(msg: CallbackQuery, _: Button, dialog_manager: DialogManager): + invite_id = dialog_manager.dialog_data.get("invite_id") if not invite_id: - await c.answer("❌ Приглашение не найдено", show_alert=True) - await m.reset_stack() + await msg.answer("❌ Приглашение не найдено", show_alert=True) + await dialog_manager.reset_stack() return invite = await Invitation.get_or_none(id=invite_id).prefetch_related("campaign", "created_by") if invite is None: - await c.answer("❌ Приглашение не найдено", show_alert=True) - await m.reset_stack() + await msg.answer("❌ Приглашение не найдено", show_alert=True) + await dialog_manager.reset_stack() return - user = m.middleware_data["user"] + user = dialog_manager.middleware_data["user"] - participation = await handle_accept_invitation(m, c, user, invite) + participation = await handle_accept_invitation(dialog_manager, msg, user, invite) if invite.campaign.verified: - await m.start( - AcademyCampaignPreview.preview, - data={"campaign_id": invite.campaign.id, "participation_id": participation.id}, + await dialog_manager.start( + states.CampaignList.main, + data={ + "campaign_id": invite.campaign.id, + "participation_id": participation.id, + "redirect_to": states.CampaignManage.main, + }, + mode=StartMode.RESET_STACK, ) else: # TODO @pxc1984: когда доделаем другие игры следует сюда добавить логику активации игры для них diff --git a/handlers/admin/start.py b/handlers/admin/start.py index 6f28c01..b03db12 100644 --- a/handlers/admin/start.py +++ b/handlers/admin/start.py @@ -5,7 +5,7 @@ from aiogram.types import Message from aiogram_dialog import DialogManager, StartMode -from db.models import Invitation, User +from db.models import Invitation, Participation, User from utils.uuid import is_valid_uuid from . import states @@ -62,12 +62,21 @@ async def start_args(message: Message, command: CommandObject, dialog_manager: D await message.reply( "⚠️ Это приглашение уже было использовано.\n\n" "Если вы хотите присоединиться к кампании, попросите мастера " - "отправить вам новое приглашение.\n\n" - "Если вы уже в этой кампании, используйте обычный /start " - "для доступа к своим кампаниям." + "отправить вам новое приглашение." ) return + participation = await Participation.get_or_none(user=user, campaign=invite.campaign) + if participation is not None: + logger.info( + "User %s used /start in the %s campaign, where he was already invited. It was for %s.", + user.id, + command.args, + invite.user.id, + ) + await message.reply(f"🗳️ Вы уже участвуете в этой кампании в качестве {participation.role}") + return + logger.debug( "Такой инвайт был найден. %s пригласили в игру %s на роль %s", invite.user.id, @@ -78,7 +87,10 @@ async def start_args(message: Message, command: CommandObject, dialog_manager: D invite.used = True await invite.save() - await dialog_manager.start(states.InviteMenu.invite, data={"invitation_id": invite.id}) + await dialog_manager.start( + states.InviteMenu.invite, + data={"invitation_id": invite.id, "campaign_id": invite.campaign.id}, + ) @router.message(CommandStart(deep_link=False)) diff --git a/handlers/player/academy.py b/handlers/player/academy.py index 7d36908..0792428 100644 --- a/handlers/player/academy.py +++ b/handlers/player/academy.py @@ -13,6 +13,7 @@ from states.academy_campaigns import AcademyCampaigns from states.rating import AcademyRating from states.upload_character import UploadCharacter +from utils.redirect import redirect logger = logging.getLogger(__name__) router = Router() @@ -54,6 +55,7 @@ async def character_data_getter(dialog_manager: DialogManager, **kwargs) -> dict ), getter=character_data_getter, state=Academy.main, - ) + ), + on_start=redirect, ) ) diff --git a/handlers/player/academy_campaigns.py b/handlers/player/academy_campaigns.py index 6ff9265..923364c 100644 --- a/handlers/player/academy_campaigns.py +++ b/handlers/player/academy_campaigns.py @@ -9,6 +9,7 @@ from db.models import Campaign, Participation from states.academy_campaigns import AcademyCampaignPreview, AcademyCampaigns +from utils.redirect import redirect logger = logging.getLogger(__name__) router = Router() @@ -56,7 +57,8 @@ async def campaign_getter(dialog_manager: DialogManager, **kwargs): Cancel(Const("Назад")), getter=campaigns_getter, state=AcademyCampaigns.campaigns, - ) + ), + on_start=redirect, ) ) @@ -69,6 +71,7 @@ async def campaign_getter(dialog_manager: DialogManager, **kwargs): Cancel(Const("Назад")), getter=campaign_getter, state=AcademyCampaignPreview.preview, - ) + ), + on_start=redirect, ) ) diff --git a/handlers/player/invitation.py b/handlers/player/invitation.py index fb61053..0fa6a83 100644 --- a/handlers/player/invitation.py +++ b/handlers/player/invitation.py @@ -2,51 +2,64 @@ from aiogram import Router from aiogram.types import CallbackQuery -from aiogram_dialog import Dialog, DialogManager, Window +from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.widgets.kbd import Button, Cancel from aiogram_dialog.widgets.text import Const, Format from db.models import Invitation from services.invitation import handle_accept_invitation, invitation_getter -from states.academy_campaigns import AcademyCampaignPreview +from states.academy import Academy from states.invitation import InvitationAccept -from utils.invitation import get_invite_id +from states.start_simple import StartSimple logger = logging.getLogger(__name__) router = Router() -async def on_accept(c: CallbackQuery, b: Button, m: DialogManager): - invite_id = get_invite_id(m) - invitation = await Invitation.get_or_none(id=invite_id).prefetch_related("campaign", "created_by") +async def on_accept(msg: CallbackQuery, _: Button, dialog_manager: DialogManager): + invite_id = dialog_manager.dialog_data.get("invite_id") + if not invite_id: + await msg.answer("❌ Приглашение не найдено", show_alert=True) + await dialog_manager.reset_stack() + return - if invitation is None: - msg = "Invitation not found" - raise ValueError(msg) + invite = await Invitation.get_or_none(id=invite_id).prefetch_related("campaign", "created_by") + if invite is None: + await msg.answer("❌ Приглашение не найдено", show_alert=True) + await dialog_manager.reset_stack() + return - user = m.middleware_data["user"] + user = dialog_manager.middleware_data["user"] - participation = await handle_accept_invitation(m, c, user, invitation) + participation = await handle_accept_invitation(dialog_manager, msg, user, invite) - if invitation.campaign.verified: - await m.start( - AcademyCampaignPreview.preview, - data={"campaign_id": invitation.campaign.id, "participation_id": participation.id}, + if invite.campaign.verified: + await dialog_manager.start( + StartSimple.simple, + data={ + "campaign_id": invite.campaign.id, + "participation_id": participation.id, + "redirect_to": Academy.main, + "path": [ + "AcademyCampaigns.campaigns", + "AcademyCampaignPreview.preview", + ], + }, + mode=StartMode.RESET_STACK, ) else: # TODO @pxc1984: когда доделаем другие игры следует сюда добавить логику активации игры для них # https://github.com/cu-tabletop/dnd/issues/10 - ... + pass -router.include_router( - Dialog( - Window( - Format("Вас пригласили в кампанию {campaign_title} на роль {role}"), - Button(Const("Присоединиться"), id="accept", on_click=on_accept), - Cancel(Const("Отказаться")), - getter=invitation_getter, - state=InvitationAccept.invitation, - ) - ) +invite_window = Window( + Format("🎉 Вас пригласили в кампанию!\n\n{campaign_title}\nРоль: {role}"), + Button(Const("✅ Присоединиться"), id="accept_admin", on_click=on_accept), + Cancel(Const("❌ Отказаться")), + getter=invitation_getter, + state=InvitationAccept.invitation, ) + + +router.include_router(Dialog(invite_window)) diff --git a/handlers/player/start.py b/handlers/player/start.py index 5c2f2ce..97d4878 100644 --- a/handlers/player/start.py +++ b/handlers/player/start.py @@ -8,10 +8,12 @@ from aiogram_dialog.widgets.text import Const from db.models import Invitation, User +from db.models.participation import Participation from states.academy import Academy from states.invitation import InvitationAccept from states.start_simple import StartSimple from states.upload_character import UploadCharacter +from utils.redirect import redirect from utils.uuid import is_valid_uuid logger = logging.getLogger(__name__) @@ -22,9 +24,14 @@ async def start_args(message: Message, command: CommandObject, dialog_manager: DialogManager, user: User): if not command.args: return + if not is_valid_uuid(command.args): logger.warning("User %s used /start with invalid UUID: %s", user.id, command.args) + await message.reply( + "❌ Неверная ссылка приглашения.\n\nПожалуйста, убедитесь, что ссылка скопирована полностью и корректно." + ) return + invite = await Invitation.get_or_none(start_data=command.args).prefetch_related("user", "campaign") if not invite: logger.warning( @@ -32,7 +39,13 @@ async def start_args(message: Message, command: CommandObject, dialog_manager: D user.id, command.args, ) + await message.reply( + "❌ Приглашение не найдено.\n\n" + "Возможно, ссылка устарела или была отозвана. " + "Попросите мастера отправить новое приглашение." + ) return + if invite.user is None: invite.user = user await invite.save() @@ -43,14 +56,35 @@ async def start_args(message: Message, command: CommandObject, dialog_manager: D command.args, invite.user.id, ) + await message.reply( + "🔒 Это приглашение предназначено другому пользователю.\n\n" + "Каждое приглашение привязано к конкретному Telegram-аккаунту. " + "Попросите мастера отправить вам персональное приглашение." + ) return + logger.info("%s пригласили в игру %s на роль %s", invite.user.id, invite.campaign.id, invite.role.name) if invite.used: await message.reply( - "Сорян, этот инвайт уже был использован.\n\n" - "Если ты его использовал по ошибке, то попроси мастера пригласить тебя еще раз" + "⚠️ Это приглашение уже было использовано.\n\n" + "Если вы хотите присоединиться к кампании, попросите мастера " + "отправить вам новое приглашение." ) return + + participation = await Participation.get_or_none(user=user, campaign=invite.campaign) + if participation is not None: + logger.info( + "User %s used /start in the %s campaign, where he was already invited. It was for %s.", + user.id, + command.args, + invite.user.id, + ) + await message.reply( + f"🗳️ Вы уже участвуете в этой кампании в качестве {'игрока' if (i := participation.role == 0) else str(i)}" + ) + return + logger.debug( "Такой инвайт был найден. %s пригласили в игру %s на роль %s", invite.user.id, @@ -91,6 +125,7 @@ async def on_other(c: CallbackQuery, b: Button, m: DialogManager): ... # https://github.com/cu-tabletop/dnd/issues/11 ), state=StartSimple.simple, - ) + ), + on_start=redirect, ) ) diff --git a/services/invitation.py b/services/invitation.py index 741c11b..c150b2b 100644 --- a/services/invitation.py +++ b/services/invitation.py @@ -5,6 +5,7 @@ from db.models import Invitation, Participation, User from utils.invitation import get_invite_id +from utils.role import Role from .settings import settings @@ -29,14 +30,16 @@ async def handle_accept_invitation(m: DialogManager, callback: CallbackQuery, us user=user, campaign=invitation.campaign, role=invitation.role ) - await callback.answer(f"Приглашение в кампанию {invitation.campaign.title} принято!") + await callback.answer(f"🎉 Приглашение в кампанию {invitation.campaign.title} принято!") if invitation.created_by is not None: if settings.admin_bot is None: - msg = "bot is not specified" + msg = "player bot is not specified" raise TypeError(msg) + role = "Мастер" if invitation.role == Role.MASTER else "игрок" await settings.admin_bot.send_message( - invitation.created_by.id, f"ℹ️ @{user.username} (Игрок) принял приглашение в {invitation.campaign.title}" + invitation.created_by.id, + f"ℹ️ @{user.username} ({role}) принял приглашение в {invitation.campaign.title}", ) await m.done() diff --git a/utils/redirect.py b/utils/redirect.py new file mode 100644 index 0000000..8236707 --- /dev/null +++ b/utils/redirect.py @@ -0,0 +1,21 @@ +from typing import Any + +from aiogram_dialog import DialogManager + +from states.academy_campaigns import AcademyCampaignPreview, AcademyCampaigns + +states = { # Костыль + "AcademyCampaigns.campaigns": AcademyCampaigns.campaigns, + "AcademyCampaignPreview.preview": AcademyCampaignPreview.preview, +} + + +async def redirect(start_data: Any, dialog_manager: DialogManager): + if isinstance(start_data, dict) and (go_to := start_data.get("redirect_to")): + if isinstance(go_to, str): + go_to = states[go_to] + + path = start_data.get("path", []) + start_data["redirect_to"] = path[0] if path else None + start_data["path"] = path[1:] + await dialog_manager.start(state=go_to, data=start_data) From 286b30698f5042c6c8e84dd6627d15a954527110 Mon Sep 17 00:00:00 2001 From: "@m.danilin" Date: Tue, 16 Dec 2025 03:29:20 +0300 Subject: [PATCH 09/10] Updated the UX in the player's bot --- handlers/admin/create_campaign.py | 10 +- handlers/admin/inventory_management.py | 23 +-- handlers/admin/invitation.py | 22 +- handlers/player/academy.py | 64 ++++-- handlers/player/academy_campaigns.py | 87 ++++---- handlers/player/inventory.py | 77 ++++--- handlers/player/invitation.py | 31 ++- handlers/player/other_games.py | 118 ++++++----- handlers/player/other_games_campaigns.py | 37 ++-- handlers/player/other_games_character.py | 44 ++-- handlers/player/player_preview.py | 46 +++-- handlers/player/rating.py | 75 ++++--- handlers/player/start.py | 40 ++-- handlers/player/upload.py | 50 +++-- services/character.py | 245 +++++++++++++++++++++++ services/character_data.py | 6 +- services/settings.py | 2 +- utils/character.py | 3 + 18 files changed, 703 insertions(+), 277 deletions(-) create mode 100644 services/character.py diff --git a/handlers/admin/create_campaign.py b/handlers/admin/create_campaign.py index 8cf2432..20dc3e1 100644 --- a/handlers/admin/create_campaign.py +++ b/handlers/admin/create_campaign.py @@ -60,21 +60,17 @@ async def on_description_entered( text: str, ): if len(text) > settings.MAX_DESCRIPTION_LEN: -<<<<<<< HEAD - mes.answer("Максимум 1023 символа, можно пропустить") -======= await mes.answer("Максимум 1023 символа, можно пропустить") ->>>>>>> upstream/master return dialog_manager.dialog_data["description"] = text await dialog_manager.next() async def on_icon_entered(mes: Message, wid: MessageInput, dialog_manager: DialogManager): - if mes.photo: + if mes.photo and mes.bot: photo = mes.photo[-1] file = await mes.bot.get_file(photo.file_id) - bin_stream: BinaryIO = await mes.bot.download_file(file.file_path) + bin_stream: BinaryIO | None = await mes.bot.download_file(file.file_path or "") object_id = uuid.uuid4() settings.minio.put_object( @@ -103,7 +99,7 @@ async def on_confirm(mes: CallbackQuery, button: Button, dialog_manager: DialogM new_campaign: Campaign = await Campaign.create( title=campaign_data.get("title", ""), description=campaign_data.get("description", ""), - icon=campaign_data.get("icon", ""), + icon=campaign_data.get("icon"), verified=verified, ) diff --git a/handlers/admin/inventory_management.py b/handlers/admin/inventory_management.py index cacf9e8..bf91f80 100644 --- a/handlers/admin/inventory_management.py +++ b/handlers/admin/inventory_management.py @@ -256,7 +256,7 @@ async def on_delete_inventory_item(callback: CallbackQuery, button: Button, dial ) add_item_description_window = Window( - Format("Название {new_item_name}"), + Format("📦 Название: {new_item_name}"), Const("📝 Введите описание предмета (или '-' чтобы пропустить):"), TextInput( id="item_description_input", @@ -268,8 +268,8 @@ async def on_delete_inventory_item(callback: CallbackQuery, button: Button, dial ) add_item_quantity_window = Window( - Format("Название: {new_item_name}"), - Format("Описание {new_item_description}"), + Format("📦 Название: {new_item_name}"), + Format("📄 Описание {new_item_description}"), Const("🔢 Введите количество предмета:"), TextInput( id="item_quantity_input", @@ -283,32 +283,31 @@ async def on_delete_inventory_item(callback: CallbackQuery, button: Button, dial edit_inventory_item_window = Window( Multi( Const("✏️ Редактирование предмета"), - Format("📦 {item.title}"), + Format("📦 Название: {item.title}"), Format("📝 Описание: {item.description}", when="has_description"), - Const("📝 Описание отсутствует", when=lambda data, *_: not data.get("item", {}).description), + Const("📭 Описание отсутствует", when=lambda data, *_: not data.get("item", {}).description), Format("🔢 Количество: {item.quantity}"), - Const(""), - Const("Выберите что изменить:"), + Const("\nВыберите что изменить:"), sep="\n", ), Group( SwitchTo( - Const("✏️ Название"), + Const("✏️ Изменить название"), id="edit_name", state=states.ManageInventory.edit_inventory_item_name, ), SwitchTo( - Const("📝 Описание"), + Const("📝 Изменить описание"), id="edit_description", state=states.ManageInventory.edit_inventory_item_description, ), SwitchTo( - Const("🔢 Количество"), + Const("🔢 Изменить количество"), id="edit_quantity", state=states.ManageInventory.edit_inventory_item_quantity, ), SwitchTo( - Const("🗑️ Удалить"), + Const("🗑️ Удалить предмет"), id="delete_item", state=states.ManageInventory.accept_delete, ), @@ -354,7 +353,7 @@ async def on_delete_inventory_item(callback: CallbackQuery, button: Button, dial ) accept_delete_item_window = Window( - Const("🎯 Точно удалить?"), + Const("⚠️ Вы точно хотите удалить этот предмет?"), Button( Const("🚫 Удалить"), id="accept_delete", diff --git a/handlers/admin/invitation.py b/handlers/admin/invitation.py index 9ead916..38806e5 100644 --- a/handlers/admin/invitation.py +++ b/handlers/admin/invitation.py @@ -6,7 +6,7 @@ from aiogram_dialog import Dialog, DialogManager, StartMode, Window from aiogram_dialog.api.entities import MediaAttachment from aiogram_dialog.widgets.input import ManagedTextInput, TextInput -from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Next +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Next, Row from aiogram_dialog.widgets.link_preview import LinkPreview from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.text import Const, Format, Multi @@ -28,7 +28,7 @@ async def get_link(dialog_manager: DialogManager, **_): created_by: User = dialog_manager.middleware_data["user"] - link = dialog_manager.dialog_data.get("link") + link = dialog_manager.dialog_data.get("link", "") if "link" not in dialog_manager.dialog_data and isinstance(dialog_manager.start_data, dict): campaign_id = dialog_manager.start_data.get("campaign_id", 0) role = dialog_manager.start_data.get("role", Role.PLAYER) @@ -177,16 +177,24 @@ async def on_accept(msg: CallbackQuery, _: Button, dialog_manager: DialogManager getter=get_qr, ) - invite_window = Window( - Format("🎉 Вас пригласили в кампанию!\n\n{campaign_title}\nРоль: {role}"), - Button(Const("✅ Присоединиться"), id="accept_admin", on_click=on_accept), - Cancel(Const("❌ Отказаться")), + Multi( + Const("🎉 Вам пришло приглашение!"), + Const(""), + Format("🏰 Кампания: {campaign_title}"), + Format("👑 Роль: {role}"), + Const(""), + Const("Присоединиться к кампании?"), + sep="\n", + ), + Row( + Button(Const("✅ Присоединиться"), id="accept_admin", on_click=on_accept), + Cancel(Const("❌ Отказаться")), + ), getter=invitation_getter, state=states.InviteMenu.invite, ) - # === Создание диалога и роутера === dialog = Dialog(invite_menu_window, qr_window, invite_window) router = Router() diff --git a/handlers/player/academy.py b/handlers/player/academy.py index 06ff79b..bfba651 100644 --- a/handlers/player/academy.py +++ b/handlers/player/academy.py @@ -4,9 +4,9 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Cancel, Column +from aiogram_dialog.widgets.kbd import Button, Cancel, Column, Row from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from services.character_data import character_preview_getter from states.academy import Academy @@ -41,26 +41,46 @@ async def on_campaigns(c: CallbackQuery, b: Button, m: DialogManager): async def character_data_getter(dialog_manager: DialogManager, **kwargs) -> dict: user = dialog_manager.middleware_data["user"] - data = json.loads(user.data["data"]) - - return character_preview_getter(user, data) - - -router.include_router( - Dialog( - Window( - DynamicMedia("avatar", when="avatar"), - Format("{character_data_preview}", when="character_data_preview"), - Column( - Button(Const("Посмотреть инвентарь"), id="inventory", on_click=on_inventory), - Button(Const("Загрузить обновленный .json"), id="update_json", on_click=on_update), - Button(Const("Рейтинг"), id="rating", on_click=on_rating), - Button(Const("Кампании внутри академии"), id="campaigns", on_click=on_campaigns), - Cancel(Const("Назад")), + + if user.data is None: + return {"has_character_data": False, "avatar": False} + + data = json.loads(user.data.get("data", "{}")) + + character_preview = character_preview_getter(user, data) + + return { + **character_preview, + "has_character_data": True, + } + + +academy_dialog = Dialog( + Window( + Multi( + Const("🎓 Ваш профиль в академии"), + Const(""), + ), + DynamicMedia("avatar", when="avatar"), + Format("{character_data_preview}", when="has_character_data"), + Const( + "📭 У вас пока нет загруженного персонажа", when=lambda data, *_: not data.get("has_character_data", False) + ), + Column( + Row( + Button(Const("🏰 Кампании"), id="campaigns", on_click=on_campaigns), + Button(Const("🎒 Инвентарь"), id="inventory", on_click=on_inventory), + ), + Row( + Button(Const("📈 Рейтинг"), id="rating", on_click=on_rating), + Button(Const("📤 Обновить .json"), id="update_json", on_click=on_update), ), - getter=character_data_getter, - state=Academy.main, + Cancel(Const("⬅️ Назад")), ), - on_start=redirect, - ) + getter=character_data_getter, + state=Academy.main, + ), + on_start=redirect, ) + +router.include_router(academy_dialog) diff --git a/handlers/player/academy_campaigns.py b/handlers/player/academy_campaigns.py index 3b8efe7..eeca7d2 100644 --- a/handlers/player/academy_campaigns.py +++ b/handlers/player/academy_campaigns.py @@ -4,9 +4,9 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Cancel, ScrollingGroup, Select +from aiogram_dialog.widgets.kbd import Cancel, ScrollingGroup, Select from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Participation from services.campaigns import campaign_getter @@ -19,12 +19,15 @@ async def campaigns_getter(dialog_manager: DialogManager, **kwargs): user = dialog_manager.middleware_data["user"] + participations = await Participation.filter(user=user, campaign__verified=True).prefetch_related("campaign").all() + return { - "campaigns": await Participation.filter(user=user, campaign__verified=True).prefetch_related("campaign").all() + "campaigns": participations, + "has_campaigns": len(participations) > 0, } -async def on_campaign(c: CallbackQuery, b: Button, m: DialogManager, participation_id: UUID): +async def on_campaign(c: CallbackQuery, b: Select, m: DialogManager, participation_id: UUID): participation = await Participation.get(id=participation_id).prefetch_related("campaign") await m.start( AcademyCampaignPreview.preview, @@ -32,40 +35,56 @@ async def on_campaign(c: CallbackQuery, b: Button, m: DialogManager, participati ) -router.include_router( - Dialog( - Window( - Const("Кампании внутри академии, в которых вы участвуете"), - ScrollingGroup( - Select( - Format("{item.campaign.title}"), - id="campaign", - items="campaigns", - item_id_getter=lambda x: x.id, - on_click=on_campaign, - ), - hide_on_single_page=True, - height=5, - id="campaigns", +# Диалог списка кампаний академии +campaigns_dialog = Dialog( + Window( + Multi( + Const("🏰 Кампании академии"), + Const(""), + Const("Здесь собраны официальные кампании, в которых вы участвуете."), + Const(""), + Const("📭 У вас пока нет кампаний в академии", when=lambda data, *_: not data.get("has_campaigns", False)), + sep="\n", + ), + ScrollingGroup( + Select( + Format("🎮 {item.campaign.title}"), + id="campaign", + items="campaigns", + item_id_getter=lambda x: x.id, + on_click=on_campaign, + type_factory=UUID, ), - Cancel(Const("Назад")), - getter=campaigns_getter, - state=AcademyCampaigns.campaigns, + hide_on_single_page=True, + height=5, + id="campaigns", + when="has_campaigns", ), - on_start=redirect, - ) + Cancel(Const("⬅️ Назад")), + getter=campaigns_getter, + state=AcademyCampaigns.campaigns, + ), + on_start=redirect, ) -router.include_router( - Dialog( - Window( - Format("Информация о кампании: {title}\n\nОписание: {description}\n\nВыберите действие:"), - DynamicMedia("icon"), - Cancel(Const("Назад")), - getter=campaign_getter, - state=AcademyCampaignPreview.preview, +# Диалог предпросмотра кампании +preview_dialog = Dialog( + Window( + DynamicMedia("icon"), + Multi( + Format("🎓 Информация о кампании: {title}"), + Const(""), + Format("📝 Описание: {description}"), + Const(""), + Const("Здесь вы можете управлять своей кампанией."), + sep="\n", ), - on_start=redirect, - ) + Cancel(Const("⬅️ Назад")), + getter=campaign_getter, + state=AcademyCampaignPreview.preview, + ), + on_start=redirect, ) + +router.include_routers(campaigns_dialog, preview_dialog) diff --git a/handlers/player/inventory.py b/handlers/player/inventory.py index 83b23fc..4a494bb 100644 --- a/handlers/player/inventory.py +++ b/handlers/player/inventory.py @@ -5,7 +5,7 @@ from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.kbd import Back, Button, Cancel, ScrollingGroup, Select -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from pydantic import BaseModel, field_validator from db.models import Campaign, Item @@ -59,6 +59,7 @@ async def inventory_data_getter(dialog_manager: DialogManager, **kwargs) -> dict return { "inventory": items, "has_items": len(items) > 0, + "items_count": len(items), } @@ -69,45 +70,59 @@ async def get_inventory_item_data(dialog_manager: DialogManager, **kwargs): return {"item": None} item = await Item.get(id=item_id) - return {"item": item, "has_description": item.description != ""} + return { + "item": item, + "has_description": item.description != "", + "is_empty": item.description == "", + } -async def on_inventory_item_selected(c: CallbackQuery, b: Button, m: DialogManager, item_id: UUID): +async def on_inventory_item_selected(c: CallbackQuery, b: Select, m: DialogManager, item_id: UUID): m.dialog_data["selected_item_id"] = item_id await m.switch_to(InventoryView.preview) -router.include_router( - Dialog( - Window( - Format("Инвентарь персонажа"), - ScrollingGroup( - Select( - Format("{item.title} ×{item.quantity}"), - id="inventory_select", - item_id_getter=lambda item: item.id, - items="inventory", - on_click=on_inventory_item_selected, - type_factory=UUID, - ), - id="inventory_scroll", - width=1, - height=10, - hide_on_single_page=True, - when="has_items", +inventory_dialog = Dialog( + Window( + Multi( + Const("🎒 Инвентарь персонажа"), + Const(""), + Format("📦 Всего предметов: {items_count}", when="has_items"), + Const("📭 Инвентарь пуст", when=lambda data, *_: not data.get("has_items", False)), + Const("Выберите предмет для просмотра:"), + sep="\n", + ), + ScrollingGroup( + Select( + Format("📦 {item.title} ×{item.quantity}"), + id="inventory_select", + item_id_getter=lambda item: item.id, + items="inventory", + on_click=on_inventory_item_selected, + type_factory=UUID, ), - Cancel(Const("Назад")), - getter=inventory_data_getter, - state=InventoryView.view, + id="inventory_scroll", + width=1, + height=8, + hide_on_single_page=True, + when="has_items", ), - Window( - Format("📦 {item.title}"), + Cancel(Const("⬅️ Назад")), + getter=inventory_data_getter, + state=InventoryView.view, + ), + Window( + Multi( + Format("📦 Предмет: {item.title}"), + Const(""), Format("📝 Описание: {item.description}", when="has_description"), - Const("📝 Описание отсутствует", when=~F["has_description"]), + Const("📭 Описание отсутствует", when=~F["has_description"]), Format("🔢 Количество: {item.quantity}"), - Back(Const("Назад")), - getter=get_inventory_item_data, - state=InventoryView.preview, ), - ) + Back(Const("⬅️ Назад к инвентарю")), + getter=get_inventory_item_data, + state=InventoryView.preview, + ), ) + +router.include_router(inventory_dialog) diff --git a/handlers/player/invitation.py b/handlers/player/invitation.py index 0fa6a83..cfce6f9 100644 --- a/handlers/player/invitation.py +++ b/handlers/player/invitation.py @@ -3,8 +3,8 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, StartMode, Window -from aiogram_dialog.widgets.kbd import Button, Cancel -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.kbd import Button, Cancel, Row +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Invitation from services.invitation import handle_accept_invitation, invitation_getter @@ -53,13 +53,24 @@ async def on_accept(msg: CallbackQuery, _: Button, dialog_manager: DialogManager pass -invite_window = Window( - Format("🎉 Вас пригласили в кампанию!\n\n{campaign_title}\nРоль: {role}"), - Button(Const("✅ Присоединиться"), id="accept_admin", on_click=on_accept), - Cancel(Const("❌ Отказаться")), - getter=invitation_getter, - state=InvitationAccept.invitation, +invite_dialog = Dialog( + Window( + Multi( + Const("🎉 Вам пришло приглашение!"), + Const(""), + Format("🏰 Кампания: {campaign_title}"), + Format("👑 Роль: {role}"), + Const(""), + Const("Присоединиться к кампании?"), + sep="\n", + ), + Row( + Button(Const("✅ Присоединиться"), id="accept_admin", on_click=on_accept), + Cancel(Const("❌ Отказаться")), + ), + getter=invitation_getter, + state=InvitationAccept.invitation, + ) ) - -router.include_router(Dialog(invite_window)) +router.include_router(invite_dialog) diff --git a/handlers/player/other_games.py b/handlers/player/other_games.py index e4fcb87..1efc88d 100644 --- a/handlers/player/other_games.py +++ b/handlers/player/other_games.py @@ -4,8 +4,8 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Back, Button, Cancel, ScrollingGroup, Select -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.kbd import Back, Button, Cancel, Row, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Campaign, Character, Participation, User from states.other_games import OtherGames @@ -42,10 +42,13 @@ async def available_campaigns_getter(dialog_manager: DialogManager, **kwargs) -> for p in (await Participation.filter(user=user, campaign__verified=False).prefetch_related("campaign").all()) ] - return {"participations": participations, "participations_exist": len(participations) > 0} + return { + "participations": participations, + "has_participations": len(participations) > 0, + } -async def on_character_selected(c: CallbackQuery, b: Button, m: DialogManager, character_id: UUID): +async def on_character_selected(c: CallbackQuery, b: Select, m: DialogManager, character_id: UUID): await m.start( OtherGamesCharacter.preview, data={ @@ -58,7 +61,7 @@ async def on_available_games(c: CallbackQuery, b: Button, m: DialogManager): await m.switch_to(OtherGames.available) -async def on_campaign_selected(c: CallbackQuery, b: Button, m: DialogManager, participation_id: UUID): +async def on_campaign_selected(c: CallbackQuery, b: Select, m: DialogManager, participation_id: UUID): user: User = m.middleware_data["user"] participation = await Participation.get(id=participation_id).prefetch_related("campaign") campaign: Campaign = participation.campaign @@ -76,50 +79,71 @@ async def on_campaign_selected(c: CallbackQuery, b: Button, m: DialogManager, pa ) -router.include_router( - Dialog( - Window( - Const("Вы находитесь в меню неофициальных игр"), - ScrollingGroup( - Select( - Format("{item[1].name} - {item[2].title}"), - id="character_select", - items="characters_data", - item_id_getter=lambda c: c[0].id, - on_click=on_character_selected, - type_factory=UUID, - ), - id="characters_scroll", - width=1, - height=8, - hide_on_single_page=True, - when="has_characters", +other_games_dialog = Dialog( + Window( + Multi( + Const("🎮 Другие игры"), + Const(""), + Const("Здесь вы можете управлять своими персонажами в неофициальных кампаниях."), + Const(""), + Const( + "📭 У вас пока нет персонажей в других играх", + when=lambda data, *_: not data.get("has_characters", False), ), - Button(Const("Посмотреть доступные кампании"), id="available_games", on_click=on_available_games), - Cancel(Const("Назад")), - getter=main_getter, - state=OtherGames.main, + Const("Выберите персонажа для управления:"), + sep="\n", ), - Window( - Const("Вот кампании к которым у вас есть доступ"), - ScrollingGroup( - Select( - Format("{item[0].title} - {item[1].role.name}"), - id="campaign_select", - items="participations", - item_id_getter=lambda c: c[1].id, - on_click=on_campaign_selected, - type_factory=UUID, - ), - id="participations_scroll", - width=1, - height=8, - hide_on_single_page=True, - when="participations_exist", + ScrollingGroup( + Select( + Format("👤 {item[1].name} - {item[2].title}"), + id="character_select", + items="characters_data", + item_id_getter=lambda c: c[0].id, + on_click=on_character_selected, + type_factory=UUID, ), - Back(Const("Назад")), - getter=available_campaigns_getter, - state=OtherGames.available, + id="characters_scroll", + width=1, + height=6, + hide_on_single_page=True, + when="has_characters", ), - ) + Row( + Button(Const("🏰 Доступные кампании"), id="available_games", on_click=on_available_games), + Cancel(Const("⬅️ Назад")), + ), + getter=main_getter, + state=OtherGames.main, + ), + Window( + Multi( + Const("🏰 Доступные кампании"), + Const(""), + Const("Кампании, к которым у вас есть доступ:"), + Const(""), + Const("📭 У вас нет доступных кампаний", when=lambda data, *_: not data.get("has_participations", False)), + Const("Выберите кампанию для присоединения:"), + sep="\n", + ), + ScrollingGroup( + Select( + Format("🎮 {item[0].title} (Роль: {item[1].role.name})"), + id="campaign_select", + items="participations", + item_id_getter=lambda c: c[1].id, + on_click=on_campaign_selected, + type_factory=UUID, + ), + id="participations_scroll", + width=1, + height=6, + hide_on_single_page=True, + when="has_participations", + ), + Back(Const("⬅️ Назад")), + getter=available_campaigns_getter, + state=OtherGames.available, + ), ) + +router.include_router(other_games_dialog) diff --git a/handlers/player/other_games_campaigns.py b/handlers/player/other_games_campaigns.py index c60d287..b182138 100644 --- a/handlers/player/other_games_campaigns.py +++ b/handlers/player/other_games_campaigns.py @@ -3,7 +3,7 @@ from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.kbd import Button, Cancel from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Character from services.campaigns import campaign_getter @@ -15,13 +15,16 @@ async def campaign_preview_getter(dialog_manager: DialogManager, **kwargs): - campaign_id = dialog_manager.start_data.get("campaign_id") + if "campaign_id" not in dialog_manager.dialog_data and isinstance(dialog_manager.start_data, dict): + dialog_manager.dialog_data["campaign_id"] = dialog_manager.start_data.get("campaign_id", 0) + campaign_id = dialog_manager.dialog_data["campaign_id"] user = dialog_manager.middleware_data["user"] character: Character | None = await Character.get_or_none(campaign_id=campaign_id, user=user) return { **await campaign_getter(dialog_manager, **kwargs), "should_join": character is None, + "has_character": character is not None, } @@ -31,20 +34,28 @@ async def on_join_campaign(c: CallbackQuery, b: Button, m: DialogManager): data={ "target_type": TargetType.CHARACTER, "target_id": None, - "campaign_id": m.start_data.get("campaign_id"), + "campaign_id": m.dialog_data["campaign_id"], }, ) -router.include_router( - Dialog( - Window( - Format("Информация о кампании: {title}\n\nОписание: {description}\n\nВыберите действие:"), - DynamicMedia("icon"), - Button(Const("Присоединиться"), id="join", on_click=on_join_campaign, when="should_join"), - Cancel(Const("Назад")), - getter=campaign_preview_getter, - state=OtherGamesCampaign.preview, - ) +campaign_preview_dialog = Dialog( + Window( + DynamicMedia("icon"), + Multi( + Format("🎮 Кампания: {title}"), + Const(""), + Format("📝 Описание: {description}"), + Const(""), + Const("🌟 Вы ещё не создали персонажа для этой кампании", when="should_join"), + Const("✅ У вас уже есть персонаж в этой кампании", when="has_character"), + sep="\n", + ), + Button(Const("➕ Присоединиться"), id="join", on_click=on_join_campaign, when="should_join"), + Cancel(Const("⬅️ Назад")), + getter=campaign_preview_getter, + state=OtherGamesCampaign.preview, ) ) + +router.include_router(campaign_preview_dialog) diff --git a/handlers/player/other_games_character.py b/handlers/player/other_games_character.py index c461e81..4d24dac 100644 --- a/handlers/player/other_games_character.py +++ b/handlers/player/other_games_character.py @@ -3,9 +3,9 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Cancel +from aiogram_dialog.widgets.kbd import Button, Cancel, Column, Row from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import Campaign, Character, Participation, User from services.character_data import character_preview_getter @@ -21,7 +21,12 @@ async def character_data_getter(dialog_manager: DialogManager, **kwargs) -> dict character = await Character.get(id=dialog_manager.start_data["character_id"]) data = json.loads(character.data["data"]) - return character_preview_getter(character, data) + character_preview = character_preview_getter(character, data) + + return { + **character_preview, + "has_character_data": character_preview.get("character_data_preview") not in [None, ""], + } async def on_campaign_info(c: CallbackQuery, b: Button, m: DialogManager): @@ -63,17 +68,26 @@ async def on_upload_json(c: CallbackQuery, b: Button, m: DialogManager): ) -router.include_router( - Dialog( - Window( - DynamicMedia("avatar", when="avatar"), - Format("{character_data_preview}", when="character_data_preview"), - Button(Const("Посмотреть инвентарь"), id="inventory", on_click=on_inventory), - Button(Const("Информация о кампании"), id="campaign_info", on_click=on_campaign_info), - Button(Const("Загрузить обновленный .json"), id="update_json", on_click=on_upload_json), - Cancel(Const("Назад")), - getter=character_data_getter, - state=OtherGamesCharacter.preview, - ) +character_dialog = Dialog( + Window( + Multi( + Const("👤 Персонаж"), + Const(""), + ), + DynamicMedia("avatar", when="avatar"), + Format("{character_data_preview}", when="character_data_preview"), + Const("📭 У персонажа нет данных", when=lambda data, *_: not data.get("character_data_preview", "")), + Column( + Row( + Button(Const("🎒 Инвентарь"), id="inventory", on_click=on_inventory), + Button(Const("🏰 Кампания"), id="campaign_info", on_click=on_campaign_info), + ), + Button(Const("📤 Обновить .json"), id="update_json", on_click=on_upload_json), + Cancel(Const("⬅️ Назад")), + ), + getter=character_data_getter, + state=OtherGamesCharacter.preview, ) ) + +router.include_router(character_dialog) diff --git a/handlers/player/player_preview.py b/handlers/player/player_preview.py index 754c5fe..302f85d 100644 --- a/handlers/player/player_preview.py +++ b/handlers/player/player_preview.py @@ -3,9 +3,9 @@ from aiogram import Router from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Cancel, Url +from aiogram_dialog.widgets.kbd import Cancel, Row, Url from aiogram_dialog.widgets.media import DynamicMedia -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import User from services.character_data import character_preview_getter @@ -16,24 +16,42 @@ async def preview_getter(dialog_manager: DialogManager, **kwargs): - user = await User.get(id=dialog_manager.start_data["user_id"]) + if "light" not in dialog_manager.dialog_data and isinstance(dialog_manager.start_data, dict): + dialog_manager.dialog_data["light"] = dialog_manager.start_data.get("light", True) + dialog_manager.dialog_data["user_id"] = dialog_manager.start_data.get("user_id", 0) + + user = await User.get(id=dialog_manager.dialog_data["user_id"]) data = json.loads(user.data["data"]) + light = dialog_manager.dialog_data["light"] + + character_preview = character_preview_getter(user, data, light=light) return { "profile_link": f"tg://user?id={user.id}", - **character_preview_getter(user, data), + "username": user.username or "Пользователь", + **character_preview, + "has_character_data": character_preview.get("character_data_preview") not in [None, ""], } -router.include_router( - Dialog( - Window( - DynamicMedia("avatar", when="avatar"), - Format("{character_data_preview}", when="character_data_preview"), - Url(Const("Перейти в профиль"), Format("{profile_link}")), - Cancel(Const("Назад")), - getter=preview_getter, - state=PlayerPreview.preview, - ) +preview_dialog = Dialog( + Window( + Multi( + Format("👤 Профиль: @{username}"), + Const(""), + ), + DynamicMedia("avatar", when="avatar"), + Format("{character_data_preview}", when="character_data_preview"), + Const( + "📭 У игрока нет загруженного персонажа", when=lambda data, *_: not data.get("character_data_preview", "") + ), + Row( + Url(Const("📨 Написать"), Format("{profile_link}")), + Cancel(Const("⬅️ Назад")), + ), + getter=preview_getter, + state=PlayerPreview.preview, ) ) + +router.include_router(preview_dialog) diff --git a/handlers/player/rating.py b/handlers/player/rating.py index a12fcc6..dd1591a 100644 --- a/handlers/player/rating.py +++ b/handlers/player/rating.py @@ -3,8 +3,8 @@ from aiogram import Router from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.widgets.kbd import Button, Cancel, ScrollingGroup, Select -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.kbd import Cancel, ScrollingGroup, Select +from aiogram_dialog.widgets.text import Const, Format, Multi from db.models import User from states.player_preview import PlayerPreview @@ -15,33 +15,50 @@ async def rating_getter(dialog_manager: DialogManager, **kwargs): - return {"top": await User.all().order_by("-rating")} - - -async def on_preview(c: CallbackQuery, b: Button, m: DialogManager, user_id: int): - await m.start(PlayerPreview.preview, data={"user_id": user_id}) - - -router.include_router( - Dialog( - Window( - Const("Вот топ по рейтингу"), - ScrollingGroup( - Select( - Format("@{item.username} - {item.rating}"), - id="preview", - items="top", - item_id_getter=lambda x: x.id, - on_click=on_preview, - ), - hide_on_single_page=True, - width=1, - height=5, - id="top", + top_users = await User.all().order_by("-rating") + + # Добавляем позиции в рейтинге + top_with_positions = [(i + 1, user) for i, user in enumerate(top_users)] + + return { + "top_with_positions": top_with_positions, + "has_users": len(top_with_positions) > 0, + } + + +async def on_preview(c: CallbackQuery, b: Select, m: DialogManager, user_id: int): + await m.start(PlayerPreview.preview, data={"user_id": user_id, "light": True}) + + +rating_dialog = Dialog( + Window( + Multi( + Const("🏆 Рейтинг игроков"), + Const(""), + Const("Топ игроков по рейтингу в академии:"), + Const(""), + Const("📭 В рейтинге пока нет игроков", when=lambda data, *_: not data.get("has_users", False)), + sep="\n", + ), + ScrollingGroup( + Select( + Format("{item[0]}. @{item[1].username} - {item[1].rating} ⭐"), + id="preview", + items="top_with_positions", + item_id_getter=lambda x: x[1].id, + on_click=on_preview, + type_factory=int, ), - Cancel(Const("Назад")), - getter=rating_getter, - state=AcademyRating.rating, + hide_on_single_page=True, + width=1, + height=6, + id="top", + when="has_users", ), - ) + Cancel(Const("⬅️ Назад")), + getter=rating_getter, + state=AcademyRating.rating, + ), ) + +router.include_router(rating_dialog) diff --git a/handlers/player/start.py b/handlers/player/start.py index 6488ed2..b5df548 100644 --- a/handlers/player/start.py +++ b/handlers/player/start.py @@ -5,7 +5,7 @@ from aiogram.types import CallbackQuery, Message from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.kbd import Button, Column -from aiogram_dialog.widgets.text import Const +from aiogram_dialog.widgets.text import Const, Multi from db.models import Invitation, User from db.models.participation import Participation @@ -84,9 +84,8 @@ async def start_args(message: Message, command: CommandObject, dialog_manager: D command.args, invite.user.id, ) - await message.reply( - f"🗳️ Вы уже участвуете в этой кампании в качестве {'игрока' if (i := participation.role == 0) else str(i)}" - ) + role_name = "игрока" if participation.role.value == 0 else participation.role.name + await message.reply(f"🗳️ Вы уже участвуете в этой кампании в качестве {role_name}") return logger.debug( @@ -122,18 +121,25 @@ async def on_other(c: CallbackQuery, b: Button, m: DialogManager): await m.start(OtherGames.main) -router.include_router( - Dialog( - Window( - Const("Обычный /start"), - Column( - Button(Const("Академия"), id="academy", on_click=on_academy), - Button(Const("Другие игры"), id="other_games", on_click=on_other), - # TODO (@pxc1984): Добавить ближайшие встречи - # https://github.com/cu-tabletop/dnd/issues/11 - ), - state=StartSimple.simple, +start_dialog = Dialog( + Window( + Multi( + Const("🎲 Добро пожаловать!"), + Const(""), + Const("Я - бот для настольных ролевых игр."), + Const(""), + Const("Выберите раздел для продолжения:"), + sep="\n", ), - on_start=redirect, - ) + Column( + Button(Const("🎓 Академия"), id="academy", on_click=on_academy), + Button(Const("🎮 Другие игры"), id="other_games", on_click=on_other), + # TODO (@pxc1984): Добавить ближайшие встречи + # https://github.com/cu-tabletop/dnd/issues/11 + ), + state=StartSimple.simple, + ), + on_start=redirect, ) + +router.include_router(start_dialog) diff --git a/handlers/player/upload.py b/handlers/player/upload.py index 58f629c..9d1f4a8 100644 --- a/handlers/player/upload.py +++ b/handlers/player/upload.py @@ -9,13 +9,15 @@ from aiogram_dialog import Dialog, DialogManager, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import Cancel -from aiogram_dialog.widgets.text import Const -from pydantic import BaseModel, field_validator +from aiogram_dialog.widgets.link_preview import LinkPreview +from aiogram_dialog.widgets.text import Const, Multi +from pydantic import BaseModel, ValidationError, field_validator from db.models import Character from services.character_data import update_char_data from states.inventory_view import TargetType from states.upload_character import UploadCharacter +from utils.character import parse_character_data if TYPE_CHECKING: from db.models.base import CharacterData @@ -77,7 +79,7 @@ def validate_campaign_id(cls, v: int | None, values: dict) -> int: async def upload_document(msg: Message, _: MessageInput, manager: DialogManager): if not msg.document or not msg.document.file_name.endswith(".json"): - await msg.answer("Отправь .json!") + await msg.answer("❌ Пожалуйста, отправьте файл в формате .json!") logger.warning("User %d didn't send us a valid json", msg.from_user.id) return @@ -104,30 +106,46 @@ async def upload_document(msg: Message, _: MessageInput, manager: DialogManager) return try: - await update_char_data(source, json.loads(content.decode("utf-8"))) + data = json.loads(content.decode("utf-8")) + parse_character_data(data) + await update_char_data(source, data) except UnicodeDecodeError: logger.warning("Failed to unicode decode payload from user %d", msg.from_user.id) - await msg.answer("Это не json, проверь еще раз") + await msg.answer("❌ Не удалось прочитать файл. Убедитесь, что это корректный JSON-файл.") return - except json.JSONDecodeError: + except (json.JSONDecodeError, ValidationError): logger.warning("User %d sent incorrect json", msg.from_user.id) - await msg.answer("Это не json, проверь еще раз") + await msg.answer("❌ Это невалидный JSON-файл. Проверьте его содержимое.") return - await msg.answer("Успешно загружено") + await msg.answer("✅ Данные персонажа успешно загружены!") await manager.done() +async def has_data(dialog_manager: DialogManager, **kwargs): + return {"has_data": dialog_manager.middleware_data["user"].data} + + """ Этот диалог обязательно должен включать в start_data параметр request: UploadCharacterRequest """ -router.include_router( - Dialog( - Window( - Const("Отправь нам .json из LSH"), - Cancel(Const("Отмена")), - MessageInput(content_types=ContentType.DOCUMENT, func=upload_document), - state=UploadCharacter.upload, +upload_dialog = Dialog( + Window( + Multi( + Const("📤 Загрузка персонажа"), + Const(""), + Const("Отправьте файл персонажа в формате .json."), + Const('Файл должен быть экспортирован из LSH.'), + Const(""), + Const("⚠️ Внимание: существующие данные будут перезаписаны.", when=""), + sep="\n", ), - ) + LinkPreview(is_disabled=True), + Cancel(Const("❌ Отмена")), + MessageInput(content_types=ContentType.DOCUMENT, func=upload_document), + getter=has_data, + state=UploadCharacter.upload, + ), ) + +router.include_router(upload_dialog) diff --git a/services/character.py b/services/character.py new file mode 100644 index 0000000..1802533 --- /dev/null +++ b/services/character.py @@ -0,0 +1,245 @@ +import logging +from typing import Any + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +class CharacterStat(BaseModel): + score: int = 0 + modifier: int = 0 + + +class CharacterHP(BaseModel): + current: int = 0 + max: int = 0 + temp: int = 0 + ac: int = 0 + speed: int = 0 + + +class CharacterWeapon(BaseModel): + name: str = "Неизвестно" + mod: str = "" + damage: str = "" + notes: str = "" + + +class CharacterCoins(BaseModel): + pp: int = 0 + gp: int = 0 + ep: int = 0 + sp: int = 0 + cp: int = 0 + + +class CharacterData(BaseModel): + name: str = "Неизвестно" + klass: str = Field(default="Неизвестно", alias="class") + subclass: str = "" + level: int = 0 + race: str = "Неизвестно" + background: str = "Неизвестно" + alignment: str = "Неизвестно" + avatar_link: str | None + + age: str = "" + height: str = "" + weight: str = "" + eyes: str = "" + skin: str = "" + hair: str = "" + + proficiency: int = 0 + stats: dict[str, CharacterStat] = Field(default_factory=dict) + + skills: dict[str, dict] = Field(default_factory=dict) + prof_skills: list[str] = Field(default_factory=list) + + hp: CharacterHP = Field(default_factory=CharacterHP) + + weapons: list[CharacterWeapon] = Field(default_factory=list) + + traits: str = "" + equipment: str = "" + background_story: str = "" + personality: str = "" + appearance: str = "" + allies: str = "" + proficiencies: str = "" + + coins: CharacterCoins = Field(default_factory=CharacterCoins) + + class Config: + validate_by_name = True + + def preview(self) -> str: + return ( + f"Имя: {self.name}\n" + f"Класс: {self.klass} {f'({self.subclass})' if self.subclass else ''}\n" + f"Уровень: {self.level}\n" + f"Хиты: {self.hp.current}/{self.hp.max} {f'(+{self.hp.temp} временное)' if self.hp.temp else ''}\n" + f"Класс брони: {self.hp.ac}\n" + f"Скорость: {self.hp.speed} фт.\n" + f"Раса: {self.race}\n" + f"Предыстория: {self.background}\n" + f"Мировоззрение: {self.alignment}" + ) + + def preview_stats(self) -> str: + return "\n".join( + [f"{STATS_CONVERSION[key]}: {value.score}({value.modifier})" for key, value in self.stats.items()] + ) + + +STATS_CONVERSION = { + "str": "Сила", + "dex": "Ловкость", + "con": "Телосложение", + "int": "Интеллект", + "wis": "Мудрость", + "cha": "Харизма", +} + +def parse_character_data(data: dict) -> CharacterData: + + """ + Превращает json персонажа Long Story Short в адекватный Pydantic объект + """ + basic_info = data.get("info", {}) + sub_info = data.get("subInfo", {}) + vitality = data.get("vitality", {}) + coins_data = data.get("coins", {}) + prof_skills = [] + for skill_name, skill_data in data.get("skills", {}).items(): + if skill_data.get("isProf"): + prof_skills.append(skill_name) + stats = {} + for stat_name, stat_data in data.get("stats", {}).items(): + stats[stat_name] = CharacterStat( + score=stat_data.get("score", 0), + modifier=stat_data.get("modifier", 0), + ) + weapons = [ + CharacterWeapon( + name=weapon.get("name", {}).get("value", "Неизвестно"), + mod=weapon.get("mod", {}).get("value", ""), + damage=weapon.get("dmg", {}).get("value", ""), + notes=weapon.get("notes", {}).get("value", ""), + ) + for weapon in data.get("weaponsList", []) + ] + + # noinspection PyArgumentList + return CharacterData( + # Basic info + name=data.get("name", {}).get("value", "Неизвестно"), + klass=basic_info.get("charClass", {}).get("value", "Неизвестно"), + subclass=basic_info.get("charSubclass", {}).get("value", ""), + level=basic_info.get("level", {}).get("value", 0), + race=basic_info.get("race", {}).get("value", "Неизвестно"), + background=basic_info.get("background", {}).get("value", "Неизвестно"), + alignment=basic_info.get("alignment", {}).get("value", "Неизвестно"), + avatar_link=basic_info.get("avatar", {}).get("webp") or basic_info.get("avatar", {}).get("jpeg"), + # Physical characteristics + age=sub_info.get("age", {}).get("value", ""), + height=sub_info.get("height", {}).get("value", ""), + weight=sub_info.get("weight", {}).get("value", ""), + eyes=sub_info.get("eyes", {}).get("value", ""), + skin=sub_info.get("skin", {}).get("value", ""), + hair=sub_info.get("hair", {}).get("value", ""), + # Stats + proficiency=data.get("proficiency", 0), + stats=stats, + # Skills + skills=data.get("skills", {}), + prof_skills=prof_skills, + # Vitality + hp=CharacterHP( + current=vitality.get("hp-current", {}).get("value", 0), + max=vitality.get("hp-max", {}).get("value", 0), + temp=vitality.get("hp-temp", {}).get("value", 0), + ac=vitality.get("ac", {}).get("value", 0), + speed=vitality.get("speed", {}).get("value", 0), + ), + # Weapons + weapons=weapons, + # Text content + traits=extract_telegram_text(data.get("text", {}).get("traits", {}).get("value", {})), + equipment=extract_telegram_text(data.get("equipment", {}).get("value", {})), + background_story=extract_telegram_text(data.get("quests", {}).get("value", {})), + personality=extract_telegram_text(data.get("background", {}).get("value", {})), + appearance=extract_telegram_text(data.get("appearance", {}).get("value", {})), + allies=extract_telegram_text(data.get("allies", {}).get("value", {})), + proficiencies=extract_telegram_text(data.get("prof", {}).get("value", {})), + # Currency + coins=CharacterCoins( + pp=coins_data.get("pp", {}).get("value", 0), + gp=coins_data.get("gp", {}).get("value", 0), + ep=coins_data.get("ep", {}).get("value", 0), + sp=coins_data.get("sp", {}).get("value", 0), + cp=coins_data.get("cp", {}).get("value", 0), + ), + ) + + +def extract_telegram_text(text_data: dict[str, Any]) -> str: + """ + Превращает json документооборот в HTML + """ + if not text_data: + return "" + + content = text_data.get("data", {}).get("content", []) + paragraphs = [] + + for block in content: + if block.get("type") == "paragraph": + paragraph_text = process_paragraph(block.get("content", [])) + if paragraph_text: + paragraphs.append(paragraph_text) + + return "\n".join(paragraphs) + + +def process_paragraph(items: list[dict[str, Any]]) -> str: + """Process all items in paragraph into HTML.""" + result = [] + + for item in items: + item_type = item.get("type") + + if item_type == "text": + html_text = process_text_item(item) + if html_text: + result.append(html_text) + + elif item_type == "roller": + result.append(process_roller_item(item)) + + return " ".join(result).strip() + + +def process_text_item(item: dict[str, Any]) -> str: + """Apply HTML formatting to text item.""" + text = item.get("text", "").strip() + if not text: + return "" + + for mark in item.get("marks", []): + mark_type = mark.get("type") + if mark_type == "bold": + text = f"{text}" + elif mark_type == "italic": + text = f"{text}" + elif mark_type == "underline": + text = f"{text}" + + return text + + +def process_roller_item(item: dict[str, Any]) -> str: + """Convert roller item to placeholder HTML-like form.""" + roller_text = item.get("content", [{}])[0].get("text", "") + return f"[{roller_text}]" diff --git a/services/character_data.py b/services/character_data.py index 30ae6f2..cfd809a 100644 --- a/services/character_data.py +++ b/services/character_data.py @@ -4,6 +4,8 @@ from aiogram_dialog.api.entities import MediaAttachment from db.models.base import CharacterData as BaseCharacterData +from db.models.character import Character +from db.models.user import User from utils.character import CharacterData, parse_character_data logger = logging.getLogger(__name__) @@ -14,10 +16,10 @@ async def update_char_data(holder: BaseCharacterData, data: dict): await holder.save() -def character_preview_getter(user: BaseCharacterData, data: dict): +def character_preview_getter(user: User | Character, data: dict, *, light: bool = False): ret = {} info: CharacterData = parse_character_data(data) - ret["character_data_preview"] = info.preview() + ret["character_data_preview"] = info.light_preview() if light else info.preview() avatar_url = data.get("avatar", {}).get("webp") if avatar_url: ret["avatar"] = MediaAttachment( diff --git a/services/settings.py b/services/settings.py index e44b9ff..7c9e467 100644 --- a/services/settings.py +++ b/services/settings.py @@ -40,7 +40,7 @@ class Settings(BaseSettings): DB_PASSWORD: str = Field(default="admin", alias="POSTGRES_PASSWORD") # ^ Minio - MINIO_HOST: str = "localhost" + MINIO_HOST: str = "minio" MINIO_PORT: str = "9000" MINIO_ROOT_USER: str = Field(default="admin", alias="MINIO_ROOT_USER") MINIO_ROOT_PASSWORD: str = Field(default="admin", alias="MINIO_ROOT_PASSWORD") diff --git a/utils/character.py b/utils/character.py index cbfdf9a..90dff19 100644 --- a/utils/character.py +++ b/utils/character.py @@ -87,6 +87,9 @@ def preview(self) -> str: f"Мировоззрение: {self.alignment}" ) + def light_preview(self) -> str: + return f"Имя: {self.name}\n" + def preview_stats(self) -> str: return "\n".join( [f"{STATS_CONVERSION[key]}: {value.score}({value.modifier})" for key, value in self.stats.items()] From 9b303ad69170dfcade61b163011d331ead112868 Mon Sep 17 00:00:00 2001 From: Igor Mamaev Date: Tue, 16 Dec 2025 21:57:44 +0300 Subject: [PATCH 10/10] Run ruff format --- services/character.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/character.py b/services/character.py index 1802533..cbfdf9a 100644 --- a/services/character.py +++ b/services/character.py @@ -102,8 +102,8 @@ def preview_stats(self) -> str: "cha": "Харизма", } -def parse_character_data(data: dict) -> CharacterData: +def parse_character_data(data: dict) -> CharacterData: """ Превращает json персонажа Long Story Short в адекватный Pydantic объект """