diff --git a/handlers/admin/campaign_list.py b/handlers/admin/campaign_list.py index 6f63e10..a5af689 100644 --- a/handlers/admin/campaign_list.py +++ b/handlers/admin/campaign_list.py @@ -60,6 +60,7 @@ async def on_campaign_selected( type_factory=UUID, ), hide_on_single_page=True, + width=1, height=5, id="campaigns", ), diff --git a/handlers/player/academy.py b/handlers/player/academy.py index 384af6f..06ff79b 100644 --- a/handlers/player/academy.py +++ b/handlers/player/academy.py @@ -25,7 +25,10 @@ async def on_inventory(c: CallbackQuery, b: Button, m: DialogManager): async def on_update(c: CallbackQuery, b: Button, m: DialogManager): - await m.start(UploadCharacter.upload, data={"source": "user"}) + await m.start( + UploadCharacter.upload, + data={"target_type": TargetType.USER, "target_id": m.middleware_data["user"].id}, + ) async def on_rating(c: CallbackQuery, b: Button, m: DialogManager): diff --git a/handlers/player/academy_campaigns.py b/handlers/player/academy_campaigns.py index b233ef2..3b8efe7 100644 --- a/handlers/player/academy_campaigns.py +++ b/handlers/player/academy_campaigns.py @@ -2,18 +2,16 @@ from uuid import UUID from aiogram import Router -from aiogram.enums import ContentType from aiogram.types import CallbackQuery from aiogram_dialog import Dialog, DialogManager, Window -from aiogram_dialog.api.entities import MediaAttachment from aiogram_dialog.widgets.kbd import Button, Cancel, ScrollingGroup, Select from aiogram_dialog.widgets.media import DynamicMedia from aiogram_dialog.widgets.text import Const, Format -from db.models import Campaign, Participation +from db.models import Participation +from services.campaigns import campaign_getter from states.academy_campaigns import AcademyCampaignPreview, AcademyCampaigns from utils.redirect import redirect -from utils.role import Role logger = logging.getLogger(__name__) router = Router() @@ -34,25 +32,6 @@ async def on_campaign(c: CallbackQuery, b: Button, m: DialogManager, participati ) -async def campaign_getter(dialog_manager: DialogManager, **kwargs): - campaign_id = dialog_manager.start_data.get("campaign_id") - participation_id = dialog_manager.start_data.get("participation_id") - - campaign = await Campaign.get(id=campaign_id) - participation: Participation = await Participation.get(id=participation_id) - - icon = None - if object_name := campaign.icon: - icon = MediaAttachment(type=ContentType.PHOTO, path=f"minio://campaign-icons:{object_name}") - - return { - "title": campaign.title, - "description": campaign.description or "Описание отсутствует", - "icon": icon, - "is_owner": participation.role == Role.OWNER, - } - - router.include_router( Dialog( Window( diff --git a/handlers/player/other_games.py b/handlers/player/other_games.py new file mode 100644 index 0000000..70c6497 --- /dev/null +++ b/handlers/player/other_games.py @@ -0,0 +1,114 @@ +import json +from uuid import UUID + +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 db.models import Campaign, Character, Participation, User +from states.other_games import OtherGames +from states.other_games_campaign import OtherGamesCampaign +from utils.character import CharacterData, parse_character_data + +router = Router() + + +async def main_getter(dialog_manager: DialogManager, **kwargs) -> dict: + user: User = dialog_manager.middleware_data["user"] + + characters: list[Character] = ( + await Character.filter(user=user, campaign__verified=False).prefetch_related("campaign").all() + ) + + characters_data: list[tuple[Character, CharacterData, Campaign]] = [ + (character, parse_character_data(json.loads(character.data["data"])), character.campaign) + for character in characters + ] + + return { + "characters_data": characters_data, + "has_characters": len(characters_data) > 0, + } + + +async def available_campaigns_getter(dialog_manager: DialogManager, **kwargs) -> dict: + user: User = dialog_manager.middleware_data["user"] + + participations: list[tuple[Campaign, Participation]] = [ + (p.campaign, p) + for p in (await Participation.filter(user=user, campaign__verified=False).prefetch_related("campaign").all()) + ] + + return {"participations": participations, "participations_exist": len(participations) > 0} + + +async def on_character_selected(c: CallbackQuery, b: Button, m: DialogManager, character_id: UUID): + pass + + +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): + user: User = m.middleware_data["user"] + participation = await Participation.get(id=participation_id).prefetch_related("campaign") + campaign: Campaign = participation.campaign + character = await Character.get_or_none(user=user, campaign=campaign) + if character is None: + await m.start( + OtherGamesCampaign.preview, data={"campaign_id": campaign.id, "participation_id": participation.id} + ) + else: + ... + + +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", + ), + Button(Const("Посмотреть доступные кампании"), id="available_games", on_click=on_available_games), + Cancel(Const("Назад")), + getter=main_getter, + state=OtherGames.main, + ), + 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", + ), + Back(Const("Назад")), + getter=available_campaigns_getter, + state=OtherGames.available, + ), + ) +) diff --git a/handlers/player/other_games_campaigns.py b/handlers/player/other_games_campaigns.py new file mode 100644 index 0000000..c60d287 --- /dev/null +++ b/handlers/player/other_games_campaigns.py @@ -0,0 +1,50 @@ +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.media import DynamicMedia +from aiogram_dialog.widgets.text import Const, Format + +from db.models import Character +from services.campaigns import campaign_getter +from states.inventory_view import TargetType +from states.other_games_campaign import OtherGamesCampaign +from states.upload_character import UploadCharacter + +router = Router() + + +async def campaign_preview_getter(dialog_manager: DialogManager, **kwargs): + campaign_id = dialog_manager.start_data.get("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, + } + + +async def on_join_campaign(c: CallbackQuery, b: Button, m: DialogManager): + await m.start( + UploadCharacter.upload, + data={ + "target_type": TargetType.CHARACTER, + "target_id": None, + "campaign_id": m.start_data.get("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, + ) + ) +) diff --git a/handlers/player/start.py b/handlers/player/start.py index aa2bdae..6488ed2 100644 --- a/handlers/player/start.py +++ b/handlers/player/start.py @@ -10,7 +10,9 @@ from db.models import Invitation, User from db.models.participation import Participation from states.academy import Academy +from states.inventory_view import TargetType from states.invitation import InvitationAccept +from states.other_games import OtherGames from states.start_simple import StartSimple from states.upload_character import UploadCharacter from utils.redirect import redirect @@ -108,12 +110,16 @@ async def start_simple(message: Message, dialog_manager: DialogManager, user: Us async def on_academy(c: CallbackQuery, b: Button, m: DialogManager): user: User = m.middleware_data["user"] if user.data is None: - await m.start(UploadCharacter.upload, data={"source": "user"}) + await m.start( + UploadCharacter.upload, + data={"target_type": TargetType.USER, "target_id": user.id}, + ) return await m.start(Academy.main) -async def on_other(c: CallbackQuery, b: Button, m: DialogManager): ... +async def on_other(c: CallbackQuery, b: Button, m: DialogManager): + await m.start(OtherGames.main) router.include_router( diff --git a/handlers/player/upload.py b/handlers/player/upload.py index b95b94e..58f629c 100644 --- a/handlers/player/upload.py +++ b/handlers/player/upload.py @@ -1,5 +1,7 @@ import json import logging +from typing import TYPE_CHECKING +from uuid import UUID from aiogram import Router from aiogram.enums import ContentType @@ -8,15 +10,71 @@ 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 db.models import Character from services.character_data import update_char_data +from states.inventory_view import TargetType from states.upload_character import UploadCharacter +if TYPE_CHECKING: + from db.models.base import CharacterData + logger = logging.getLogger(__name__) router = Router() +class UploadCharacterRequest(BaseModel): + target_type: TargetType + target_id: int | UUID | None + campaign_id: UUID | None = None + + @classmethod + @field_validator("target_type", mode="before") + def validate_target_type(cls, v: TargetType | int | str) -> TargetType | None: + if isinstance(v, TargetType): + return v + try: + if isinstance(v, int): + return TargetType(v) + if isinstance(v, str): + try: + return TargetType(int(v)) + except ValueError: + return TargetType[v.upper()] + except (ValueError, KeyError) as e: + msg = f"Invalid target_type: {v}" + raise ValueError(msg) from e + + @classmethod + @field_validator("target_id", mode="wrap") + def validate_target_id(cls, v: int | UUID | None, values: dict) -> UUID | None | int: + if "target_type" not in values: + msg = "target_type is required to be passed" + raise ValueError(msg) + target_type: TargetType = values["target_type"] + if target_type == TargetType.CHARACTER: + if isinstance(v, UUID) or v is None: + return v + msg = "you should provide UUID or None as target_id for CHARACTER target" + raise ValueError(msg) + if target_type == TargetType.USER: + if isinstance(v, int): + return v + msg = "you should provide int as target_id for USER target" + raise ValueError(msg) + msg = "you provided unrecognized target_type" + raise ValueError(msg) + + @classmethod + @field_validator("campaign_id", mode="wrap") + def validate_campaign_id(cls, v: int | None, values: dict) -> int: + if "target_type" in values and values["target_type"] == TargetType.CHARACTER and v is None: + msg = "campaign_id is required for CHARACTER target type" + raise ValueError(msg) + return v + + 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!") @@ -26,15 +84,25 @@ async def upload_document(msg: Message, _: MessageInput, manager: DialogManager) f = await msg.bot.download(msg.document.file_id) content = f.read() - source = manager.start_data.get("source") user = manager.middleware_data["user"] - if source == "user": + request = UploadCharacterRequest(**manager.start_data) + + source: CharacterData + if request.target_type == TargetType.USER: source = user + elif request.target_type == TargetType.CHARACTER: + if request.target_id is None: + source = await Character.create(user=user, campaign_id=request.campaign_id) + else: + source = await Character.get(id=request.target_id) else: - source = await Character.get_or_none(id=source) + logger.error("Failed to find source for user %d", user) + return + if not source: logger.error("Failed to find source for user %d", user) return + try: await update_char_data(source, json.loads(content.decode("utf-8"))) except UnicodeDecodeError: @@ -51,8 +119,7 @@ async def upload_document(msg: Message, _: MessageInput, manager: DialogManager) """ -Этот диалог обязательно должен включать в start_data параметр source, -который должен содержать либо "user", что означает, что нам надо сохранять в юзере, либо же UUID персонажа +Этот диалог обязательно должен включать в start_data параметр request: UploadCharacterRequest """ router.include_router( Dialog( diff --git a/services/campaigns.py b/services/campaigns.py new file mode 100644 index 0000000..3b46571 --- /dev/null +++ b/services/campaigns.py @@ -0,0 +1,25 @@ +from aiogram.enums import ContentType +from aiogram_dialog import DialogManager +from aiogram_dialog.api.entities import MediaAttachment + +from db.models import Campaign, Participation +from utils.role import Role + + +async def campaign_getter(dialog_manager: DialogManager, **kwargs): + campaign_id = dialog_manager.start_data.get("campaign_id") + participation_id = dialog_manager.start_data.get("participation_id") + + campaign = await Campaign.get(id=campaign_id) + participation: Participation = await Participation.get(id=participation_id) + + icon = None + if object_name := campaign.icon: + icon = MediaAttachment(type=ContentType.PHOTO, path=f"minio://campaign-icons:{object_name}") + + return { + "title": campaign.title, + "description": campaign.description or "Описание отсутствует", + "icon": icon, + "is_owner": participation.role == Role.OWNER, + } diff --git a/states/inventory_view.py b/states/inventory_view.py index 0353f38..f4da03c 100644 --- a/states/inventory_view.py +++ b/states/inventory_view.py @@ -10,4 +10,4 @@ class InventoryView(StatesGroup): class TargetType(enum.IntEnum): USER = 0 - CHARACTER = 0 + CHARACTER = 1 diff --git a/states/other_games.py b/states/other_games.py new file mode 100644 index 0000000..aec9118 --- /dev/null +++ b/states/other_games.py @@ -0,0 +1,6 @@ +from aiogram.fsm.state import State, StatesGroup + + +class OtherGames(StatesGroup): + main = State() + available = State() diff --git a/states/other_games_campaign.py b/states/other_games_campaign.py new file mode 100644 index 0000000..130d70a --- /dev/null +++ b/states/other_games_campaign.py @@ -0,0 +1,5 @@ +from aiogram.fsm.state import State, StatesGroup + + +class OtherGamesCampaign(StatesGroup): + preview = State()