diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c1f402d..b47f5be7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,3 +61,29 @@ jobs: run: uv venv && uv pip install .[test] - name: Test with pytest run: source .venv/bin/activate && pytest + docs: + needs: [build] + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - name: Install uv + run: pipx install uv + - name: Install graphviz + run: sudo apt install -y graphviz + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: "pip" + cache-dependency-path: pyproject.toml + - name: Install dependencies + run: uv venv && uv pip install .[test] + - name: Render Aiogram-dialogs transitions + run: source .venv/bin/activate && python -m shvatka.tgbot.dialogs.__init__ + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: transitions + path: out/shvatka-dialogs.png + diff --git a/.gitignore b/.gitignore index 259335a3..6b1453d4 100644 --- a/.gitignore +++ b/.gitignore @@ -150,6 +150,7 @@ dmypy.json /config/ /files/ /local-storage/ +/out/ #pyro stuff *.session diff --git a/pyproject.toml b/pyproject.toml index a360ebc1..9ccc2c27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ test = [ "mypy>=1.1.1,<2.0", "aiogram-tests @ git+https://github.com/bomzheg/aiogram_tests.git@fix/aiogram3rc", "asgi-lifespan>=2.1.0,<3.0", + "aiogram_dialog[tools]>=2.1,<2.2", ] [project.scripts] diff --git a/shvatka/api/models/responses.py b/shvatka/api/models/responses.py index 56af0342..6fe97975 100644 --- a/shvatka/api/models/responses.py +++ b/shvatka/api/models/responses.py @@ -3,12 +3,15 @@ from datetime import datetime from typing import Sequence, Generic +from adaptix import Retort, dumper + from shvatka.core.games.dto import CurrentHints from shvatka.core.models import dto, enums from shvatka.core.models.dto import scn from shvatka.core.models.enums import GameStatus T = typing.TypeVar("T") +retort = Retort(recipe=[dumper(scn.HintsList, lambda x: x.hints)]) @dataclass @@ -76,7 +79,7 @@ class Level: db_id: int name_id: str author: Player - scenario: scn.LevelScenario + scenario: dict[str, typing.Any] game_id: int | None = None number_in_game: int | None = None @@ -88,7 +91,7 @@ def from_core(cls, core: dto.Level | None = None): db_id=core.db_id, name_id=core.name_id, author=Player.from_core(core.author), - scenario=core.scenario, + scenario=retort.dump(core.scenario), game_id=core.game_id, number_in_game=core.number_in_game, ) diff --git a/shvatka/common/data_examples.py b/shvatka/common/data_examples.py index 17122fdb..c0463f17 100644 --- a/shvatka/common/data_examples.py +++ b/shvatka/common/data_examples.py @@ -42,56 +42,58 @@ scenario=scn.LevelScenario( id="level_100", keys={"SH1"}, - time_hints=[ - scn.TimeHint( - time=0, - hint=[ - scn.TextHint( - text="level_100_0", - ), - ], - ), - scn.TimeHint( - time=10, - hint=[ - scn.TextHint( - text="level_100_10", - ), - ], - ), - scn.TimeHint( - time=20, - hint=[ - scn.TextHint( - text="level_100_20", - ), - ], - ), - scn.TimeHint( - time=30, - hint=[ - scn.TextHint( - text="level_100_20", - ), - ], - ), - scn.TimeHint( - time=40, - hint=[ - scn.TextHint( - text="level_100_20", - ), - ], - ), - scn.TimeHint( - time=60, - hint=[ - scn.TextHint( - text="level_100_20", - ), - ], - ), - ], + time_hints=scn.HintsList( + [ + scn.TimeHint( + time=0, + hint=[ + scn.TextHint( + text="level_100_0", + ), + ], + ), + scn.TimeHint( + time=10, + hint=[ + scn.TextHint( + text="level_100_10", + ), + ], + ), + scn.TimeHint( + time=20, + hint=[ + scn.TextHint( + text="level_100_20", + ), + ], + ), + scn.TimeHint( + time=30, + hint=[ + scn.TextHint( + text="level_100_20", + ), + ], + ), + scn.TimeHint( + time=40, + hint=[ + scn.TextHint( + text="level_100_20", + ), + ], + ), + scn.TimeHint( + time=60, + hint=[ + scn.TextHint( + text="level_100_20", + ), + ], + ), + ] + ), ), ), dto.Level( @@ -103,56 +105,58 @@ scenario=scn.LevelScenario( id="level_101", keys={"SH2"}, - time_hints=[ - scn.TimeHint( - time=0, - hint=[ - scn.TextHint( - text="level_101_0", - ), - ], - ), - scn.TimeHint( - time=10, - hint=[ - scn.TextHint( - text="level_101_10", - ), - ], - ), - scn.TimeHint( - time=20, - hint=[ - scn.TextHint( - text="level_101_20", - ), - ], - ), - scn.TimeHint( - time=30, - hint=[ - scn.TextHint( - text="level_101_20", - ), - ], - ), - scn.TimeHint( - time=40, - hint=[ - scn.TextHint( - text="level_101_20", - ), - ], - ), - scn.TimeHint( - time=60, - hint=[ - scn.TextHint( - text="level_101_20", - ), - ], - ), - ], + time_hints=scn.HintsList( + [ + scn.TimeHint( + time=0, + hint=[ + scn.TextHint( + text="level_101_0", + ), + ], + ), + scn.TimeHint( + time=10, + hint=[ + scn.TextHint( + text="level_101_10", + ), + ], + ), + scn.TimeHint( + time=20, + hint=[ + scn.TextHint( + text="level_101_20", + ), + ], + ), + scn.TimeHint( + time=30, + hint=[ + scn.TextHint( + text="level_101_20", + ), + ], + ), + scn.TimeHint( + time=40, + hint=[ + scn.TextHint( + text="level_101_20", + ), + ], + ), + scn.TimeHint( + time=60, + hint=[ + scn.TextHint( + text="level_101_20", + ), + ], + ), + ] + ), ), ), dto.Level( @@ -164,56 +168,58 @@ scenario=scn.LevelScenario( id="level_102", keys={"SH3"}, - time_hints=[ - scn.TimeHint( - time=0, - hint=[ - scn.TextHint( - text="level_102_0", - ), - ], - ), - scn.TimeHint( - time=10, - hint=[ - scn.TextHint( - text="level_102_10", - ), - ], - ), - scn.TimeHint( - time=20, - hint=[ - scn.TextHint( - text="level_102_20", - ), - ], - ), - scn.TimeHint( - time=30, - hint=[ - scn.TextHint( - text="level_102_20", - ), - ], - ), - scn.TimeHint( - time=40, - hint=[ - scn.TextHint( - text="level_102_20", - ), - ], - ), - scn.TimeHint( - time=60, - hint=[ - scn.TextHint( - text="level_102_20", - ), - ], - ), - ], + time_hints=scn.HintsList( + [ + scn.TimeHint( + time=0, + hint=[ + scn.TextHint( + text="level_102_0", + ), + ], + ), + scn.TimeHint( + time=10, + hint=[ + scn.TextHint( + text="level_102_10", + ), + ], + ), + scn.TimeHint( + time=20, + hint=[ + scn.TextHint( + text="level_102_20", + ), + ], + ), + scn.TimeHint( + time=30, + hint=[ + scn.TextHint( + text="level_102_20", + ), + ], + ), + scn.TimeHint( + time=40, + hint=[ + scn.TextHint( + text="level_102_20", + ), + ], + ), + scn.TimeHint( + time=60, + hint=[ + scn.TextHint( + text="level_102_20", + ), + ], + ), + ] + ), ), ), dto.Level( @@ -225,56 +231,58 @@ scenario=scn.LevelScenario( id="level_103", keys={"SH4"}, - time_hints=[ - scn.TimeHint( - time=0, - hint=[ - scn.TextHint( - text="level_103_0", - ), - ], - ), - scn.TimeHint( - time=10, - hint=[ - scn.TextHint( - text="level_103_10", - ), - ], - ), - scn.TimeHint( - time=20, - hint=[ - scn.TextHint( - text="level_103_20", - ), - ], - ), - scn.TimeHint( - time=30, - hint=[ - scn.TextHint( - text="level_103_20", - ), - ], - ), - scn.TimeHint( - time=40, - hint=[ - scn.TextHint( - text="level_103_20", - ), - ], - ), - scn.TimeHint( - time=60, - hint=[ - scn.TextHint( - text="level_103_20", - ), - ], - ), - ], + time_hints=scn.HintsList( + [ + scn.TimeHint( + time=0, + hint=[ + scn.TextHint( + text="level_103_0", + ), + ], + ), + scn.TimeHint( + time=10, + hint=[ + scn.TextHint( + text="level_103_10", + ), + ], + ), + scn.TimeHint( + time=20, + hint=[ + scn.TextHint( + text="level_103_20", + ), + ], + ), + scn.TimeHint( + time=30, + hint=[ + scn.TextHint( + text="level_103_20", + ), + ], + ), + scn.TimeHint( + time=40, + hint=[ + scn.TextHint( + text="level_103_20", + ), + ], + ), + scn.TimeHint( + time=60, + hint=[ + scn.TextHint( + text="level_103_20", + ), + ], + ), + ] + ), ), ), ], diff --git a/shvatka/common/factory.py b/shvatka/common/factory.py index 73caea12..4ecc19d1 100644 --- a/shvatka/common/factory.py +++ b/shvatka/common/factory.py @@ -1,10 +1,29 @@ +import typing + +import adaptix import dataclass_factory +from adaptix import ( + Retort, + validator, + P, + name_mapping, + loader, + Chain, + dumper, +) +from adaptix.load_error import LoadError +from adaptix._internal.morphing.provider_template import ABCProxy from dataclass_factory import Schema, NameStyle from dishka import Provider, Scope, provide from telegraph.aio import Telegraph from shvatka.common.url_factory import UrlFactory +from shvatka.core.models.dto import scn +from shvatka.core.models.dto.scn import HintsList, TimeHint from shvatka.core.models.schems import schemas +from shvatka.core.utils import exceptions +from shvatka.core.utils.input_validation import validate_level_id, is_multiple_keys_normal +from shvatka.core.views.texts import INVALID_KEY_ERROR from shvatka.tgbot.config.models.bot import BotConfig @@ -17,6 +36,13 @@ def create_telegraph(self, bot_config: BotConfig) -> Telegraph: return telegraph +REQUIRED_GAME_RECIPES = [ + loader(HintsList, lambda x: HintsList(x), Chain.LAST), + ABCProxy(HintsList, list[TimeHint]), # internal class, can be broken in next version adaptix + dumper(set, lambda x: tuple(x)), +] + + class DCFProvider(Provider): scope = Scope.APP @@ -28,6 +54,38 @@ def create_dataclass_factory(self) -> dataclass_factory.Factory: ) return dcf + @provide + def create_retort(self) -> Retort: + retort = Retort( + recipe=[ + name_mapping( + name_style=adaptix.NameStyle.LOWER_KEBAB, + ), + *REQUIRED_GAME_RECIPES, + validator( + pred=P[scn.LevelScenario].id, + func=lambda x: validate_level_id(x) is not None, + error=lambda x: typing.cast( + LoadError, + exceptions.ScenarioNotCorrect( + name_id=x, text=f"name_id ({x}) not correct" + ), + ), + ), + validator( + pred=P[scn.LevelScenario].keys, + func=is_multiple_keys_normal, + error=lambda x: typing.cast( + LoadError, + exceptions.ScenarioNotCorrect( + notify_user=INVALID_KEY_ERROR, text="invalid keys" + ), + ), + ), + ] + ) + return retort + class UrlProvider(Provider): scope = Scope.APP diff --git a/shvatka/core/models/dto/scn/__init__.py b/shvatka/core/models/dto/scn/__init__.py index bbd629bd..bc3bd059 100644 --- a/shvatka/core/models/dto/scn/__init__.py +++ b/shvatka/core/models/dto/scn/__init__.py @@ -25,6 +25,6 @@ PhotoHint, ContactHint, ) -from .level import LevelScenario, SHKey, BonusKey +from .level import LevelScenario, SHKey, BonusKey, HintsList from .parsed_zip import ParsedZip from .time_hint import TimeHint diff --git a/shvatka/core/models/dto/scn/level.py b/shvatka/core/models/dto/scn/level.py index 7133b75b..30e9f4a4 100644 --- a/shvatka/core/models/dto/scn/level.py +++ b/shvatka/core/models/dto/scn/level.py @@ -1,7 +1,13 @@ import typing +from collections.abc import Sequence from dataclasses import dataclass, field from datetime import timedelta +from typing import overload +from mypy.server.objgraph import Iterable + +from shvatka.core.utils import exceptions +from .hint_part import AnyHint from .time_hint import TimeHint, EnumeratedTimeHint SHKey: typing.TypeAlias = str @@ -21,10 +27,98 @@ def __hash__(self) -> int: return hash(self.text) +class HintsList(Sequence[TimeHint]): + def __init__(self, hints: list[TimeHint]): + self.verify(hints) + self.hints = hints + + @classmethod + def parse(cls, hints: list[TimeHint]): + return cls(cls.normalize(hints)) + + @staticmethod + def normalize(hints: list[TimeHint]) -> list[TimeHint]: + hint_map: dict[int, list[AnyHint]] = {} + for hint in hints: + if not hint.hint: + continue + hint_map.setdefault(hint.time, []).extend(hint.hint) + return [TimeHint(k, v) for k, v in sorted(hint_map.items(), key=lambda x: x[0])] + + @staticmethod + def verify(hints: Iterable[TimeHint]) -> None: + times: set[int] = set() + for hint in hints: + if hint.time in times: + raise exceptions.LevelError( + text=f"Contains multiple times hints for time {hint.time}" + ) + times.add(hint.time) + if not hint.hint: + raise exceptions.LevelError(text=f"There is no hint for time {hint.time}") + + def get_hint_by_time(self, time: timedelta) -> EnumeratedTimeHint: + hint = self.hints[0] + number = 0 + for i, h in enumerate(self.hints): + if timedelta(minutes=h.time) < time: + hint = h + number = i + else: + break + return EnumeratedTimeHint(time=hint.time, hint=hint.hint, number=number) + + def get_hints_for_timedelta(self, delta: timedelta) -> list[TimeHint]: + minutes = delta.total_seconds() // 60 + return [th for th in self.hints if th.time <= minutes] + + def replace(self, old: TimeHint, new: TimeHint) -> "HintsList": + for i, hint in enumerate(self.hints): + if hint.time == old.time: + old_index = i + break + else: + old_index = None + if old_index is None: + raise exceptions.LevelError( + text=f"can't replace, there is no hints for time {old.time}" + ) + result = self.hints[0:old_index] + self.hints[old_index + 1 :] + [new] + return self.__class__(self.normalize(result)) + + @property + def hints_count(self) -> int: + return sum(time_hint.hints_count for time_hint in self.hints) + + @overload + def __getitem__(self, index: int) -> TimeHint: + return self.hints[index] + + @overload + def __getitem__(self, index: slice) -> Sequence[TimeHint]: + return self.hints[index] + + def __getitem__(self, index): + return self.hints[index] + + def __len__(self): + return len(self.hints) + + def __eq__(self, other): + if isinstance(other, HintsList): + return self.hints == other.hints + if isinstance(other, list): + return self.hints == other + return NotImplemented + + def __repr__(self): + return repr(self.hints) + + @dataclass class LevelScenario: id: str - time_hints: list[TimeHint] + time_hints: HintsList keys: set[SHKey] = field(default_factory=set) bonus_keys: set[BonusKey] = field(default_factory=set) @@ -32,15 +126,7 @@ def get_hint(self, hint_number: int) -> TimeHint: return self.time_hints[hint_number] def get_hint_by_time(self, time: timedelta) -> EnumeratedTimeHint: - hint = self.time_hints[0] - number = 0 - for i, h in enumerate(self.time_hints): - if timedelta(minutes=h.time) < time: - hint = h - number = i - else: - break - return EnumeratedTimeHint(time=hint.time, hint=hint.hint, number=number) + return self.time_hints.get_hint_by_time(time) def is_last_hint(self, hint_number: int) -> bool: return len(self.time_hints) == hint_number + 1 @@ -59,8 +145,7 @@ def get_guids(self) -> list[str]: @property def hints_count(self) -> int: - return sum(time_hint.hints_count for time_hint in self.time_hints) + return self.time_hints.hints_count def get_hints_for_timedelta(self, delta: timedelta) -> list[TimeHint]: - minutes = delta.total_seconds() // 60 - return [th for th in self.time_hints if th.time <= minutes] + return self.time_hints.get_hints_for_timedelta(delta) diff --git a/shvatka/core/models/dto/scn/time_hint.py b/shvatka/core/models/dto/scn/time_hint.py index 50ce427e..b6bf80d2 100644 --- a/shvatka/core/models/dto/scn/time_hint.py +++ b/shvatka/core/models/dto/scn/time_hint.py @@ -1,5 +1,6 @@ from dataclasses import dataclass +from shvatka.core.utils import exceptions from .hint_part import AnyHint @@ -8,6 +9,9 @@ class TimeHint: time: int hint: list[AnyHint] + def __post_init__(self): + _check_hint_not_empty(self.hint) + def get_guids(self) -> list[str]: guids = [] for hint in self.hint: @@ -18,7 +22,28 @@ def get_guids(self) -> list[str]: def hints_count(self) -> int: return len(self.hint) + def update_time(self, new_time: int) -> None: + if not self.can_update_time(): + raise exceptions.LevelError(text="Невозможно отредактировать загадку уровня") + if new_time == 0: + raise exceptions.LevelError(text="Нельзя заменить таким способом загадку уровня") + self.time = new_time + + def update_hint(self, new_hint: list[AnyHint]) -> None: + _check_hint_not_empty(new_hint) + self.hint = new_hint + + def can_update_time(self) -> bool: + return self.time != 0 + @dataclass class EnumeratedTimeHint(TimeHint): number: int + + +def _check_hint_not_empty(new_hint: list[AnyHint]) -> None: + if not new_hint: + raise exceptions.LevelError( + text="Нельзя установить пустой список подсказок. Вероятно нужно было удалить?" + ) diff --git a/shvatka/core/services/game.py b/shvatka/core/services/game.py index 03542209..3c7eb7a9 100644 --- a/shvatka/core/services/game.py +++ b/shvatka/core/services/game.py @@ -1,6 +1,6 @@ from datetime import datetime -from dataclass_factory import Factory +from adaptix import Retort from shvatka.core.interfaces.clients.file_storage import FileGateway from shvatka.core.interfaces.dal.complex import GameCompleter, GamePackager @@ -37,11 +37,11 @@ async def upsert_game( raw_scn: scn.RawGameScenario, author: dto.Player, dao: GameUpserter, - dcf: Factory, + retort: Retort, file_gateway: FileGateway, ) -> dto.FullGame: check_allow_be_author(author) - game_scn = parse_uploaded_game(raw_scn, dcf) + game_scn = parse_uploaded_game(raw_scn, retort) if not await dao.is_name_available(name=game_scn.name): if not await dao.is_author_game_by_name(name=game_scn.name, author=author): raise CantEditGame( @@ -121,7 +121,7 @@ async def get_game_package( id_: int, author: dto.Player, dao: GamePackager, - dcf: Factory, + retort: Retort, file_gateway: FileGateway, ) -> scn.RawGameScenario: game = await dao.get_full(id_=id_) @@ -151,7 +151,9 @@ async def get_game_package( ) else: game_stat = None - return scn.RawGameScenario(scn=dcf.dump(scenario), files=contents, stat=dcf.dump(game_stat)) + return scn.RawGameScenario( + scn=retort.dump(scenario), files=contents, stat=retort.dump(game_stat) + ) async def get_active(dao: ActiveGameFinder) -> dto.Game | None: diff --git a/shvatka/core/services/level.py b/shvatka/core/services/level.py index 71b75cd7..edaa11ff 100644 --- a/shvatka/core/services/level.py +++ b/shvatka/core/services/level.py @@ -1,4 +1,4 @@ -from dataclass_factory import Factory +from adaptix import Retort from shvatka.core.interfaces.dal.level import ( LevelUpserter, @@ -15,10 +15,10 @@ async def upsert_raw_level( - level_data: dict, author: dto.Player, dcf: Factory, dao: LevelUpserter + level_data: dict, author: dto.Player, retort: Retort, dao: LevelUpserter ) -> dto.Level: check_allow_be_author(author) - scenario = load_level(level_data, dcf) + scenario = load_level(level_data, retort) return await upsert_level(author, scenario, dao) diff --git a/shvatka/core/services/scenario/game_ops.py b/shvatka/core/services/scenario/game_ops.py index e045d18d..b77d4778 100644 --- a/shvatka/core/services/scenario/game_ops.py +++ b/shvatka/core/services/scenario/game_ops.py @@ -1,4 +1,4 @@ -from dataclass_factory import Factory +from adaptix import Retort from shvatka.core.models.dto import scn from shvatka.core.services.scenario.level_ops import ( @@ -6,16 +6,16 @@ ) -def parse_game(game: scn.RawGameScenario, dcf: Factory) -> scn.GameScenario: - return dcf.load(game.scn, scn.GameScenario) +def parse_game(game: scn.RawGameScenario, retort: Retort) -> scn.GameScenario: + return retort.load(game.scn, scn.GameScenario) -def parse_uploaded_game(game: scn.RawGameScenario, dcf: Factory) -> scn.UploadedGameScenario: - return dcf.load(game.scn, scn.UploadedGameScenario) +def parse_uploaded_game(game: scn.RawGameScenario, retort: Retort) -> scn.UploadedGameScenario: + return retort.load(game.scn, scn.UploadedGameScenario) -def serialize(game: scn.FullGameScenario, dcf: Factory) -> dict: - return dcf.dump(game) +def serialize(game: scn.FullGameScenario, retort: Retort) -> dict: + return retort.dump(game) def check_all_files_saved(game: scn.GameScenario, guids: set[str]): diff --git a/shvatka/core/services/scenario/level_ops.py b/shvatka/core/services/scenario/level_ops.py index 57651d9d..b4363586 100644 --- a/shvatka/core/services/scenario/level_ops.py +++ b/shvatka/core/services/scenario/level_ops.py @@ -1,14 +1,14 @@ -import dataclass_factory -from dataclass_factory import Factory +from adaptix import Retort +from adaptix.load_error import LoadError from shvatka.core.models.dto import scn from shvatka.core.utils import exceptions -def load_level(dct: dict, dcf: Factory) -> scn.LevelScenario: +def load_level(dct: dict, retort: Retort) -> scn.LevelScenario: try: - return dcf.load(dct, scn.LevelScenario) - except dataclass_factory.PARSER_EXCEPTIONS as e: + return retort.load(dct, scn.LevelScenario) + except LoadError as e: raise exceptions.ScenarioNotCorrect(notify_user="невалидный уровень") from e diff --git a/shvatka/core/utils/exceptions.py b/shvatka/core/utils/exceptions.py index 48bc27e6..02b26486 100644 --- a/shvatka/core/utils/exceptions.py +++ b/shvatka/core/utils/exceptions.py @@ -1,6 +1,8 @@ +import typing from typing import Any -from shvatka.core.models import dto +if typing.TYPE_CHECKING: + from shvatka.core.models import dto class SHError(Exception): @@ -14,10 +16,10 @@ def __init__( chat_id: int | None = None, team_id: int | None = None, game_id: int | None = None, - user: dto.User | None = None, - player: dto.Player | None = None, - chat: dto.Chat | None = None, - team: dto.Team | None = None, + user: "dto.User | None" = None, + player: "dto.Player | None" = None, + chat: "dto.Chat | None" = None, + team: "dto.Team | None" = None, game: Any | None = None, alarm: bool | None = False, notify_user: str | None = None, @@ -163,7 +165,7 @@ class GameNotFound(GameError, AttributeError): class LevelError(SHError): notify_user = "Ошибка связанная с уровнем" - def __init__(self, level_id: int, *args, **kwargs) -> None: + def __init__(self, level_id: int | None = None, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.level_id = level_id diff --git a/shvatka/infrastructure/crawler/game_scn/loader/load_scns.py b/shvatka/infrastructure/crawler/game_scn/loader/load_scns.py index 2b5b7cc1..e0206bc1 100644 --- a/shvatka/infrastructure/crawler/game_scn/loader/load_scns.py +++ b/shvatka/infrastructure/crawler/game_scn/loader/load_scns.py @@ -6,7 +6,7 @@ from typing import BinaryIO, Any, Callable, Coroutine from zipfile import Path as ZipPath -from dataclass_factory import Factory +from adaptix import Retort from dishka import make_async_container from shvatka.common.config.parser.logging_config import setup_logging @@ -50,7 +50,7 @@ async def main(): bot_player=bot_player, dao=dao, file_gateway=file_gateway, - dcf=await dishka.get(Factory), + retort=await dishka.get(Retort), path=config.file_storage_config.path.parent / "scn", ) finally: @@ -61,7 +61,7 @@ async def load_scns( bot_player: dto.Player, dao: HolderDao, file_gateway: FileGateway, - dcf: Factory, + retort: Retort, path: Path, ): files = sorted(path.glob("*.zip"), key=lambda p: int(p.stem)) @@ -73,12 +73,12 @@ async def load_scns( player=bot_player, dao=dao, file_gateway=file_gateway, - dcf=dcf, + retort=retort, zip_scn=game_zip_scn, ) if not game: continue - results = load_results(game_zip_scn, dcf) + results = load_results(game_zip_scn, retort) await dao.game.set_completed(game) await set_results(game, results, dao) await dao.commit() @@ -192,7 +192,7 @@ async def transfer_ownership(game: dto.FullGame, bot_player: dto.Player, dao: Ho await dao.game.transfer(game, bot_player) -def load_results(game_zip_scn: BinaryIO, dcf: Factory) -> GameStat: +def load_results(game_zip_scn: BinaryIO, retort: Retort) -> GameStat: zip_path = ZipPath(game_zip_scn) for unpacked_file in zip_path.iterdir(): if not unpacked_file.is_file(): @@ -200,7 +200,7 @@ def load_results(game_zip_scn: BinaryIO, dcf: Factory) -> GameStat: if unpacked_file.name != "results.json": continue with unpacked_file.open("r", encoding="utf8") as results_file: - results = dcf.load(json.load(results_file), GameStat) + results = retort.load(json.load(results_file), GameStat) return results raise ValueError("no results found") @@ -209,12 +209,12 @@ async def load_scn( player: dto.Player, dao: HolderDao, file_gateway: FileGateway, - dcf: Factory, + retort: Retort, zip_scn: BinaryIO, ) -> dto.FullGame | None: try: with unpack_scn(ZipPath(zip_scn)).open() as scenario: # type: scn.RawGameScenario - game = await upsert_game(scenario, player, dao.game_upserter, dcf, file_gateway) + game = await upsert_game(scenario, player, dao.game_upserter, retort, file_gateway) except exceptions.ScenarioNotCorrect as e: logger.error("game scenario from player %s has problems", player.id, exc_info=e) return None diff --git a/shvatka/infrastructure/db/models/level.py b/shvatka/infrastructure/db/models/level.py index ff781794..7d479d3c 100644 --- a/shvatka/infrastructure/db/models/level.py +++ b/shvatka/infrastructure/db/models/level.py @@ -1,13 +1,14 @@ import typing from typing import Any -from dataclass_factory import Factory +from adaptix import Retort, dumper from sqlalchemy import Integer, Text, ForeignKey, JSON, TypeDecorator, UniqueConstraint from sqlalchemy.engine import Dialect from sqlalchemy.orm import relationship, mapped_column, Mapped +from shvatka.common.factory import REQUIRED_GAME_RECIPES from shvatka.core.models import dto -from shvatka.core.models.dto.scn.level import LevelScenario +from shvatka.core.models.dto import scn from shvatka.infrastructure.db.models import Base if typing.TYPE_CHECKING: @@ -18,20 +19,22 @@ class ScenarioField(TypeDecorator): impl = JSON cache_ok = True - dcf = Factory() + retort = Retort( + recipe=[*REQUIRED_GAME_RECIPES, dumper(set, lambda x: list(x))], + ) def coerce_compared_value(self, op: Any, value: Any): - if isinstance(value, LevelScenario): + if isinstance(value, scn.LevelScenario): return self return self.impl().coerce_compared_value(op=op, value=value) - def process_bind_param(self, value: LevelScenario | None, dialect: Dialect): - return self.dcf.dump(value, LevelScenario) + def process_bind_param(self, value: scn.LevelScenario | None, dialect: Dialect): + return self.retort.dump(value, scn.LevelScenario) - def process_result_value(self, value: Any, dialect: Dialect) -> LevelScenario | None: + def process_result_value(self, value: Any, dialect: Dialect) -> scn.LevelScenario | None: if value is None: return None - return self.dcf.load(value, LevelScenario) + return self.retort.load(value, scn.LevelScenario) class Level(Base): @@ -52,7 +55,7 @@ class Level(Base): back_populates="my_levels", ) number_in_game = mapped_column(Integer, nullable=True) - scenario: Mapped[LevelScenario] = mapped_column(ScenarioField) + scenario: Mapped[scn.LevelScenario] = mapped_column(ScenarioField) __table_args__ = (UniqueConstraint("author_id", "name_id"),) diff --git a/shvatka/tgbot/dialogs/__init__.py b/shvatka/tgbot/dialogs/__init__.py index 9e517dbe..f866df98 100644 --- a/shvatka/tgbot/dialogs/__init__.py +++ b/shvatka/tgbot/dialogs/__init__.py @@ -2,6 +2,8 @@ from aiogram.enums import ChatType from aiogram_dialog import setup_dialogs from aiogram_dialog.api.protocols import MessageManagerProtocol, BgManagerFactory +from aiogram_dialog.manager.message_manager import MessageManager +from aiogram_dialog.tools import render_transitions from shvatka.tgbot.dialogs import ( game_orgs, @@ -60,3 +62,13 @@ def setup_active_game_dialogs() -> Router: router = Router(name=__name__ + ".game.running") game_spy.setup(router) return router + + +def render_all(): + router = Router(name="main") + setup(router, MessageManager()) + render_transitions(router, title="Shvatka", filename="out/shvatka-dialogs") + + +if __name__ == "__main__": + render_all() diff --git a/shvatka/tgbot/dialogs/game_manage/dialogs.py b/shvatka/tgbot/dialogs/game_manage/dialogs.py index b82cbb8f..fc3b0f62 100644 --- a/shvatka/tgbot/dialogs/game_manage/dialogs.py +++ b/shvatka/tgbot/dialogs/game_manage/dialogs.py @@ -47,8 +47,7 @@ to_publish_game_forum, complete_game_handler, ) -from shvatka.tgbot.dialogs.preview_data import PREVIEW_GAME - +from shvatka.tgbot.dialogs.preview_data import PREVIEW_GAME, PreviewSwitchTo, PreviewStart games = Dialog( Window( @@ -67,8 +66,9 @@ ), Cancel(Const("🔙Назад")), state=states.CompletedGamesPanelSG.list, - preview_data={"games": [PREVIEW_GAME]}, getter=get_games, + preview_data={"games": [PREVIEW_GAME]}, + preview_add_transitions=[PreviewSwitchTo(states.CompletedGamesPanelSG.game)], ), Window( Jinja( @@ -117,8 +117,11 @@ state=states.CompletedGamesPanelSG.list, ), state=states.CompletedGamesPanelSG.game, - preview_data={"game": PREVIEW_GAME}, getter=get_completed_game, + preview_data={"game": PREVIEW_GAME}, + preview_add_transitions=[ + PreviewStart(states.GameOrgsSG.orgs_list), + ], ), Window( Jinja( @@ -237,8 +240,9 @@ ), Cancel(Const("🔙Назад")), state=states.MyGamesPanelSG.choose_game, - preview_data={"games": [PREVIEW_GAME]}, getter=get_my_games, + preview_data={"games": [PREVIEW_GAME]}, + preview_add_transitions=[PreviewSwitchTo(states.MyGamesPanelSG.game_menu)], ), Window( Jinja( @@ -320,8 +324,16 @@ state=states.MyGamesPanelSG.choose_game, ), state=states.MyGamesPanelSG.game_menu, - preview_data={"game": PREVIEW_GAME}, getter=get_game, + preview_data={"game": PREVIEW_GAME}, + preview_add_transitions=[ + PreviewStart(states.GameEditSG.current_levels), + PreviewStart(states.GameOrgsSG.orgs_list), + PreviewStart(states.GamePublishSG.prepare), + PreviewStart(states.GamePublishSG.forum), + Cancel(), + PreviewStart(states.GameScheduleSG.date), + ], ), Window( Jinja("Чтобы переименовать игру {{game.name}} пришли новое имя"), @@ -339,8 +351,9 @@ Calendar(id="select_game_play_date", on_click=select_date), Cancel(Const("🔙Назад")), state=states.GameScheduleSG.date, - preview_data={"game": PREVIEW_GAME}, getter=get_game, + preview_data={"game": PREVIEW_GAME}, + preview_add_transitions=[PreviewSwitchTo(states.GameScheduleSG.time)], ), Window( Case( diff --git a/shvatka/tgbot/dialogs/game_manage/getters.py b/shvatka/tgbot/dialogs/game_manage/getters.py index 26e497a3..e3b79f22 100644 --- a/shvatka/tgbot/dialogs/game_manage/getters.py +++ b/shvatka/tgbot/dialogs/game_manage/getters.py @@ -5,6 +5,7 @@ from aiogram_dialog import DialogManager from aiogram_dialog.api.entities import MediaAttachment, MediaId from dishka import AsyncContainer +from dishka.integrations.aiogram import CONTAINER_NAME from telegraph import Telegraph from shvatka.common.url_factory import UrlFactory @@ -30,7 +31,7 @@ async def get_completed_game(dao: HolderDao, dialog_manager: DialogManager, **_) game_id = ( dialog_manager.dialog_data.get("game_id", None) or dialog_manager.start_data["game_id"] ) - dishka: AsyncContainer = dialog_manager.middleware_data["dishka_container"] + dishka: AsyncContainer = dialog_manager.middleware_data[CONTAINER_NAME] url_factory = await dishka.get(UrlFactory) return { "game": await game.get_game( diff --git a/shvatka/tgbot/dialogs/game_manage/handlers.py b/shvatka/tgbot/dialogs/game_manage/handlers.py index a724002b..a97a38f3 100644 --- a/shvatka/tgbot/dialogs/game_manage/handlers.py +++ b/shvatka/tgbot/dialogs/game_manage/handlers.py @@ -3,10 +3,10 @@ from io import BytesIO from typing import Any +from adaptix import Retort from aiogram.types import CallbackQuery, Message, BufferedInputFile from aiogram_dialog import DialogManager from aiogram_dialog.widgets.kbd import Button -from dataclass_factory import Factory from shvatka.core.interfaces.clients.file_storage import FileGateway from shvatka.core.interfaces.scheduler import Scheduler @@ -80,9 +80,9 @@ async def show_my_zip_scn(c: CallbackQuery, widget: Button, manager: DialogManag async def common_show_zip(c: CallbackQuery, game_id: int, manager: DialogManager): player: dto.Player = manager.middleware_data["player"] dao: HolderDao = manager.middleware_data["dao"] - dcf: Factory = manager.middleware_data["dcf"] + retort: Retort = manager.middleware_data["retort"] file_gateway: FileGateway = manager.middleware_data["file_gateway"] - game_ = await game.get_game_package(game_id, player, dao.game_packager, dcf, file_gateway) + game_ = await game.get_game_package(game_id, player, dao.game_packager, retort, file_gateway) zip_ = pack_scn(game_) assert isinstance(c.message, Message) await c.message.answer_document(BufferedInputFile(file=zip_.read(), filename="scenario.zip")) diff --git a/shvatka/tgbot/dialogs/game_scn/dialogs.py b/shvatka/tgbot/dialogs/game_scn/dialogs.py index 6fdff055..5bccf7af 100644 --- a/shvatka/tgbot/dialogs/game_scn/dialogs.py +++ b/shvatka/tgbot/dialogs/game_scn/dialogs.py @@ -8,12 +8,14 @@ Select, Cancel, SwitchTo, + Next, ) from aiogram_dialog.widgets.text import Const, Format, Jinja from shvatka.tgbot import states from .getters import get_game_name, select_my_levels, select_full_game from .handlers import process_name, save_game, edit_level, add_level_handler, process_zip_scn +from shvatka.tgbot.dialogs.preview_data import PreviewStart game_writer = Dialog( Window( @@ -33,6 +35,7 @@ SwitchTo(Const("Загрузить из zip"), id="game_from_zip", state=states.GameWriteSG.from_zip), Cancel(Const("🔙Отменить")), state=states.GameWriteSG.game_name, + preview_add_transitions=[Next()], ), Window( Jinja("Игра {{game_name}}\n\n"), @@ -91,6 +94,9 @@ Cancel(Const("🔙Назад")), state=states.GameEditSG.current_levels, getter=select_full_game, + preview_add_transitions=[ + PreviewStart(states.LevelTestSG.wait_key), + ], ), Window( Jinja("Игра {{game.name}}\n\n"), diff --git a/shvatka/tgbot/dialogs/game_scn/handlers.py b/shvatka/tgbot/dialogs/game_scn/handlers.py index 1a529e2b..4ffaf4a4 100644 --- a/shvatka/tgbot/dialogs/game_scn/handlers.py +++ b/shvatka/tgbot/dialogs/game_scn/handlers.py @@ -3,12 +3,12 @@ from typing import Any from zipfile import Path as ZipPath +from adaptix import Retort from aiogram import Bot from aiogram.types import Message, CallbackQuery from aiogram.utils.text_decorations import html_decoration as hd from aiogram_dialog import DialogManager from aiogram_dialog.widgets.kbd import Button, ManagedMultiselect -from dataclass_factory import Factory from shvatka.core.interfaces.clients.file_storage import FileGateway from shvatka.core.models import dto @@ -57,12 +57,12 @@ async def process_zip_scn(m: Message, dialog_: Any, manager: DialogManager): dao: HolderDao = manager.middleware_data["dao"] bot: Bot = manager.middleware_data["bot"] file_gateway: FileGateway = manager.middleware_data["file_gateway"] - dcf: Factory = manager.middleware_data["dcf"] + retort: Retort = manager.middleware_data["retort"] assert m.document document = await bot.download(m.document.file_id) try: with unpack_scn(ZipPath(document)).open() as scenario: # type: scn.RawGameScenario - game = await upsert_game(scenario, player, dao.game_upserter, dcf, file_gateway) + game = await upsert_game(scenario, player, dao.game_upserter, retort, file_gateway) except ScenarioNotCorrect as e: await m.reply(f"Ошибка {e}\n попробуйте исправить файл") logger.error("game scenario from player %s has problems", player.id, exc_info=e) diff --git a/shvatka/tgbot/dialogs/level_manage/dialogs.py b/shvatka/tgbot/dialogs/level_manage/dialogs.py index 7b1c6e88..9cc1f2bc 100644 --- a/shvatka/tgbot/dialogs/level_manage/dialogs.py +++ b/shvatka/tgbot/dialogs/level_manage/dialogs.py @@ -19,6 +19,7 @@ unlink_level_handler, delete_level_handler, ) +from shvatka.tgbot.dialogs.preview_data import PreviewStart levels_list = Dialog( Window( @@ -38,13 +39,21 @@ Cancel(Const("🔙Назад")), state=states.LevelListSG.levels, getter=get_levels, + preview_add_transitions=[PreviewStart(states.LevelManageSG.menu)], ), ) level_manage = Dialog( Window( - Jinja("Уровень {{level.name_id}}\n{{rendered}}"), + Jinja( + "Уровень {{level.name_id}}\n" + "{% if time_hints %}" + "{{time_hints | time_hints}}" + "{% else %}" + "пока нет ни одной подсказки" + "{% endif %}" + ), Button( Const("✏Редактирование"), id="level_edit", @@ -82,6 +91,10 @@ Cancel(Const("🔙Назад")), state=states.LevelManageSG.menu, getter=get_level_id, + preview_add_transitions=[ + PreviewStart(states.LevelEditSg.menu), + PreviewStart(states.LevelTestSG.wait_key), + ], ), Window( Jinja( @@ -119,5 +132,6 @@ ), getter=get_level_id, state=states.LevelTestSG.wait_key, + preview_add_transitions=[Cancel()], ), ) diff --git a/shvatka/tgbot/dialogs/level_manage/getters.py b/shvatka/tgbot/dialogs/level_manage/getters.py index 6037afcc..af225d6b 100644 --- a/shvatka/tgbot/dialogs/level_manage/getters.py +++ b/shvatka/tgbot/dialogs/level_manage/getters.py @@ -6,7 +6,6 @@ from shvatka.core.services.level import get_by_id, get_level_by_id_for_org, get_all_my_free_levels from shvatka.core.services.organizers import get_org_by_id, get_by_player from shvatka.infrastructure.db.dao.holder import HolderDao -from shvatka.tgbot.views.utils import render_time_hints async def get_level_id(dao: HolderDao, dialog_manager: DialogManager, **_): @@ -15,8 +14,8 @@ async def get_level_id(dao: HolderDao, dialog_manager: DialogManager, **_): hints = level.scenario.time_hints return { "level": level, + "time_hints": hints, "org": org, - "rendered": render_time_hints(hints) if hints else "пока нет ни одной подсказки", } @@ -38,15 +37,14 @@ async def get_level_and_org( dao: HolderDao, manager: DialogManager, ) -> tuple[dto.Level, dto.Organizer | None]: - try: - org_id = manager.start_data["org_id"] - except KeyError: - level = await get_by_id(manager.start_data["level_id"], author, dao.level) - org = await get_org(author, level, dao) - else: - org = await get_org_by_id(org_id, dao.organizer) + if "org_id" in manager.start_data: + org = await get_org_by_id(manager.start_data["org_id"], dao.organizer) level = await get_level_by_id_for_org(manager.start_data["level_id"], org, dao.level) - return level, org + return level, org + else: + level = await get_by_id(manager.start_data["level_id"], author, dao.level) + org_ = await get_org(author, level, dao) + return level, org_ async def get_levels( diff --git a/shvatka/tgbot/dialogs/level_scn/dialogs.py b/shvatka/tgbot/dialogs/level_scn/dialogs.py index 4b62a8a0..954ae96f 100644 --- a/shvatka/tgbot/dialogs/level_scn/dialogs.py +++ b/shvatka/tgbot/dialogs/level_scn/dialogs.py @@ -1,7 +1,7 @@ from aiogram import F from aiogram_dialog import Dialog, Window from aiogram_dialog.widgets.input import TextInput -from aiogram_dialog.widgets.kbd import Button, Cancel +from aiogram_dialog.widgets.kbd import Button, Cancel, ScrollingGroup, Select, Next from aiogram_dialog.widgets.text import Const, Jinja from shvatka.tgbot import states @@ -27,8 +27,9 @@ on_correct_bonus_keys, not_correct_bonus_keys, start_bonus_keys, + start_edit_time_hint, ) -from shvatka.tgbot.dialogs.preview_data import RENDERED_HINTS_PREVIEW +from shvatka.tgbot.dialogs.preview_data import PreviewStart level = Dialog( Window( @@ -51,6 +52,7 @@ id="level_id", ), state=states.LevelSG.level_id, + preview_add_transitions=[Next()], ), Window( Jinja( @@ -64,7 +66,11 @@ "💰Бонусных ключей: {{bonus_keys | length}}\n" "{% endif %}" "\n💡Подсказки:\n" - "{{rendered}}" + "{% if time_hints %}" + "{{time_hints | time_hints}}" + "{% else %}" + "пока нет ни одной" + "{% endif %}" ), Button(Const("🔑Ключи"), id="keys", on_click=start_keys), Button(Const("💰Бонусные ключи"), id="bonus_keys", on_click=start_bonus_keys), @@ -80,6 +86,12 @@ preview_data={ "level_id": "Pinky Pie", }, + preview_add_transitions=[ + PreviewStart(state=states.LevelKeysSG.keys), + PreviewStart(state=states.LevelBonusKeysSG.bonus_keys), + PreviewStart(state=states.LevelHintsSG.time_hints), + Cancel(), + ], ), on_process_result=process_level_result, ) @@ -97,7 +109,11 @@ "💰Бонусных ключей: {{bonus_keys | length}}\n" "{% endif %}" "\n💡Подсказки:\n" - "{{rendered}}" + "{% if time_hints %}" + "{{time_hints | time_hints}}" + "{% else %}" + "пока нет ни одной" + "{% endif %}" ), Button(Const("🔑Ключи"), id="keys", on_click=start_keys), Button(Const("💰Бонусные ключи"), id="bonus_keys", on_click=start_bonus_keys), @@ -114,6 +130,11 @@ preview_data={ "level_id": "Pinky Pie", }, + preview_add_transitions=[ + PreviewStart(state=states.LevelKeysSG.keys), + PreviewStart(state=states.LevelBonusKeysSG.bonus_keys), + PreviewStart(state=states.LevelHintsSG.time_hints), + ], ), on_process_result=process_level_result, on_start=on_start_level_edit, @@ -158,7 +179,25 @@ hints_dialog = Dialog( Window( Jinja("💡Подсказки уровня {{level_id}}:\n"), - Jinja("{{rendered}}"), + Jinja( + "{% if time_hints %}" + "{{time_hints | time_hints}}" + "{% else %}" + "пока нет ни одной" + "{% endif %}" + ), + ScrollingGroup( + Select( + Jinja("{{item | time_hint}}"), + id="level_hints", + item_id_getter=lambda x: x.time, + items="time_hints", + on_click=start_edit_time_hint, + ), + id="level_hints_sg", + width=1, + height=10, + ), Button(Const("➕Добавить подсказку"), id="add_time_hint", on_click=start_add_time_hint), Button( Const("👌Достаточно подсказок"), @@ -177,9 +216,12 @@ getter=get_time_hints, preview_data={ "time_hints": [], - "rendered": RENDERED_HINTS_PREVIEW, "level_id": "Pinky Pie", }, + preview_add_transitions=[ + PreviewStart(state=states.TimeHintSG.time), + PreviewStart(state=states.TimeHintEditSG.details), + ], ), on_process_result=process_time_hint_result, on_start=on_start_hints_edit, diff --git a/shvatka/tgbot/dialogs/level_scn/getters.py b/shvatka/tgbot/dialogs/level_scn/getters.py index e3e42a9a..8057fd62 100644 --- a/shvatka/tgbot/dialogs/level_scn/getters.py +++ b/shvatka/tgbot/dialogs/level_scn/getters.py @@ -1,9 +1,8 @@ +from adaptix import Retort from aiogram_dialog import DialogManager -from dataclass_factory import Factory from shvatka.core.models.dto import scn from shvatka.core.models.dto.scn import TimeHint -from shvatka.tgbot.views.utils import render_time_hints async def get_level_id(dialog_manager: DialogManager, **_): @@ -20,34 +19,32 @@ async def get_keys(dialog_manager: DialogManager, **_): async def get_bonus_keys(dialog_manager: DialogManager, **_): - dcf: Factory = dialog_manager.middleware_data["dcf"] + retort: Retort = dialog_manager.middleware_data["retort"] keys_raw = dialog_manager.dialog_data.get( "bonus_keys", dialog_manager.start_data.get("bonus_keys", []) ) return { - "bonus_keys": dcf.load(keys_raw, list[scn.BonusKey]), + "bonus_keys": retort.load(keys_raw, list[scn.BonusKey]), } async def get_level_data(dialog_manager: DialogManager, **_): dialog_data = dialog_manager.dialog_data - dcf: Factory = dialog_manager.middleware_data["dcf"] - hints = dcf.load(dialog_data.get("time_hints", []), list[TimeHint]) + retort: Retort = dialog_manager.middleware_data["retort"] + hints = retort.load(dialog_data.get("time_hints", []), list[TimeHint]) return { "level_id": dialog_data["level_id"], "keys": dialog_data.get("keys", []), "bonus_keys": dialog_data.get("bonus_keys", []), "time_hints": hints, - "rendered": render_time_hints(hints) if hints else "пока нет ни одной", } async def get_time_hints(dialog_manager: DialogManager, **_): dialog_data = dialog_manager.dialog_data - dcf: Factory = dialog_manager.middleware_data["dcf"] - hints = dcf.load(dialog_data.get("time_hints", []), list[TimeHint]) + retort: Retort = dialog_manager.middleware_data["retort"] + hints = retort.load(dialog_data.get("time_hints", []), list[TimeHint]) return { "level_id": dialog_manager.start_data["level_id"], "time_hints": hints, - "rendered": render_time_hints(hints) if hints else "пока нет ни одной", } diff --git a/shvatka/tgbot/dialogs/level_scn/handlers.py b/shvatka/tgbot/dialogs/level_scn/handlers.py index 9c67da10..f82affad 100644 --- a/shvatka/tgbot/dialogs/level_scn/handlers.py +++ b/shvatka/tgbot/dialogs/level_scn/handlers.py @@ -1,9 +1,9 @@ from typing import Any +from adaptix import Retort from aiogram.types import CallbackQuery, Message from aiogram_dialog import Data, DialogManager from aiogram_dialog.widgets.kbd import Button -from dataclass_factory import Factory from shvatka.core.models import dto from shvatka.core.models.dto import scn @@ -96,15 +96,25 @@ async def not_correct_bonus_keys( async def on_correct_bonus_keys( m: Message, dialog_: Any, manager: DialogManager, keys: list[scn.BonusKey] ): - dcf: Factory = manager.middleware_data["dcf"] - await manager.done({"bonus_keys": dcf.dump(keys)}) + retort: Retort = manager.middleware_data["retort"] + await manager.done({"bonus_keys": retort.dump(keys)}) async def process_time_hint_result(start_data: Data, result: Any, manager: DialogManager): if not result: return - if new_hint := result["time_hint"]: + if new_hint := result.get("time_hint", None): manager.dialog_data.setdefault("time_hints", []).append(new_hint) + elif (edited_hint := result.get("edited_time_hint")) and isinstance(start_data, dict): + old_hint = start_data["time_hint"] + if edited_hint == old_hint: + return + retort: Retort = manager.middleware_data["retort"] + hints_list = retort.load(manager.dialog_data.get("time_hints", []), scn.HintsList) + edited_list = hints_list.replace( + retort.load(old_hint, scn.TimeHint), retort.load(edited_hint, scn.TimeHint) + ) + manager.dialog_data["time_hints"] = retort.dump(edited_list) async def process_level_result(start_data: Data, result: Any, manager: DialogManager): @@ -120,22 +130,33 @@ async def process_level_result(start_data: Data, result: Any, manager: DialogMan async def on_start_level_edit(start_data: dict[str, Any], manager: DialogManager): dao: HolderDao = manager.middleware_data["dao"] - dcf: Factory = manager.middleware_data["dcf"] + retort: Retort = manager.middleware_data["retort"] author: dto.Player = manager.middleware_data["player"] level = await get_by_id(start_data["level_id"], author, dao.level) manager.dialog_data["level_id"] = level.name_id manager.dialog_data["keys"] = list(level.get_keys()) - manager.dialog_data["time_hints"] = dcf.dump(level.scenario.time_hints) - manager.dialog_data["bonus_keys"] = dcf.dump(level.get_bonus_keys()) + manager.dialog_data["time_hints"] = retort.dump(level.scenario.time_hints) + manager.dialog_data["bonus_keys"] = retort.dump(level.get_bonus_keys(), set[scn.BonusKey]) async def on_start_hints_edit(start_data: dict[str, Any], manager: DialogManager): manager.dialog_data["time_hints"] = start_data["time_hints"] +async def start_edit_time_hint( + c: CallbackQuery, widget: Any, manager: DialogManager, hint_time: str +): + retort: Retort = manager.middleware_data["retort"] + hints = retort.load(manager.dialog_data.get("time_hints", []), list[scn.TimeHint]) + await manager.start( + state=states.TimeHintEditSG.details, + data={"time_hint": retort.dump(next(filter(lambda x: x.time == int(hint_time), hints)))}, + ) + + async def start_add_time_hint(c: CallbackQuery, button: Button, manager: DialogManager): - dcf: Factory = manager.middleware_data["dcf"] - hints = dcf.load(manager.dialog_data.get("time_hints", []), list[scn.TimeHint]) + retort: Retort = manager.middleware_data["retort"] + hints = retort.load(manager.dialog_data.get("time_hints", []), list[scn.TimeHint]) previous_time = hints[-1].time if hints else -1 await manager.start(state=states.TimeHintSG.time, data={"previous_time": previous_time}) @@ -179,16 +200,16 @@ async def clear_hints(c: CallbackQuery, button: Button, manager: DialogManager): async def save_level(c: CallbackQuery, button: Button, manager: DialogManager): - dcf: Factory = manager.middleware_data["dcf"] + retort: Retort = manager.middleware_data["retort"] author: dto.Player = manager.middleware_data["player"] dao: HolderDao = manager.middleware_data["dao"] data = manager.dialog_data id_ = data["level_id"] keys = set(map(normalize_key, data["keys"])) - time_hints = dcf.load(data["time_hints"], list[scn.TimeHint]) - bonus_keys = dcf.load(data.get("bonus_keys", []), set[scn.BonusKey]) + time_hints = retort.load(data["time_hints"], list[scn.TimeHint]) + bonus_keys = retort.load(data.get("bonus_keys", []), set[scn.BonusKey]) level_scn = scn.LevelScenario(id=id_, keys=keys, time_hints=time_hints, bonus_keys=bonus_keys) level = await upsert_level(author=author, scenario=level_scn, dao=dao.level) - await manager.done(result={"level": dcf.dump(level)}) + await manager.done(result={"level": retort.dump(level)}) await c.answer(text="Уровень успешно сохранён") diff --git a/shvatka/tgbot/dialogs/preview_data.py b/shvatka/tgbot/dialogs/preview_data.py index aaf2ea55..a40a27f3 100644 --- a/shvatka/tgbot/dialogs/preview_data.py +++ b/shvatka/tgbot/dialogs/preview_data.py @@ -1,5 +1,9 @@ from datetime import datetime +from aiogram.fsm.state import State +from aiogram_dialog.widgets.kbd import Start, SwitchTo +from aiogram_dialog.widgets.text import Const + from shvatka.core.models import dto from shvatka.core.models.enums import GameStatus from shvatka.core.utils.datetime_utils import tz_utc @@ -34,4 +38,13 @@ levels_count=13, ) TIMES_PRESET = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60] -RENDERED_HINTS_PREVIEW = "0: 📃🪪\n10: 📃\n10: 📃\n15: 📃\n20: 📃\n25: 🪪\n30: 📡\n45: 📃" + + +class PreviewStart(Start): + def __init__(self, state: State): + super().__init__(Const(""), "", state) + + +class PreviewSwitchTo(SwitchTo): + def __init__(self, state: State): + super().__init__(Const(""), "", state) diff --git a/shvatka/tgbot/dialogs/starters/editor.py b/shvatka/tgbot/dialogs/starters/editor.py index 6224d6c5..c21cfbcf 100644 --- a/shvatka/tgbot/dialogs/starters/editor.py +++ b/shvatka/tgbot/dialogs/starters/editor.py @@ -41,4 +41,9 @@ def setup() -> Router: state=states.GameWriteSG.game_name, router=router, ) + register_start_handler( + Command(commands=LEVELS_COMMAND), + state=states.LevelListSG.levels, + router=router, + ) return router diff --git a/shvatka/tgbot/dialogs/time_hint/__init__.py b/shvatka/tgbot/dialogs/time_hint/__init__.py index 81fd4bae..49726281 100644 --- a/shvatka/tgbot/dialogs/time_hint/__init__.py +++ b/shvatka/tgbot/dialogs/time_hint/__init__.py @@ -1,7 +1,8 @@ from aiogram import Router -from .dialogs import time_hint +from .dialogs import time_hint, time_hint_edit def setup(router: Router): router.include_router(time_hint) + router.include_router(time_hint_edit) diff --git a/shvatka/tgbot/dialogs/time_hint/dialogs.py b/shvatka/tgbot/dialogs/time_hint/dialogs.py index 52260bab..9afd32b6 100644 --- a/shvatka/tgbot/dialogs/time_hint/dialogs.py +++ b/shvatka/tgbot/dialogs/time_hint/dialogs.py @@ -1,12 +1,32 @@ from aiogram_dialog import Dialog, Window from aiogram_dialog.widgets.input import MessageInput -from aiogram_dialog.widgets.kbd import Select, Button, Group, Back, Cancel +from aiogram_dialog.widgets.kbd import ( + Select, + Button, + Group, + Back, + Cancel, + SwitchTo, + ScrollingGroup, + ListGroup, +) from aiogram_dialog.widgets.text import Const, Format, Case, Jinja from shvatka.tgbot import states from .getters import get_available_times, get_hints -from .handlers import process_time_message, select_time, process_hint, on_finish, hint_on_start -from shvatka.tgbot.dialogs.preview_data import TIMES_PRESET +from .handlers import ( + process_time_message, + select_time, + process_hint, + on_finish, + hint_on_start, + hint_edit_on_start, + process_edit_time_message, + edit_single_hint, + save_edited_time_hint, + delete_single_hint, +) +from shvatka.tgbot.dialogs.preview_data import TIMES_PRESET, PreviewSwitchTo time_hint = Dialog( Window( @@ -27,6 +47,7 @@ state=states.TimeHintSG.time, getter=get_available_times, preview_data={"times": TIMES_PRESET}, + preview_add_transitions=[PreviewSwitchTo(state=states.TimeHintSG.hint)], ), Window( Jinja("Подсказка выходящая в {{time}} мин."), @@ -34,7 +55,7 @@ { False: Const("Присылай сообщения с подсказками (текст, фото, видео итд)"), True: Jinja( - "{{rendered}}\n" + "{{hints | hints}}\n" "Можно прислать ещё сообщения или перейти к следующей подсказке" ), }, @@ -50,7 +71,73 @@ Back(text=Const("Изменить время")), getter=get_hints, state=states.TimeHintSG.hint, - preview_data={"has_hints": True, "rendered": "📃🪪"}, + preview_data={"has_hints": True}, ), on_start=hint_on_start, ) + + +time_hint_edit = Dialog( + Window( + Jinja("Подсказка выходящая в {{time}}:" "{{hints | hints}}"), + SwitchTo( + Const("Изменить время"), + id="change_time", + state=states.TimeHintEditSG.time, + ), + ScrollingGroup( + ListGroup( + Button( + Jinja("{{item[1] | single_hint}}"), + on_click=edit_single_hint, + id="show", + ), + Button( + Const("🗑"), + on_click=delete_single_hint, + id="delete", + ), + id="hints", + item_id_getter=lambda x: x[0], + items="numerated_hints", + ), + id="hints_sg", + width=2, + height=10, + ), + SwitchTo(Const("Добавить"), state=states.TimeHintEditSG.add_part, id="to_add_part"), + Button( + text=Const("Сохранить изменения"), + id="save_time_hint", + on_click=save_edited_time_hint, + ), + Cancel(text=Const("Вернуться, ничего не менять")), + getter=get_hints, + state=states.TimeHintEditSG.details, + ), + Window( + Jinja("Введи новое время выхода подсказки"), + MessageInput(func=process_edit_time_message), + getter=get_hints, + state=states.TimeHintEditSG.time, + ), + Window( + Jinja("Подсказка выходящая в {{time}} мин."), + Case( + { + False: Const("Присылай сообщения с подсказками (текст, фото, видео итд)"), + True: Jinja("{{hints | hints}}\n" "Можно прислать ещё сообщения или вернуться"), + }, + selector="has_hints", + ), + MessageInput(func=process_hint), + SwitchTo( + text=Const("Вернуться"), + state=states.TimeHintEditSG.details, + id="to_details", + ), + getter=get_hints, + state=states.TimeHintEditSG.add_part, + ), + on_start=hint_edit_on_start, +) diff --git a/shvatka/tgbot/dialogs/time_hint/getters.py b/shvatka/tgbot/dialogs/time_hint/getters.py index 04a5710a..6decdcaa 100644 --- a/shvatka/tgbot/dialogs/time_hint/getters.py +++ b/shvatka/tgbot/dialogs/time_hint/getters.py @@ -1,8 +1,7 @@ +from adaptix import Retort from aiogram_dialog import DialogManager -from dataclass_factory import Factory from shvatka.core.models.dto.scn.hint_part import AnyHint -from shvatka.tgbot.views.utils import render_hints async def get_available_times(dialog_manager: DialogManager, **_): @@ -18,13 +17,13 @@ async def get_available_times(dialog_manager: DialogManager, **_): async def get_hints(dialog_manager: DialogManager, **_): dialog_data = dialog_manager.dialog_data - dcf: Factory = dialog_manager.middleware_data["dcf"] + retort: Retort = dialog_manager.middleware_data["retort"] - hints = dcf.load(dialog_data["hints"], list[AnyHint]) + hints = retort.load(dialog_data["hints"], list[AnyHint]) time_ = dialog_data["time"] return { "hints": hints, + "numerated_hints": list(enumerate(hints)), "time": time_, "has_hints": len(hints) > 0, - "rendered": render_hints(hints), } diff --git a/shvatka/tgbot/dialogs/time_hint/handlers.py b/shvatka/tgbot/dialogs/time_hint/handlers.py index 1b53798a..26461897 100644 --- a/shvatka/tgbot/dialogs/time_hint/handlers.py +++ b/shvatka/tgbot/dialogs/time_hint/handlers.py @@ -1,20 +1,45 @@ from typing import Any +from adaptix import Retort +from aiogram import types from aiogram.types import CallbackQuery, Message -from aiogram_dialog import DialogManager +from aiogram_dialog import DialogManager, SubManager from aiogram_dialog.widgets.kbd import Button -from dataclass_factory import Factory +from dishka import AsyncContainer +from dishka.integrations.aiogram import CONTAINER_NAME +from shvatka.core.models.dto import scn from shvatka.core.models.dto.scn import TimeHint from shvatka.core.models.dto.scn.hint_part import AnyHint +from shvatka.core.utils import exceptions from shvatka.tgbot import states from shvatka.tgbot.views.hint_factory.hint_parser import HintParser +from shvatka.tgbot.views.hint_sender import HintSender async def select_time(c: CallbackQuery, widget: Any, manager: DialogManager, item_id: str): await set_time(int(item_id), manager) +async def process_edit_time_message(m: Message, dialog_: Any, manager: DialogManager) -> None: + try: + time_ = int(m.text) + except ValueError: + await m.answer("Некорректный формат времени. Пожалуйста введите время в формате ЧЧ:ММ") + return + retort: Retort = manager.middleware_data["retort"] + hint = retort.load(manager.start_data["time_hint"], scn.TimeHint) + if not hint.can_update_time(): + await m.reply( + "Увы, отредактировать время данной подсказки не получится. " + "Скорее всего это загадка уровня (Подсказка 0 мин.). " + "Придётся переделать прямо тут текст (или медиа, или что там)" + ) + return + manager.dialog_data["time"] = time_ + await manager.switch_to(states.TimeHintEditSG.details) + + async def process_time_message(m: Message, dialog_: Any, manager: DialogManager) -> None: try: time_ = int(m.text) @@ -27,6 +52,41 @@ async def process_time_message(m: Message, dialog_: Any, manager: DialogManager) await m.answer("Время выхода данной подсказки должно быть больше, чем предыдущей") +async def edit_single_hint(c: CallbackQuery, widget: Any, manager: DialogManager): + assert isinstance(manager, SubManager) + dishka: AsyncContainer = manager.middleware_data[CONTAINER_NAME] + retort = await dishka.get(Retort) + hint = retort.load(manager.dialog_data["hints"], list[AnyHint]) + hint_sender = await dishka.get(HintSender) + chat: types.Chat = manager.middleware_data["event_chat"] + hint_index = manager.item_id + await hint_sender.send_hint(hint[int(hint_index)], chat.id) + + +async def delete_single_hint(c: CallbackQuery, widget: Any, manager: DialogManager): + assert isinstance(manager, SubManager) + dishka: AsyncContainer = manager.middleware_data[CONTAINER_NAME] + retort = await dishka.get(Retort) + hints = retort.load(manager.dialog_data.get("hints"), list[AnyHint]) + hint_index = manager.item_id + hints.pop(int(hint_index)) + manager.dialog_data["hints"] = retort.dump(hints, list[AnyHint]) + + +async def save_edited_time_hint(c: CallbackQuery, widget: Any, manager: DialogManager): + dishka: AsyncContainer = manager.middleware_data[CONTAINER_NAME] + retort = await dishka.get(Retort) + time_hint = retort.load(manager.start_data["time_hint"], scn.TimeHint) + try: + time_hint.update_time(manager.dialog_data["time"]) + time_hint.update_hint(retort.load(manager.dialog_data["hints"], list[AnyHint])) + except exceptions.LevelError as e: + assert isinstance(c.message, Message) + await c.message.reply(e.text) + return + await manager.done({"edited_time_hint": retort.dump(time_hint)}) + + async def set_time(time_minutes: int, manager: DialogManager): if time_minutes <= int(manager.start_data["previous_time"]): raise ValueError("Время меньше предыдущего") @@ -38,18 +98,18 @@ async def set_time(time_minutes: int, manager: DialogManager): async def process_hint(m: Message, dialog_: Any, manager: DialogManager) -> None: - dcf: Factory = manager.middleware_data["dcf"] + retort: Retort = manager.middleware_data["retort"] parser: HintParser = manager.middleware_data["hint_parser"] hint = await parser.parse(m, manager.middleware_data["player"]) - manager.dialog_data["hints"].append(dcf.dump(hint)) + manager.dialog_data["hints"].append(retort.dump(hint)) async def on_finish(c: CallbackQuery, button: Button, manager: DialogManager): - dcf: Factory = manager.middleware_data["dcf"] - hints = dcf.load(manager.dialog_data["hints"], list[AnyHint]) + retort: Retort = manager.middleware_data["retort"] + hints = retort.load(manager.dialog_data["hints"], list[AnyHint]) time_ = manager.dialog_data["time"] time_hint = TimeHint(time=time_, hint=hints) - await manager.done({"time_hint": dcf.dump(time_hint)}) + await manager.done({"time_hint": retort.dump(time_hint)}) async def hint_on_start(start_data: dict, manager: DialogManager): @@ -58,3 +118,10 @@ async def hint_on_start(start_data: dict, manager: DialogManager): manager.dialog_data["time"] = 0 await manager.switch_to(states.TimeHintSG.hint) manager.dialog_data.setdefault("hints", []) + + +async def hint_edit_on_start(start_data: dict, manager: DialogManager): + retort: Retort = manager.middleware_data["retort"] + hint = retort.load(manager.start_data["time_hint"], scn.TimeHint) + manager.dialog_data["hints"] = retort.dump(hint.hint, list[AnyHint]) + manager.dialog_data["time"] = hint.time diff --git a/shvatka/tgbot/middlewares/init_middleware.py b/shvatka/tgbot/middlewares/init_middleware.py index ad9fa95a..ffdf557a 100644 --- a/shvatka/tgbot/middlewares/init_middleware.py +++ b/shvatka/tgbot/middlewares/init_middleware.py @@ -1,5 +1,6 @@ from typing import Callable, Any, Awaitable +from adaptix import Retort from aiogram import BaseMiddleware from aiogram.types import TelegramObject from aiogram_dialog.api.protocols import BgManagerFactory @@ -40,6 +41,7 @@ async def __call__( # type: ignore[override] data["main_config"] = await dishka.get(TgBotConfig) data["user_getter"] = await dishka.get(UserGetter) data["dcf"] = await dishka.get(Factory) + data["retort"] = await dishka.get(Retort) data["scheduler"] = await dishka.get(Scheduler) # type: ignore[type-abstract] data["locker"] = await dishka.get(KeyCheckerFactory) # type: ignore[type-abstract] data["file_storage"] = file_storage diff --git a/shvatka/tgbot/states.py b/shvatka/tgbot/states.py index 04524197..5fc9d64a 100644 --- a/shvatka/tgbot/states.py +++ b/shvatka/tgbot/states.py @@ -18,6 +18,12 @@ class TimeHintSG(StatesGroup): hint = State() +class TimeHintEditSG(StatesGroup): + details = State() + time = State() + add_part = State() + + class LevelListSG(StatesGroup): levels = State() diff --git a/shvatka/tgbot/utils/data.py b/shvatka/tgbot/utils/data.py index bc9965ae..31e2d11d 100644 --- a/shvatka/tgbot/utils/data.py +++ b/shvatka/tgbot/utils/data.py @@ -1,5 +1,6 @@ from typing import TypedDict, Any +from adaptix import Retort from aiogram import types, Bot, Router from aiogram.dispatcher.event.handler import HandlerObject from aiogram.fsm.context import FSMContext @@ -52,6 +53,7 @@ class MiddlewareData(DialogMiddlewareData, total=False): dishka_container: AsyncContainer user_getter: UserGetter dcf: Factory + retort: Retort dao: HolderDao scheduler: Scheduler locker: KeyCheckerFactory diff --git a/shvatka/tgbot/views/commands.py b/shvatka/tgbot/views/commands.py index 85459125..e9b6c39a 100644 --- a/shvatka/tgbot/views/commands.py +++ b/shvatka/tgbot/views/commands.py @@ -76,6 +76,7 @@ def __str__(self) -> str: ) PUBLISH_COMMAND = BotCommand(command="publish_forum", description="опубликовать на форуме") + HELP_ORG = CommandsGroup( "Команды для организаторов:", [ diff --git a/shvatka/tgbot/views/jinja_filters/__init__.py b/shvatka/tgbot/views/jinja_filters/__init__.py index 1ca15366..d8dd418c 100644 --- a/shvatka/tgbot/views/jinja_filters/__init__.py +++ b/shvatka/tgbot/views/jinja_filters/__init__.py @@ -6,6 +6,12 @@ from .boolean_emoji import bool_render from .game_status import to_readable_name from .timezone import datetime_filter, timedelta_filter +from shvatka.tgbot.views.utils import ( + render_single_hint, + render_hints, + render_time_hint, + render_time_hints, +) def setup_jinja(bot: Bot): @@ -18,5 +24,9 @@ def setup_jinja(bot: Bot): "bool_emoji": bool_render, "game_status": to_readable_name, "timedelta": timedelta_filter, + "single_hint": render_single_hint, + "hints": render_hints, + "time_hint": render_time_hint, + "time_hints": render_time_hints, }, ) diff --git a/shvatka/tgbot/views/utils.py b/shvatka/tgbot/views/utils.py index f719f70d..bd462400 100644 --- a/shvatka/tgbot/views/utils.py +++ b/shvatka/tgbot/views/utils.py @@ -51,4 +51,8 @@ def render_time_hint(time_hint: TimeHint) -> str: def render_hints(hints: list[BaseHint]) -> str: - return "".join([HINTS_EMOJI[HintType[hint.type]] for hint in hints]) + return "".join([render_single_hint(hint) for hint in hints]) + + +def render_single_hint(hint: BaseHint) -> str: + return HINTS_EMOJI[HintType[hint.type]] diff --git a/tests/conftest.py b/tests/conftest.py index 118f20a2..b69340ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import pytest import pytest_asyncio +from adaptix import Retort from dataclass_factory import Factory from dishka import make_async_container @@ -41,3 +42,9 @@ def event_loop(): async def dcf(): dishka = make_async_container(DCFProvider()) return await dishka.get(Factory) + + +@pytest_asyncio.fixture(scope="session") +async def retort() -> Retort: + dishka = make_async_container(DCFProvider()) + return await dishka.get(Retort) diff --git a/tests/fixtures/game_fixtures.py b/tests/fixtures/game_fixtures.py index f9df0e7d..ca82e697 100644 --- a/tests/fixtures/game_fixtures.py +++ b/tests/fixtures/game_fixtures.py @@ -2,7 +2,7 @@ from datetime import datetime import pytest_asyncio -from dataclass_factory import Factory +from adaptix import Retort from shvatka.core.interfaces.clients.file_storage import FileGateway from shvatka.core.models import dto, enums @@ -20,14 +20,14 @@ async def game( complex_scn: RawGameScenario, author: dto.Player, dao: HolderDao, - dcf: Factory, + retort: Retort, file_gateway: FileGateway, ) -> dto.FullGame: return await upsert_game( complex_scn, author, dao.game_upserter, - dcf, + retort, file_gateway, ) diff --git a/tests/integration/api_full/test_game.py b/tests/integration/api_full/test_game.py index 54f8ee76..18958aa3 100644 --- a/tests/integration/api_full/test_game.py +++ b/tests/integration/api_full/test_game.py @@ -1,9 +1,12 @@ import pytest +from adaptix import Retort from dataclass_factory import Factory from httpx import AsyncClient from shvatka.api.models import responses +from shvatka.common.factory import REQUIRED_GAME_RECIPES from shvatka.core.models import dto +from shvatka.core.models.dto import scn from shvatka.core.models.enums import GameStatus from shvatka.core.services.player import upsert_player from shvatka.infrastructure.db.dao.holder import HolderDao @@ -61,12 +64,16 @@ async def test_game_card( assert resp.is_success resp.read() - dcf = Factory() - actual = dcf.load(resp.json(), responses.FullGame) + retort = Retort( + recipe=[ + *REQUIRED_GAME_RECIPES, + ] + ) + actual = retort.load(resp.json(), responses.FullGame) assert actual.id == finished_game.id assert actual.status == GameStatus.complete assert len(actual.levels) == len(finished_game.levels) - assert [lvl.scenario for lvl in actual.levels] == [ + assert [retort.load(lvl.scenario, scn.LevelScenario) for lvl in actual.levels] == [ lvl.scenario for lvl in finished_game.levels ] diff --git a/tests/integration/test_game.py b/tests/integration/test_game.py index 66d2831b..9a349d11 100644 --- a/tests/integration/test_game.py +++ b/tests/integration/test_game.py @@ -1,7 +1,7 @@ from copy import deepcopy import pytest -from dataclass_factory import Factory +from adaptix import Retort from shvatka.core.interfaces.clients.file_storage import FileGateway from shvatka.core.models import dto @@ -25,10 +25,10 @@ async def test_game_simple( author: dto.Player, three_lvl_scn: RawGameScenario, dao: HolderDao, - dcf: Factory, + retort: Retort, file_gateway: FileGateway, ): - game = await upsert_game(three_lvl_scn, author, dao.game_upserter, dcf, file_gateway) + game = await upsert_game(three_lvl_scn, author, dao.game_upserter, retort, file_gateway) assert await dao.game.count() == 1 assert await dao.level.count() == 3 @@ -48,7 +48,7 @@ async def test_game_simple( another_scn["levels"].append(another_scn["levels"].pop(0)) game = await upsert_game( - RawGameScenario(scn=another_scn, files={}), author, dao.game_upserter, dcf, file_gateway + RawGameScenario(scn=another_scn, files={}), author, dao.game_upserter, retort, file_gateway ) assert await dao.game.count() == 1 @@ -68,7 +68,7 @@ async def test_game_simple( another_scn["levels"].pop() game = await upsert_game( - RawGameScenario(scn=another_scn, files={}), author, dao.game_upserter, dcf, file_gateway + RawGameScenario(scn=another_scn, files={}), author, dao.game_upserter, retort, file_gateway ) assert await dao.game.count() == 1 @@ -98,10 +98,10 @@ async def test_game_get_full( author: dto.Player, simple_scn: RawGameScenario, dao: HolderDao, - dcf: Factory, + retort: Retort, file_gateway: FileGateway, ): - game_expected = await upsert_game(simple_scn, author, dao.game_upserter, dcf, file_gateway) + game_expected = await upsert_game(simple_scn, author, dao.game_upserter, retort, file_gateway) game_actual = await dao.game.get_full(game_expected.id) assert game_expected == game_actual @@ -111,10 +111,10 @@ async def test_game_get_preview( author: dto.Player, simple_scn: RawGameScenario, dao: HolderDao, - dcf: Factory, + retort: Retort, file_gateway: FileGateway, ): - game_expected = await upsert_game(simple_scn, author, dao.game_upserter, dcf, file_gateway) + game_expected = await upsert_game(simple_scn, author, dao.game_upserter, retort, file_gateway) game_actual = await dao.game.get_preview(game_expected.id) assert len(game_expected.levels) == game_actual.levels_count assert game_expected.id == game_actual.id diff --git a/tests/integration/test_level.py b/tests/integration/test_level.py index de41a9fc..be4fc54f 100644 --- a/tests/integration/test_level.py +++ b/tests/integration/test_level.py @@ -1,5 +1,5 @@ import pytest -from dataclass_factory import Factory +from adaptix import Retort from shvatka.core.models.dto.scn.game import RawGameScenario from shvatka.core.models.dto.scn.level import LevelScenario @@ -11,12 +11,12 @@ @pytest.mark.asyncio -async def test_simple_level(simple_scn: RawGameScenario, dao: HolderDao, dcf: Factory): +async def test_simple_level(simple_scn: RawGameScenario, dao: HolderDao, retort: Retort): author = await upsert_player(await upsert_user(create_dto_harry(), dao.user), dao.player) await dao.player.promote(author, author) await dao.commit() author.can_be_author = True - lvl = await upsert_raw_level(simple_scn.scn["levels"][0], author, dcf, dao.level) + lvl = await upsert_raw_level(simple_scn.scn["levels"][0], author, retort, dao.level) assert lvl.db_id is not None assert await dao.level.count() == 1 diff --git a/tests/mocks/bot.py b/tests/mocks/bot.py index c154981e..52d775c9 100644 --- a/tests/mocks/bot.py +++ b/tests/mocks/bot.py @@ -7,6 +7,7 @@ from shvatka.tgbot.config.models.bot import BotConfig from shvatka.tgbot.config.models.main import TgBotConfig from shvatka.tgbot.views.bot_alert import BotAlert +from shvatka.tgbot.views.jinja_filters import setup_jinja class MockMessageManagerProvider(Provider): @@ -22,7 +23,9 @@ class MockBotProvider(Provider): @provide async def get_bot(self, config: TgBotConfig) -> Bot: - return MockedBot(token=config.bot.token) + bot = MockedBot(token=config.bot.token) + setup_jinja(bot) + return bot @provide async def bot_alert(self, bot: Bot, config: BotConfig) -> BotAlert: diff --git a/tests/unit/serialization/test_deserialize.py b/tests/unit/serialization/test_deserialize.py index 0af61daa..bf8c53e5 100644 --- a/tests/unit/serialization/test_deserialize.py +++ b/tests/unit/serialization/test_deserialize.py @@ -1,7 +1,7 @@ from copy import deepcopy import pytest -from dataclass_factory import Factory +from adaptix import Retort from shvatka.core.models.dto.scn import TextHint, GPSHint, PhotoHint, ContactHint from shvatka.core.models.dto.scn.game import RawGameScenario @@ -22,41 +22,41 @@ from shvatka.tgbot.views.utils import render_hints -def test_deserialize_game(simple_scn: RawGameScenario, dcf: Factory): - game = parse_game(simple_scn, dcf) +def test_deserialize_game(simple_scn: RawGameScenario, retort: Retort): + game = parse_game(simple_scn, retort) assert "My new game" == game.name assert HintType.text.name == game.levels[0].time_hints[0].hint[0].type assert "загадка" == game.levels[0].time_hints[0].hint[0].text assert HintType.gps.name == game.levels[0].time_hints[2].hint[0].type -def test_deserialize_level(simple_scn: RawGameScenario, dcf: Factory): - level = load_level(simple_scn.scn["levels"][0], dcf) +def test_deserialize_level(simple_scn: RawGameScenario, retort: Retort): + level = load_level(simple_scn.scn["levels"][0], retort) assert "first" == level.id assert HintType.text.name == level.time_hints[0].hint[0].type assert "загадка" == level.time_hints[0].hint[0].text assert HintType.gps.name == level.time_hints[2].hint[0].type -def test_deserialize_invalid_level(simple_scn: RawGameScenario, dcf: Factory): +def test_deserialize_invalid_level(simple_scn: RawGameScenario, retort: Retort): level_source = simple_scn.scn["levels"][0] level = deepcopy(level_source) level["id"] = "привет" with pytest.raises(ScenarioNotCorrect): - load_level(level, dcf) + load_level(level, retort) level["keys"] = {"SHCamelCase"} with pytest.raises(ScenarioNotCorrect): - load_level(level, dcf) + load_level(level, retort) level = deepcopy(level_source) level["time_hints"] = level.pop("time-hints") with pytest.raises(ScenarioNotCorrect): - load_level(level, dcf) + load_level(level, retort) -def test_deserialize_all_types(all_types_scn: RawGameScenario, dcf: Factory): - game_scn = parse_game(all_types_scn, dcf) +def test_deserialize_all_types(all_types_scn: RawGameScenario, retort: Retort): + game_scn = parse_game(all_types_scn, retort) hints = game_scn.levels[0].time_hints assert 12 == len(hints) for i, type_ in enumerate( @@ -79,8 +79,8 @@ def test_deserialize_all_types(all_types_scn: RawGameScenario, dcf: Factory): assert hints[i].hint[0].type == type_.type # type: ignore[attr-defined] -def test_render_all_types(all_types_scn: RawGameScenario, dcf: Factory): - game_scn = parse_game(all_types_scn, dcf) +def test_render_all_types(all_types_scn: RawGameScenario, retort: Retort): + game_scn = parse_game(all_types_scn, retort) hints = [time_hint.hint[0] for time_hint in game_scn.levels[0].time_hints] assert 12 == len(hints) assert "📃📡🧭📷🎼🎬📎🌀🎤🤳🪪🏷" == render_hints(hints) diff --git a/tests/unit/test_time_hint.py b/tests/unit/test_time_hint.py new file mode 100644 index 00000000..9968159f --- /dev/null +++ b/tests/unit/test_time_hint.py @@ -0,0 +1,55 @@ +import pytest + +from shvatka.core.models.dto import scn +from shvatka.core.utils import exceptions + + +def test_create_ok_time_hint(): + time_hint = scn.TimeHint( + time=0, + hint=[scn.TextHint(text="some text")], + ) + assert time_hint.time == 0 + assert time_hint.hint == [scn.TextHint(text="some text")] + + +def test_create_empty_time_hint(): + with pytest.raises(exceptions.LevelError): + scn.TimeHint(time=5, hint=[]) + + +def test_edit_empty_time_hint(): + time_hint = scn.TimeHint( + time=0, + hint=[scn.TextHint(text="some text")], + ) + with pytest.raises(exceptions.LevelError): + time_hint.update_hint([]) + + +def test_ok_change_time(): + time_hint = scn.TimeHint( + time=5, + hint=[scn.TextHint(text="some text")], + ) + time_hint.update_time(10) + assert time_hint.time == 10 + assert time_hint.hint == [scn.TextHint(text="some text")] + + +def test_change_time_for_0(): + time_hint = scn.TimeHint( + time=0, + hint=[scn.TextHint(text="some text")], + ) + with pytest.raises(exceptions.LevelError): + time_hint.update_time(5) + + +def test_change_time_to_0(): + time_hint = scn.TimeHint( + time=5, + hint=[scn.TextHint(text="some text")], + ) + with pytest.raises(exceptions.LevelError): + time_hint.update_time(0)