From 9c213f8da113c9bc603fd5305f80a93d3b7e2e3d Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 15 Sep 2025 17:40:20 +0700 Subject: [PATCH 01/45] feat(`user`): remove `LastGame`s (#51) --- src/ttt/application/game/game/cancel_game.py | 18 +-- .../game/game/make_move_in_game.py | 14 +- .../application/game/game/ports/game_dao.py | 13 ++ .../game/game/start_game_with_ai.py | 3 - src/ttt/entities/core/game/game.py | 55 +++----- src/ttt/entities/core/user/last_game.py | 23 ---- src/ttt/entities/core/user/user.py | 95 +++----------- src/ttt/entities/elo/rating.py | 11 +- src/ttt/infrastructure/adapters/game_dao.py | 53 ++++++++ .../dc59db88676c_remove_last_games.py | 51 ++++++++ .../infrastructure/sqlalchemy/tables/user.py | 39 +----- src/ttt/main/common/di.py | 8 ++ .../test_entities/test_core/conftest.py | 2 - .../test_entities/test_core/test_game.py | 99 +++++--------- .../test_entities/test_core/test_user.py | 1 - .../test_ttt/test_infrastructure/conftest.py | 17 ++- .../test_adapters/__init__.py | 0 .../test_adapters/test_game_dao.py | 123 ++++++++++++++++++ uv.lock | 2 +- 19 files changed, 344 insertions(+), 283 deletions(-) create mode 100644 src/ttt/application/game/game/ports/game_dao.py delete mode 100644 src/ttt/entities/core/user/last_game.py create mode 100644 src/ttt/infrastructure/adapters/game_dao.py create mode 100644 src/ttt/infrastructure/alembic/versions/dc59db88676c_remove_last_games.py create mode 100644 tests/test_ttt/test_infrastructure/test_adapters/__init__.py create mode 100644 tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py diff --git a/src/ttt/application/game/game/cancel_game.py b/src/ttt/application/game/game/cancel_game.py index 7f6d74a..e67ed38 100644 --- a/src/ttt/application/game/game/cancel_game.py +++ b/src/ttt/application/game/game/cancel_game.py @@ -24,15 +24,8 @@ class CancelGame: async def __call__(self, user_id: int) -> None: async with self.transaction: - ( - game, - user1_last_game_id, - user2_last_game_id, - ) = await gather( - self.games.game_with_game_location(user_id), - self.uuids.random_uuid(), - self.uuids.random_uuid(), - ) + game = await self.games.game_with_game_location(user_id) + if game is None: await self.game_views.no_game_view(user_id) return @@ -45,12 +38,7 @@ async def __call__(self, user_id: int) -> None: try: tracking = Tracking() - game.cancel( - user_id, - user1_last_game_id, - user2_last_game_id, - tracking, - ) + game.cancel(user_id, tracking) except AlreadyCompletedGameError: await self.log.already_completed_game_to_cancel(game, user_id) await self.game_views.game_already_complteted_view( diff --git a/src/ttt/application/game/game/make_move_in_game.py b/src/ttt/application/game/game/make_move_in_game.py index 2969519..85ad186 100644 --- a/src/ttt/application/game/game/make_move_in_game.py +++ b/src/ttt/application/game/game/make_move_in_game.py @@ -6,6 +6,7 @@ from ttt.application.common.ports.transaction import Transaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway +from ttt.application.game.game.ports.game_dao import GameDao from ttt.application.game.game.ports.game_log import GameLog from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games @@ -32,6 +33,7 @@ class MakeMoveInGame: ai_gateway: GameAiGateway transaction: Transaction log: GameLog + dao: GameDao async def __call__( self, @@ -52,12 +54,10 @@ async def __call__( ) ( random, - current_user_last_game_id, - not_current_user_last_game_id, + games_played_by_player_id, ) = await gather( self.randoms.random(), - self.uuids.random_uuid(), - self.uuids.random_uuid(), + self.dao.games_played_by_player_id(game), ) try: @@ -65,8 +65,7 @@ async def __call__( user_move = game.make_user_move( user_id, cell_number_int, - current_user_last_game_id, - not_current_user_last_game_id, + games_played_by_player_id, random, tracking, ) @@ -119,19 +118,16 @@ async def __call__( ( free_cell_random, ai_move_cell_number_int, - not_current_user_last_game_id, ) = await gather( self.randoms.random(), self.ai_gateway.next_move_cell_number_int( game, user_move.next_move_ai_id, ), - self.uuids.random_uuid(), ) ai_move = game.make_ai_move( user_move.next_move_ai_id, ai_move_cell_number_int, - not_current_user_last_game_id, free_cell_random, tracking, ) diff --git a/src/ttt/application/game/game/ports/game_dao.py b/src/ttt/application/game/game/ports/game_dao.py new file mode 100644 index 0000000..d28e5f7 --- /dev/null +++ b/src/ttt/application/game/game/ports/game_dao.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from ttt.entities.core.game.game import Game +from ttt.entities.elo.rating import GamesPlayed + + +class GameDao(ABC): + @abstractmethod + async def games_played_by_player_id( + self, + game: Game, + /, + ) -> dict[int, GamesPlayed]: ... diff --git a/src/ttt/application/game/game/start_game_with_ai.py b/src/ttt/application/game/game/start_game_with_ai.py index d700576..ee89faf 100644 --- a/src/ttt/application/game/game/start_game_with_ai.py +++ b/src/ttt/application/game/game/start_game_with_ai.py @@ -84,19 +84,16 @@ async def __call__(self, user_id: int, ai_type: AiType) -> None: ( free_cell_random, ai_move_cell_number_int, - not_current_user_last_game_id, ) = await gather( self.randoms.random(), self.ai_gateway.next_move_cell_number_int( started_game.game, started_game.next_move_ai_id, ), - self.uuids.random_uuid(), ) ai_move = started_game.game.make_ai_move( started_game.next_move_ai_id, ai_move_cell_number_int, - not_current_user_last_game_id, free_cell_random, tracking, ) diff --git a/src/ttt/entities/core/game/game.py b/src/ttt/entities/core/game/game.py index 3fe57cf..4a83231 100644 --- a/src/ttt/entities/core/game/game.py +++ b/src/ttt/entities/core/game/game.py @@ -33,6 +33,7 @@ User, UserAlreadyInGameError, ) +from ttt.entities.elo.rating import GamesPlayed from ttt.entities.math.matrix import Matrix from ttt.entities.math.random import Random, choice from ttt.entities.math.vector import Vector @@ -152,34 +153,30 @@ def is_completed(self) -> bool: def cancel( self, user_id: int, - user1_last_game_id: UUID, - user2_last_game_id: UUID, tracking: Tracking, ) -> None: """ :raises ttt.entities.core.game.game.AlreadyCompletedGameError: :raises ttt.entities.core.game.game.NotPlayerError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ none(self.result, else_=AlreadyCompletedGameError) canceler = not_none(self.user(user_id), else_=NotPlayerError) if isinstance(self.player1, User): - self.player1.leave_game(user1_last_game_id, self.id, tracking) + self.player1.leave_game(tracking) if isinstance(self.player2, User): - self.player2.leave_game(user2_last_game_id, self.id, tracking) + self.player2.leave_game(tracking) self.result = CancelledGameResult(canceler.id) self.state = GameState.completed tracking.register_mutated(self) - def make_user_move( # noqa: C901, PLR0913, PLR0917 + def make_user_move( # noqa: C901 self, user_id: int, cell_number_int: int, - current_user_last_game_id: UUID, - not_current_user_last_game_id: UUID, + games_played_by_player_id: dict[int, GamesPlayed], player_win_random: Random, tracking: Tracking, ) -> UserMove: @@ -230,21 +227,17 @@ def make_user_move( # noqa: C901, PLR0913, PLR0917 case User(): win = current_player.win_against_user( not_current_player.rating, + games_played_by_player_id[current_player.id], player_win_random, - current_user_last_game_id, - self.id, tracking, ) loss = not_current_player.lose_to_user( current_player.rating, - not_current_user_last_game_id, - self.id, + games_played_by_player_id[not_current_player.id], tracking, ) case Ai(): - win = current_player.win_against_ai( - current_user_last_game_id, self.id, tracking, - ) + win = current_player.win_against_ai(tracking) loss = not_current_player.lose() self._complete_as_decided(win, loss, tracking) @@ -257,20 +250,16 @@ def make_user_move( # noqa: C901, PLR0913, PLR0917 case User(): draw1 = current_player.be_draw_against_user( not_current_player.rating, - current_user_last_game_id, - self.id, + games_played_by_player_id[current_player.id], tracking, ) draw2 = not_current_player.be_draw_against_user( not_current_player.rating, - not_current_user_last_game_id, - self.id, + games_played_by_player_id[not_current_player.id], tracking, ) case Ai(): - draw1 = current_player.be_draw_against_ai( - current_user_last_game_id, self.id, tracking, - ) + draw1 = current_player.be_draw_against_ai(tracking) draw2 = not_current_player.be_draw() self._complete_as_draw(draw1, draw2, tracking) @@ -292,7 +281,6 @@ def make_ai_move( self, ai_id: UUID, cell_number_int: int | None, - not_current_user_last_game_id: UUID, free_cell_random: Random, tracking: Tracking, ) -> AiMove: @@ -316,7 +304,6 @@ def make_ai_move( return self._make_random_ai_move( current_player, not_current_player, - not_current_user_last_game_id, free_cell_random, tracking, ) @@ -327,7 +314,6 @@ def make_ai_move( return self._make_random_ai_move( current_player, not_current_player, - not_current_user_last_game_id, free_cell_random, tracking, ) @@ -340,7 +326,6 @@ def make_ai_move( return self._make_random_ai_move( current_player, not_current_player, - not_current_user_last_game_id, free_cell_random, tracking, ) @@ -351,7 +336,6 @@ def make_ai_move( return self._make_random_ai_move( current_player, not_current_player, - not_current_user_last_game_id, free_cell_random, tracking, ) @@ -361,16 +345,12 @@ def make_ai_move( if self._is_player_winner(current_player, cell_position): win = current_player.win() - loss = not_current_player.lose_to_ai( - not_current_user_last_game_id, self.id, tracking, - ) + loss = not_current_player.lose_to_ai(tracking) self._complete_as_decided(win, loss, tracking) elif not self._can_continue(): draw1 = current_player.be_draw() - draw2 = not_current_player.be_draw_against_ai( - not_current_user_last_game_id, self.id, tracking, - ) + draw2 = not_current_player.be_draw_against_ai(tracking) self._complete_as_draw(draw1, draw2, tracking) else: @@ -411,7 +391,6 @@ def _make_random_ai_move( self, current_player: Ai, not_current_player: User, - not_current_user_last_game_id: UUID, free_cell_random: Random, tracking: Tracking, ) -> AiMove: @@ -422,16 +401,12 @@ def _make_random_ai_move( if self._is_player_winner(current_player, cell.board_position): win = current_player.win() - loss = not_current_player.lose_to_ai( - not_current_user_last_game_id, self.id, tracking, - ) + loss = not_current_player.lose_to_ai(tracking) self._complete_as_decided(win, loss, tracking) elif not self._can_continue(): draw1 = current_player.be_draw() - draw2 = not_current_player.be_draw_against_ai( - not_current_user_last_game_id, self.id, tracking, - ) + draw2 = not_current_player.be_draw_against_ai(tracking) self._complete_as_draw(draw1, draw2, tracking) else: diff --git a/src/ttt/entities/core/user/last_game.py b/src/ttt/entities/core/user/last_game.py deleted file mode 100644 index 82ada9b..0000000 --- a/src/ttt/entities/core/user/last_game.py +++ /dev/null @@ -1,23 +0,0 @@ -from dataclasses import dataclass -from uuid import UUID - -from ttt.entities.tools.tracking import Tracking - - -@dataclass -class LastGame: - id: UUID - user_id: int - game_id: UUID - - -def last_game( - last_game_id: UUID, - last_game_user_id: int, - last_game_game_id: UUID, - tracking: Tracking, -) -> LastGame: - last_game = LastGame(last_game_id, last_game_user_id, last_game_game_id) - tracking.register_new(last_game) - - return last_game diff --git a/src/ttt/entities/core/user/user.py b/src/ttt/entities/core/user/user.py index 8a3d40a..31f0f84 100644 --- a/src/ttt/entities/core/user/user.py +++ b/src/ttt/entities/core/user/user.py @@ -12,7 +12,6 @@ ) from ttt.entities.core.user.draw import UserDraw from ttt.entities.core.user.emoji import UserEmoji -from ttt.entities.core.user.last_game import LastGame, last_game from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.loss import UserLoss from ttt.entities.core.user.rank import Rank, rank_for_rating @@ -20,6 +19,7 @@ from ttt.entities.core.user.win import UserWin from ttt.entities.elo.rating import ( EloRating, + GamesPlayed, initial_elo_rating, new_elo_rating, ) @@ -60,9 +60,6 @@ class EmojiNotPurchasedError(Exception): ... class NoPurchaseError(Exception): ... -class UserAlreadyLeftGameError(Exception): ... - - class UserAlreadyAdminError(Exception): ... @@ -90,7 +87,6 @@ class User: account: Account emojis: list[UserEmoji] stars_purchases: list[StarsPurchase] - last_games: list[LastGame] selected_emoji_id: UUID | None rating: EloRating admin_right: AdminRight | None @@ -235,9 +231,6 @@ def set_user_account( return user - def games_played(self) -> int: - return len(self.last_games) - def is_in_game(self) -> bool: return self.game_location is not None @@ -258,23 +251,21 @@ def be_in_game( def lose_to_user( self, enemy_rating: EloRating, - last_game_id: UUID, - game_id: UUID, + games_played: GamesPlayed, tracking: Tracking, ) -> UserLoss: """ :raises ttt.entities.core.user.user.UserNotInGameError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ - self.leave_game(last_game_id, game_id, tracking) + self.leave_game(tracking) self.number_of_defeats += 1 new_rating = new_elo_rating( self.rating, enemy_rating, WinningScore.when_losing, - self.games_played(), + games_played, ) rating_vector = new_rating - self.rating self.rating = new_rating @@ -282,18 +273,12 @@ def lose_to_user( return UserLoss(user_id=self.id, rating_vector=rating_vector) - def lose_to_ai( - self, - last_game_id: UUID, - game_id: UUID, - tracking: Tracking, - ) -> UserLoss: + def lose_to_ai(self, tracking: Tracking) -> UserLoss: """ :raises ttt.entities.core.user.user.UserNotInGameError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ - self.leave_game(last_game_id, game_id, tracking) + self.leave_game(tracking) self.number_of_defeats += 1 tracking.register_mutated(self) @@ -303,17 +288,15 @@ def lose_to_ai( def win_against_user( self, enemy_rating: EloRating, + games_played: GamesPlayed, random: Random, - last_game_id: UUID, - game_id: UUID, tracking: Tracking, ) -> UserWin: """ :raises ttt.entities.core.user.user.UserNotInGameError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ - self.leave_game(last_game_id, game_id, tracking) + self.leave_game(tracking) self.number_of_wins += 1 @@ -321,7 +304,7 @@ def win_against_user( self.rating, enemy_rating, WinningScore.when_winning, - self.games_played(), + games_played, ) rating_vector = new_rating - self.rating self.rating = new_rating @@ -332,33 +315,25 @@ def win_against_user( tracking.register_mutated(self) return UserWin(self.id, new_stars, rating_vector) - def win_against_ai( - self, - last_game_id: UUID, - game_id: UUID, - tracking: Tracking, - ) -> UserWin: + def win_against_ai(self, tracking: Tracking) -> UserWin: """ :raises ttt.entities.core.user.user.UserNotInGameError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ - self.leave_game(last_game_id, game_id, tracking) - + self.leave_game(tracking) return UserWin(self.id, new_stars=None, rating_vector=None) def be_draw_against_user( self, enemy_rating: EloRating, - last_game_id: UUID, - game_id: UUID, + games_played: GamesPlayed, tracking: Tracking, ) -> UserDraw: """ :raises ttt.entities.core.user.user.UserNotInGameError: """ - self.leave_game(last_game_id, game_id, tracking) + self.leave_game(tracking) self.number_of_draws += 1 @@ -366,7 +341,7 @@ def be_draw_against_user( self.rating, enemy_rating, WinningScore.when_winning, - self.games_played(), + games_played, ) rating_vector = new_rating - self.rating self.rating = new_rating @@ -374,29 +349,21 @@ def be_draw_against_user( return UserDraw(self.id, rating_vector) - def be_draw_against_ai( - self, - last_game_id: UUID, - game_id: UUID, - tracking: Tracking, - ) -> UserDraw: + def be_draw_against_ai(self, tracking: Tracking) -> UserDraw: """ :raises ttt.entities.core.user.user.UserNotInGameError: """ - self.leave_game(last_game_id, game_id, tracking) + self.leave_game(tracking) self.number_of_draws += 1 tracking.register_mutated(self) return UserDraw(self.id, rating_vector=None) - def leave_game( - self, last_game_id: UUID, game_id: UUID, tracking: Tracking, - ) -> None: + def leave_game(self, tracking: Tracking) -> None: """ :raises ttt.entities.core.user.user.UserNotInGameError: - :raises ttt.entities.core.user.user.UserAlreadyLeftGameError: """ assert_(self.is_in_game(), else_=UserNotInGameError(self)) @@ -404,17 +371,6 @@ def leave_game( self.game_location = None tracking.register_mutated(self) - assert_( - ( - self._last_game_with_id(last_game_id) is None - and self._last_game_with_game_id(game_id) is None - ), - else_=UserAlreadyLeftGameError, - ) - - last_game_ = last_game(last_game_id, self.id, game_id, tracking) - self.last_games.append(last_game_) - def buy_emoji( self, emoji: Emoji, @@ -566,22 +522,8 @@ def _stars_purchase(self, purchase_id: UUID) -> StarsPurchase: raise NoPurchaseError - def _last_game_with_id(self, last_game_id: UUID) -> LastGame | None: - for last_game_ in self.last_games: - if last_game_.id == last_game_id: - return last_game_ - - return None - - def _last_game_with_game_id(self, game_id: UUID) -> LastGame | None: - for last_game_ in self.last_games: - if last_game_.game_id == game_id: - return last_game_ - - return None - -UserAtomic = User | UserEmoji | StarsPurchase | LastGame +UserAtomic = User | UserEmoji | StarsPurchase def register_user(user_id: int, tracking: Tracking) -> User: @@ -589,7 +531,6 @@ def register_user(user_id: int, tracking: Tracking) -> User: id=user_id, account=Account(0), stars_purchases=[], - last_games=[], emojis=[], selected_emoji_id=None, rating=initial_elo_rating, diff --git a/src/ttt/entities/elo/rating.py b/src/ttt/entities/elo/rating.py index 4439655..8f51e4d 100644 --- a/src/ttt/entities/elo/rating.py +++ b/src/ttt/entities/elo/rating.py @@ -1,3 +1,5 @@ +from typing import Literal + from ttt.entities.elo.score import ExpectedScore, WinningScore @@ -7,11 +9,14 @@ initial_elo_rating: EloRating = 1000 +type GamesPlayed = Literal["<=30", ">30"] + + def new_elo_rating( rating: EloRating, other_rating: EloRating, winning_score: WinningScore, - games_played: int, + games_played: GamesPlayed, ) -> EloRating: expected_score = _expected_score(rating, other_rating) @@ -25,8 +30,8 @@ def new_elo_rating( return rating + k * (winning_score.value - expected_score) -def _is_player_newbie(games_played: int) -> bool: - return games_played <= 30 # noqa: PLR2004 +def _is_player_newbie(games_played: GamesPlayed) -> bool: + return games_played == "<=30" def _expected_score(rating_a: EloRating, rating_b: EloRating) -> ExpectedScore: diff --git a/src/ttt/infrastructure/adapters/game_dao.py b/src/ttt/infrastructure/adapters/game_dao.py new file mode 100644 index 0000000..35770c3 --- /dev/null +++ b/src/ttt/infrastructure/adapters/game_dao.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.application.game.game.ports.game_dao import GameDao +from ttt.entities.core.game.game import Game +from ttt.entities.core.user.user import User +from ttt.entities.elo.rating import GamesPlayed +from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.sqlalchemy.tables.game import TableGame, TableGameState + + +@dataclass(frozen=True, unsafe_hash=False) +class PostgresGameDao(GameDao): + _session: AsyncSession + + async def games_played_by_player_id( + self, + game: Game, + /, + ) -> dict[int, GamesPlayed]: + games_played_by_player_id = dict[int, GamesPlayed]() + + if isinstance(game.player1, User): + games_played_by_player_id[game.player1.id] = ( + await self._games_played(game.player1) + ) + + if isinstance(game.player2, User): + games_played_by_player_id[game.player2.id] = ( + await self._games_played(game.player2) + ) + + return games_played_by_player_id + + async def _games_played(self, user: User) -> GamesPlayed: + game_stmt = ( + select(1) + .where( + (TableGame.state == TableGameState.completed.value) + & ( + (TableGame.user1_id == user.id) + | (TableGame.user2_id == user.id) + ), + ) + .limit(31) + .subquery() + ) + stmt = select(func.count(1)).select_from(game_stmt) + raw_games_played = not_none(await self._session.scalar(stmt)) + + return "<=30" if raw_games_played <= 30 else ">30" # noqa: PLR2004 diff --git a/src/ttt/infrastructure/alembic/versions/dc59db88676c_remove_last_games.py b/src/ttt/infrastructure/alembic/versions/dc59db88676c_remove_last_games.py new file mode 100644 index 0000000..402a5e7 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/dc59db88676c_remove_last_games.py @@ -0,0 +1,51 @@ +""" +remove `last_games`. + +Revision ID: dc59db88676c +Revises: 325e2d4a600b +Create Date: 2025-09-15 10:38:01.081651 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = "dc59db88676c" +down_revision: str | None = "325e2d4a600b" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("last_games") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "last_games", + sa.Column("id", sa.UUID(), autoincrement=False, nullable=False), + sa.Column("user_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.Column("game_id", sa.UUID(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint( + ["game_id"], + ["games.id"], + name=op.f("last_games_game_id_fkey"), + initially="DEFERRED", + deferrable=True, + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("last_games_user_id_fkey"), + initially="DEFERRED", + deferrable=True, + ), + sa.PrimaryKeyConstraint("id", name=op.f("last_games_pkey")), + ) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/sqlalchemy/tables/user.py b/src/ttt/infrastructure/sqlalchemy/tables/user.py index f1b4d6e..ccd0091 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/user.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/user.py @@ -13,7 +13,6 @@ AdminRightViaOtherAdmin, ) from ttt.entities.core.user.emoji import UserEmoji -from ttt.entities.core.user.last_game import LastGame from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.stars_purchase import StarsPurchase from ttt.entities.core.user.user import User, UserAtomic @@ -96,33 +95,6 @@ def of(cls, it: StarsPurchase) -> "TableStarsPurchase": ) -class TableLastGame(Base[LastGame]): - __tablename__ = "last_games" - - id: Mapped[UUID] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - ForeignKey("users.id", deferrable=True, initially="DEFERRED"), - ) - game_id: Mapped[UUID] = mapped_column( - ForeignKey("games.id", deferrable=True, initially="DEFERRED"), - ) - - def __entity__(self) -> LastGame: - return LastGame( - id=self.id, - user_id=self.user_id, - game_id=self.game_id, - ) - - @classmethod - def of(cls, it: LastGame) -> "TableLastGame": - return TableLastGame( - id=it.id, - user_id=it.user_id, - game_id=it.game_id, - ) - - class TableAdminRight(StrEnum): via_admin_token = "via_admin_token" # noqa: S105 via_other_admin = "via_other_admin" @@ -176,10 +148,6 @@ class TableUser(Base[User]): lazy="selectin", foreign_keys=[TableStarsPurchase.user_id], ) - last_games: Mapped[list[TableLastGame]] = relationship( - lazy="selectin", - foreign_keys=[TableLastGame.user_id], - ) __table_args__ = ( Index( @@ -215,7 +183,6 @@ def __entity__(self) -> User: account=Account(self.account_stars), emojis=[it.entity() for it in self.emojis], stars_purchases=[it.entity() for it in self.stars_purchases], - last_games=[it.entity() for it in self.last_games], selected_emoji_id=self.selected_emoji_id, rating=self.rating, number_of_wins=self.number_of_wins, @@ -259,9 +226,7 @@ def of(cls, it: User) -> "TableUser": ) -type TableUserAtomic = ( - TableUser | TableUserEmoji | TableStarsPurchase | TableLastGame -) +type TableUserAtomic = TableUser | TableUserEmoji | TableStarsPurchase def table_user_atomic(entity: UserAtomic) -> TableUserAtomic: @@ -272,5 +237,3 @@ def table_user_atomic(entity: UserAtomic) -> TableUserAtomic: return TableUserEmoji.of(entity) case StarsPurchase(): return TableStarsPurchase.of(entity) - case LastGame(): - return TableLastGame.of(entity) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 8b00d42..5424e7a 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -18,6 +18,7 @@ from ttt.application.common.ports.transaction import Transaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway +from ttt.application.game.game.ports.game_dao import GameDao from ttt.application.game.game.ports.game_log import GameLog from ttt.application.game.game.ports.games import Games from ttt.application.invitation_to_game.game.ports.invitation_to_game_dao import ( # noqa: E501 @@ -57,6 +58,7 @@ ) from ttt.infrastructure.adapters.clock import NotMonotonicUtcClock from ttt.infrastructure.adapters.game_ai_gateway import GeminiGameAiGateway +from ttt.infrastructure.adapters.game_dao import PostgresGameDao from ttt.infrastructure.adapters.game_log import StructlogGameLog from ttt.infrastructure.adapters.games import InPostgresGames from ttt.infrastructure.adapters.invitation_to_game_dao import ( @@ -252,6 +254,12 @@ def provide_logger( scope=Scope.REQUEST, ) + provide_game_dao = provide( + PostgresGameDao, + provides=GameDao, + scope=Scope.REQUEST, + ) + provide_map = provide( MapToPostgres, provides=Map, diff --git a/tests/test_ttt/test_entities/test_core/conftest.py b/tests/test_ttt/test_entities/test_core/conftest.py index 1e2f831..1f0e93a 100644 --- a/tests/test_ttt/test_entities/test_core/conftest.py +++ b/tests/test_ttt/test_entities/test_core/conftest.py @@ -27,7 +27,6 @@ def user1() -> User: account=Account(0), emojis=[], stars_purchases=[], - last_games=[], selected_emoji_id=None, rating=1000., number_of_wins=0, @@ -45,7 +44,6 @@ def user2() -> User: account=Account(0), emojis=[], stars_purchases=[], - last_games=[], rating=1000., selected_emoji_id=None, number_of_wins=0, diff --git a/tests/test_ttt/test_entities/test_core/test_game.py b/tests/test_ttt/test_entities/test_core/test_game.py index 869b689..c2937c6 100644 --- a/tests/test_ttt/test_entities/test_core/test_game.py +++ b/tests/test_ttt/test_entities/test_core/test_game.py @@ -19,7 +19,6 @@ from ttt.entities.core.game.game_result import DecidedGameResult, DrawGameResult from ttt.entities.core.user.account import Account from ttt.entities.core.user.draw import UserDraw -from ttt.entities.core.user.last_game import LastGame from ttt.entities.core.user.loss import UserLoss from ttt.entities.core.user.user import User from ttt.entities.core.user.win import UserWin @@ -293,8 +292,7 @@ def test_make_move_with_completed_game( # noqa: PLR0913, PLR0917 game.make_user_move( 1, 1, - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -309,8 +307,7 @@ def test_make_move_with_not_user( game.make_user_move( 100, 9, - UUID(int=8), - UUID(int=9), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -325,8 +322,7 @@ def test_make_move_with_not_current_user( game.make_user_move( 2, 9, - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -341,8 +337,7 @@ def test_make_move_with_no_cell( game.make_user_move( 1, 10, - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -354,15 +349,14 @@ def test_make_move_with_already_filled_cell( tracking: Tracking, ) -> None: game.make_user_move( - 1, 1, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 1, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) with raises(AlreadyFilledCellError): game.make_user_move( 2, 1, - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -374,15 +368,14 @@ def test_make_move_with_double_move( tracking: Tracking, ) -> None: game.make_user_move( - 1, 1, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 1, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) with raises(NotCurrentPlayerError): game.make_user_move( 1, 2, - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -404,21 +397,21 @@ def test_winning_game( # noqa: PLR0913, PLR0917 """ game.make_user_move( - 1, 1, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 1, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 4, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 4, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 2, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 2, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 5, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 5, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 3, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 3, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) result = game.result @@ -433,9 +426,6 @@ def test_winning_game( # noqa: PLR0913, PLR0917 id=1, account=Account(50), emojis=[], - last_games=[ - LastGame(id=UUID(int=9), user_id=1, game_id=UUID(int=0)), - ], rating=1020.0, stars_purchases=[], selected_emoji_id=None, @@ -451,9 +441,6 @@ def test_winning_game( # noqa: PLR0913, PLR0917 id=2, account=Account(0), emojis=[], - last_games=[ - LastGame(id=UUID(int=10), user_id=2, game_id=UUID(int=0)), - ], rating=981.1500225556907, stars_purchases=[], selected_emoji_id=None, @@ -469,9 +456,7 @@ def test_winning_game( # noqa: PLR0913, PLR0917 game.make_user_move( 2, 6, - - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -493,35 +478,35 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 """ game.make_user_move( - 1, 1, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 1, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 2, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 2, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 3, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 3, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 5, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 5, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 4, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 4, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 7, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 7, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 6, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 6, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 9, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 9, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 8, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 8, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) result = game.result @@ -537,9 +522,6 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 account=Account(0), emojis=[], rating=1020.0, - last_games=[ - LastGame(id=UUID(int=9), user_id=1, game_id=UUID(int=0)), - ], stars_purchases=[], selected_emoji_id=None, number_of_wins=0, @@ -555,9 +537,6 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 account=Account(0), emojis=[], stars_purchases=[], - last_games=[ - LastGame(id=UUID(int=10), user_id=2, game_id=UUID(int=0)), - ], rating=1020.0, selected_emoji_id=None, number_of_wins=0, @@ -572,9 +551,7 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 game.make_user_move( 2, 5, - - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) @@ -596,35 +573,35 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 """ game.make_user_move( - 1, 1, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 1, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 2, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 2, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 3, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 3, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 4, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 4, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 5, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 5, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 6, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 6, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 8, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 8, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 2, 9, UUID(int=9), UUID(int=10), middle_random, tracking, + 2, 9, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) game.make_user_move( - 1, 7, UUID(int=9), UUID(int=10), middle_random, tracking, + 1, 7, {1: "<=30", 2: "<=30"}, middle_random, tracking, ) result = game.result @@ -641,9 +618,6 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 emojis=[], rating=1020.0, stars_purchases=[], - last_games=[ - LastGame(id=UUID(int=9), user_id=1, game_id=UUID(int=0)), - ], selected_emoji_id=None, number_of_wins=1, number_of_draws=0, @@ -659,9 +633,6 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 emojis=[], rating=981.1500225556907, stars_purchases=[], - last_games=[ - LastGame(id=UUID(int=10), user_id=2, game_id=UUID(int=0)), - ], selected_emoji_id=None, number_of_wins=0, number_of_draws=0, @@ -675,9 +646,7 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 game.make_user_move( 2, 5, - - UUID(int=9), - UUID(int=10), + {1: "<=30", 2: "<=30"}, middle_random, tracking, ) diff --git a/tests/test_ttt/test_entities/test_core/test_user.py b/tests/test_ttt/test_entities/test_core/test_user.py index b452789..22a1864 100644 --- a/tests/test_ttt/test_entities/test_core/test_user.py +++ b/tests/test_ttt/test_entities/test_core/test_user.py @@ -16,7 +16,6 @@ def test_create_user(tracking: Tracking, object_: str) -> None: emojis=[], rating=1000., stars_purchases=[], - last_games=[], selected_emoji_id=None, number_of_wins=0, number_of_draws=0, diff --git a/tests/test_ttt/test_infrastructure/conftest.py b/tests/test_ttt/test_infrastructure/conftest.py index f3373a0..0a97cfb 100755 --- a/tests/test_ttt/test_infrastructure/conftest.py +++ b/tests/test_ttt/test_infrastructure/conftest.py @@ -1,3 +1,4 @@ +import warnings from collections.abc import AsyncIterable from pytest import fixture @@ -18,7 +19,7 @@ def engine(envs: Envs) -> AsyncEngine: @fixture(scope="session") -async def _session_session( +async def _session( engine: AsyncEngine, ) -> AsyncIterable[AsyncSession]: session = AsyncSession( @@ -34,12 +35,16 @@ async def _session_session( @fixture async def session( - _session_session: AsyncSession, + _session: AsyncSession, ) -> AsyncSession: - await _clear_db(_session_session) - return _session_session + async with _session.begin(): + await _clear_db(_session) + return _session async def _clear_db(session: AsyncSession) -> None: - for table in reversed(Base.metadata.sorted_tables): - await session.execute(delete(table)) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "Cannot correctly sort tables") + + for table in reversed(Base.metadata.sorted_tables): + await session.execute(delete(table)) diff --git a/tests/test_ttt/test_infrastructure/test_adapters/__init__.py b/tests/test_ttt/test_infrastructure/test_adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py new file mode 100644 index 0000000..d0b3355 --- /dev/null +++ b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py @@ -0,0 +1,123 @@ +from uuid import UUID, uuid4 + +from pytest import fixture, mark +from sqlalchemy import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.entities.core.game.ai import Ai, AiType +from ttt.entities.core.game.cell import Cell +from ttt.entities.core.game.game import Game, GameState +from ttt.entities.core.user.account import Account +from ttt.entities.core.user.location import UserGameLocation +from ttt.entities.core.user.user import User +from ttt.entities.elo.rating import GamesPlayed +from ttt.entities.math.matrix import Matrix +from ttt.entities.text.emoji import Emoji +from ttt.infrastructure.adapters.game_dao import PostgresGameDao +from ttt.infrastructure.sqlalchemy.tables.game import TableGame, TableGameState +from ttt.infrastructure.sqlalchemy.tables.user import TableUser + + +@fixture +def dao(session: AsyncSession) -> PostgresGameDao: + return PostgresGameDao(session) + + +@fixture +def player1() -> User: + return User( + id=1, + account=Account(0), + emojis=[], + stars_purchases=[], + selected_emoji_id=None, + rating=1000., + number_of_wins=0, + number_of_draws=0, + number_of_defeats=0, + game_location=UserGameLocation(1, UUID(int=0)), + admin_right=None, + ) + + +@fixture +def player2() -> Ai: + return Ai( + id=UUID(int=2), + type=AiType.gemini_2_0_flash, + ) + + +@fixture +def game(player1: User, player2: Ai) -> Game: + return Game( + id=UUID(int=0), + player1=player1, + player1_emoji=Emoji("1"), + player2=player2, + player2_emoji=Emoji("2"), + board=Matrix([ + [ + Cell(UUID(int=0), UUID(int=0), (0, 0), None, None), + Cell(UUID(int=0), UUID(int=0), (1, 0), None, None), + Cell(UUID(int=0), UUID(int=0), (2, 0), None, None), + ], + [ + Cell(UUID(int=0), UUID(int=0), (0, 1), None, None), + Cell(UUID(int=0), UUID(int=0), (1, 1), None, None), + Cell(UUID(int=0), UUID(int=0), (2, 1), None, None), + ], + [ + Cell(UUID(int=0), UUID(int=0), (0, 2), None, None), + Cell(UUID(int=0), UUID(int=0), (1, 2), None, None), + Cell(UUID(int=0), UUID(int=0), (2, 2), None, None), + ], + ]), + number_of_unfilled_cells=9, + result=None, + state=GameState.wait_player1, + ) + + +@mark.parametrize( + ("games", "result"), + [ + (0, "<=30"), + (5, "<=30"), + (30, "<=30"), + (31, ">30"), + (40, ">30"), + ], +) +async def test_games_played_by_player_id( + dao: PostgresGameDao, + session: AsyncSession, + game: Game, + games: int, + result: GamesPlayed, +) -> None: + async with session.begin(): + await session.execute( + insert(TableUser).values({ + "id": 1, + "rating": 1000, + "number_of_wins": 0, + "number_of_draws": 0, + "number_of_defeats": 0, + }), + ) + if games: + await session.execute( + insert(TableGame).values([ + { + "id": uuid4(), + "user1_id": 1, + "state": TableGameState.completed.value, + } + for _ in range(games) + ]), + ) + + async with session.begin(): + games_played_by_player_id = await dao.games_played_by_player_id(game) + assert games_played_by_player_id == {1: result} diff --git a/uv.lock b/uv.lock index 4d0f8e6..709ddac 100644 --- a/uv.lock +++ b/uv.lock @@ -1030,7 +1030,7 @@ wheels = [ [[package]] name = "ttt" -version = "0.3.0" +version = "0.4.2" source = { editable = "." } dependencies = [ { name = "aiogram" }, From ce02fc2b6f05e8f4e8abb59318b272e5b8fbbdd6 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 15 Sep 2025 17:47:30 +0700 Subject: [PATCH 02/45] style: fix `ruff` errors (#51) --- src/ttt/application/game/game/cancel_game.py | 1 - src/ttt/infrastructure/alembic/env.py | 12 ++++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ttt/application/game/game/cancel_game.py b/src/ttt/application/game/game/cancel_game.py index e67ed38..b0c0e25 100644 --- a/src/ttt/application/game/game/cancel_game.py +++ b/src/ttt/application/game/game/cancel_game.py @@ -1,4 +1,3 @@ -from asyncio import gather from dataclasses import dataclass from ttt.application.common.ports.map import Map diff --git a/src/ttt/infrastructure/alembic/env.py b/src/ttt/infrastructure/alembic/env.py index 153c9b9..cdfc1b9 100644 --- a/src/ttt/infrastructure/alembic/env.py +++ b/src/ttt/infrastructure/alembic/env.py @@ -1,3 +1,4 @@ +import warnings from logging.config import fileConfig import alembic_postgresql_enum # noqa: F401 @@ -69,7 +70,10 @@ def run_migrations_online() -> None: context.run_migrations() -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() +with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "Cannot correctly sort tables") + + if context.is_offline_mode(): + run_migrations_offline() + else: + run_migrations_online() From 7b95ae0f3bc0ad43fd73a50091c6872bfa17430f Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:36:30 +0700 Subject: [PATCH 03/45] update: use latest `ruff` and `mypy` --- pyproject.toml | 4 +- src/ttt/entities/core/game/cell_number.py | 6 +- .../nats/paid_stars_purchase_payment_inbox.py | 7 +- uv.lock | 65 ++++++++++--------- 4 files changed, 40 insertions(+), 42 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e98ce6a..6d18112 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,8 @@ dependencies = [ [dependency-groups] dev = [ - "mypy[faster-cache]==1.16.0", - "ruff==0.11.13", + "mypy[faster-cache]==1.18.1", + "ruff==0.13.0", "pytest==8.4.0", "pytest-cov==6.1.1", "pytest-asyncio==1.0.0", diff --git a/src/ttt/entities/core/game/cell_number.py b/src/ttt/entities/core/game/cell_number.py index da24c0f..3c2c31f 100644 --- a/src/ttt/entities/core/game/cell_number.py +++ b/src/ttt/entities/core/game/cell_number.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import ClassVar, cast +from typing import ClassVar from ttt.entities.math.vector import Vector from ttt.entities.tools.assertion import assert_, not_none @@ -44,9 +44,9 @@ def __int__(self) -> int: @classmethod def of_board_position(cls, board_position: Vector) -> "CellNumber": int_ = CellNumber._int_by_board_position.get(board_position) - int_ = cast(int, not_none(int_, InvalidCellNumberError)) + int_ = not_none(int_, InvalidCellNumberError) return CellNumber(int_) def board_position(self) -> "Vector": - return cast(Vector, CellNumber._board_position_by_int[int(self)]) + return CellNumber._board_position_by_int[int(self)] diff --git a/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py b/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py index 00ae60c..f64811e 100644 --- a/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py +++ b/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py @@ -1,7 +1,7 @@ from collections.abc import AsyncIterator from dataclasses import dataclass, field from types import TracebackType -from typing import ClassVar, Self, cast +from typing import ClassVar, Self from nats.js import JetStreamContext from pydantic import TypeAdapter @@ -39,7 +39,4 @@ async def push(self, payment: PaidStarsPurchasePayment) -> None: async def __aiter__(self) -> AsyncIterator[PaidStarsPurchasePayment]: async for message in at_least_once_messages(self._subscription): - yield cast( - PaidStarsPurchasePayment, - self._adapter.validate_json(message.data), - ) + yield self._adapter.validate_json(message.data) diff --git a/uv.lock b/uv.lock index 709ddac..ab4b6fe 100644 --- a/uv.lock +++ b/uv.lock @@ -554,22 +554,22 @@ wheels = [ [[package]] name = "mypy" -version = "1.16.0" +version = "1.18.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/38/13c2f1abae94d5ea0354e146b95a1be9b2137a0d506728e0da037c4276f6/mypy-1.16.0.tar.gz", hash = "sha256:84b94283f817e2aa6350a14b4a8fb2a35a53c286f97c9d30f53b63620e7af8ab", size = 3323139, upload-time = "2025-05-29T13:46:12.532Z" } +sdist = { url = "https://files.pythonhosted.org/packages/14/a3/931e09fc02d7ba96da65266884da4e4a8806adcdb8a57faaacc6edf1d538/mypy-1.18.1.tar.gz", hash = "sha256:9e988c64ad3ac5987f43f5154f884747faf62141b7f842e87465b45299eea5a9", size = 3448447, upload-time = "2025-09-11T23:00:47.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/9c/ca03bdbefbaa03b264b9318a98950a9c683e06472226b55472f96ebbc53d/mypy-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e056237c89f1587a3be1a3a70a06a698d25e2479b9a2f57325ddaaffc3567b", size = 11059753, upload-time = "2025-05-29T13:18:18.167Z" }, - { url = "https://files.pythonhosted.org/packages/36/92/79a969b8302cfe316027c88f7dc6fee70129490a370b3f6eb11d777749d0/mypy-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b07e107affb9ee6ce1f342c07f51552d126c32cd62955f59a7db94a51ad12c0", size = 10073338, upload-time = "2025-05-29T13:19:48.079Z" }, - { url = "https://files.pythonhosted.org/packages/14/9b/a943f09319167da0552d5cd722104096a9c99270719b1afeea60d11610aa/mypy-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fb60cbd85dc65d4d63d37cb5c86f4e3a301ec605f606ae3a9173e5cf34997b", size = 11827764, upload-time = "2025-05-29T13:46:04.47Z" }, - { url = "https://files.pythonhosted.org/packages/ec/64/ff75e71c65a0cb6ee737287c7913ea155845a556c64144c65b811afdb9c7/mypy-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7e32297a437cc915599e0578fa6bc68ae6a8dc059c9e009c628e1c47f91495d", size = 12701356, upload-time = "2025-05-29T13:35:13.553Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ad/0e93c18987a1182c350f7a5fab70550852f9fabe30ecb63bfbe51b602074/mypy-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:afe420c9380ccec31e744e8baff0d406c846683681025db3531b32db56962d52", size = 12900745, upload-time = "2025-05-29T13:17:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/28/5d/036c278d7a013e97e33f08c047fe5583ab4f1fc47c9a49f985f1cdd2a2d7/mypy-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:55f9076c6ce55dd3f8cd0c6fff26a008ca8e5131b89d5ba6d86bd3f47e736eeb", size = 9572200, upload-time = "2025-05-29T13:33:44.92Z" }, - { url = "https://files.pythonhosted.org/packages/99/a3/6ed10530dec8e0fdc890d81361260c9ef1f5e5c217ad8c9b21ecb2b8366b/mypy-1.16.0-py3-none-any.whl", hash = "sha256:29e1499864a3888bca5c1542f2d7232c6e586295183320caa95758fc84034031", size = 2265773, upload-time = "2025-05-29T13:35:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ec/ef4a7260e1460a3071628a9277a7579e7da1b071bc134ebe909323f2fbc7/mypy-1.18.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d70d2b5baf9b9a20bc9c730015615ae3243ef47fb4a58ad7b31c3e0a59b5ef1f", size = 12918671, upload-time = "2025-09-11T22:58:29.814Z" }, + { url = "https://files.pythonhosted.org/packages/a1/82/0ea6c3953f16223f0b8eda40c1aeac6bd266d15f4902556ae6e91f6fca4c/mypy-1.18.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b8367e33506300f07a43012fc546402f283c3f8bcff1dc338636affb710154ce", size = 11913023, upload-time = "2025-09-11T23:00:29.049Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ef/5e2057e692c2690fc27b3ed0a4dbde4388330c32e2576a23f0302bc8358d/mypy-1.18.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:913f668ec50c3337b89df22f973c1c8f0b29ee9e290a8b7fe01cc1ef7446d42e", size = 12473355, upload-time = "2025-09-11T23:00:04.544Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/b7e429fc4be10e390a167b0cd1810d41cb4e4add4ae50bab96faff695a3b/mypy-1.18.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0e70b87eb27b33209fa4792b051c6947976f6ab829daa83819df5f58330c71", size = 13346944, upload-time = "2025-09-11T22:58:23.024Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/899dba0bfe36bbd5b7c52e597de4cf47b5053d337b6d201a30e3798e77a6/mypy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c378d946e8a60be6b6ede48c878d145546fb42aad61df998c056ec151bf6c746", size = 13512574, upload-time = "2025-09-11T22:59:52.152Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f8/7661021a5b0e501b76440454d786b0f01bb05d5c4b125fcbda02023d0250/mypy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:2cd2c1e0f3a7465f22731987fff6fc427e3dcbb4ca5f7db5bbeaff2ff9a31f6d", size = 9837684, upload-time = "2025-09-11T22:58:44.454Z" }, + { url = "https://files.pythonhosted.org/packages/e0/1d/4b97d3089b48ef3d904c9ca69fab044475bd03245d878f5f0b3ea1daf7ce/mypy-1.18.1-py3-none-any.whl", hash = "sha256:b76a4de66a0ac01da1be14ecc8ae88ddea33b8380284a9e3eae39d57ebcbe26e", size = 2352212, upload-time = "2025-09-11T22:59:26.576Z" }, ] [package.optional-dependencies] @@ -928,27 +928,28 @@ hiredis = [ [[package]] name = "ruff" -version = "0.11.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, - { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, - { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, - { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, - { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, - { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, - { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, - { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, - { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, - { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, - { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, - { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863, upload-time = "2025-09-10T16:25:37.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826, upload-time = "2025-09-10T16:24:39.5Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428, upload-time = "2025-09-10T16:24:43.866Z" }, + { url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543, upload-time = "2025-09-10T16:24:46.638Z" }, + { url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489, upload-time = "2025-09-10T16:24:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631, upload-time = "2025-09-10T16:24:53.439Z" }, + { url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602, upload-time = "2025-09-10T16:24:56.392Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751, upload-time = "2025-09-10T16:24:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317, upload-time = "2025-09-10T16:25:03.025Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418, upload-time = "2025-09-10T16:25:06.272Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843, upload-time = "2025-09-10T16:25:09.965Z" }, + { url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891, upload-time = "2025-09-10T16:25:12.969Z" }, + { url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119, upload-time = "2025-09-10T16:25:16.621Z" }, + { url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594, upload-time = "2025-09-10T16:25:19.49Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377, upload-time = "2025-09-10T16:25:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555, upload-time = "2025-09-10T16:25:25.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613, upload-time = "2025-09-10T16:25:28.664Z" }, + { url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250, upload-time = "2025-09-10T16:25:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" }, ] [[package]] @@ -1084,11 +1085,11 @@ requires-dist = [ dev = [ { name = "better-exceptions", specifier = "==0.3.3" }, { name = "dirty-equals", specifier = "==0.9.0" }, - { name = "mypy", extras = ["faster-cache"], specifier = "==1.16.0" }, + { name = "mypy", extras = ["faster-cache"], specifier = "==1.18.1" }, { name = "pytest", specifier = "==8.4.0" }, { name = "pytest-asyncio", specifier = "==1.0.0" }, { name = "pytest-cov", specifier = "==6.1.1" }, - { name = "ruff", specifier = "==0.11.13" }, + { name = "ruff", specifier = "==0.13.0" }, ] [[package]] From af00f3a752200aa7bacf1be32f24eeff30fe9d8e Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:46:05 +0700 Subject: [PATCH 04/45] chore(`ci`): lighten `jobs` --- .github/workflows/ci.yml | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d12a8b2..5a91b88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,30 +8,27 @@ jobs: steps: - uses: actions/checkout@v4 - - name: add secrets + - name: install ruff run: | - echo "bot_token: ${{ secrets.BOT_TOKEN }}" > deploy/dev/ttt/secrets.yaml - echo "payments_token: ${{ secrets.PAYMENTS_TOKEN }}" >> deploy/dev/ttt/secrets.yaml - echo "gemini_api_key: ${{ secrets.GEMINI_API_KEY }}" >> deploy/dev/ttt/secrets.yaml - echo "sentry_dsn: ${{ secrets.SENTRY_DSN }}" >> deploy/dev/ttt/secrets.yaml + curl -LsSf https://astral.sh/ruff/0.13.0/install.sh | sh + source $HOME/.local/bin/env - name: ruff - run: docker compose -f deploy/dev/docker-compose.yaml run ttt ruff check src tests + run: ruff check src tests mypy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: add secrets + - name: install dependencies run: | - echo "bot_token: ${{ secrets.BOT_TOKEN }}" > deploy/dev/ttt/secrets.yaml - echo "payments_token: ${{ secrets.PAYMENTS_TOKEN }}" >> deploy/dev/ttt/secrets.yaml - echo "gemini_api_key: ${{ secrets.GEMINI_API_KEY }}" >> deploy/dev/ttt/secrets.yaml - echo "sentry_dsn: ${{ secrets.SENTRY_DSN }}" >> deploy/dev/ttt/secrets.yaml + curl -LsSf https://astral.sh/uv/install.sh | sh + source $HOME/.local/bin/env + uv sync - name: mypy - run: docker compose -f deploy/dev/docker-compose.yaml run ttt mypy src tests + run: uv run mypy src tests pytest: runs-on: ubuntu-latest @@ -44,6 +41,7 @@ jobs: echo "payments_token: ${{ secrets.PAYMENTS_TOKEN }}" >> deploy/dev/ttt/secrets.yaml echo "gemini_api_key: ${{ secrets.GEMINI_API_KEY }}" >> deploy/dev/ttt/secrets.yaml echo "sentry_dsn: ${{ secrets.SENTRY_DSN }}" >> deploy/dev/ttt/secrets.yaml + echo "sentry_dsn: ${{ secrets.ADMIN_TOKEN }}" >> deploy/dev/ttt/secrets.yaml - name: pytest run: docker compose -f deploy/dev/docker-compose.yaml run ttt pytest tests --cov --cov-report=xml From f644454432a2439f12c9713a080f37df78818f0e Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 15 Sep 2025 19:48:22 +0700 Subject: [PATCH 05/45] chore(`ci`): remove `source /home/sunny/.local/bin/env` --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a91b88..e90f83a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,7 @@ jobs: - uses: actions/checkout@v4 - name: install ruff - run: | - curl -LsSf https://astral.sh/ruff/0.13.0/install.sh | sh - source $HOME/.local/bin/env + run: curl -LsSf https://astral.sh/ruff/0.13.0/install.sh | sh - name: ruff run: ruff check src tests @@ -24,7 +22,6 @@ jobs: - name: install dependencies run: | curl -LsSf https://astral.sh/uv/install.sh | sh - source $HOME/.local/bin/env uv sync - name: mypy From 976f351feb029c02e6fc1ebe39e0fb181b63e09f Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:29:28 +0700 Subject: [PATCH 06/45] ref(`entities`): extract `StarsPurchase` from `User` (#52) --- .../common/dto => stars_purchase}/__init__.py | 0 .../complete_stars_purchase_payment.py | 46 +++---- .../dto}/__init__.py | 0 .../common => stars_purchase}/dto/common.py | 0 .../stars_purchase/ports/__init__.py | 0 .../paid_stars_purchase_payment_inbox.py | 2 +- .../ports/stars_purchase_log.py} | 35 +++--- .../ports/stars_purchase_payment_gateway.py | 4 +- .../ports/stars_purchase_views.py} | 8 +- .../stars_purchase/ports/stars_purchases.py | 13 ++ .../stars_purchase/start_stars_purchase.py | 42 +++---- .../start_stars_purchase_payment.py | 48 ++++---- ...start_stars_purchase_payment_completion.py | 18 +-- src/ttt/entities/atomic.py | 2 + .../entities/core/stars_purchase/__init__.py | 0 .../stars_purchase.py | 78 +++++++++--- src/ttt/entities/core/user/user.py | 95 +-------------- .../paid_stars_purchase_payment_inbox.py | 4 +- .../adapters/stars_purchase_log.py | 111 +++++++++++++++++ .../adapters/stars_purchases.py | 33 +++++ src/ttt/infrastructure/adapters/user_log.py | 113 ------------------ .../nats/paid_stars_purchase_payment_inbox.py | 2 +- .../sqlalchemy/tables/__init__.py | 3 + .../sqlalchemy/tables/atomic.py | 9 ++ .../sqlalchemy/tables/stars_purchase.py | 67 +++++++++++ .../infrastructure/sqlalchemy/tables/user.py | 55 +-------- src/ttt/main/common/di.py | 28 +++-- src/ttt/main/tg_bot/di.py | 81 +++++++------ .../stars_purchase_payment_gateway.py | 8 +- .../adapters/stars_purchase_views.py | 50 ++++++++ src/ttt/presentation/adapters/user_views.py | 43 ------- src/ttt/presentation/aiogram/user/invoices.py | 6 +- .../aiogram/user/routes/handle_payment.py | 2 +- .../user/routes/handle_pre_checkout_query.py | 9 +- .../main_dialog/stars_shop_window.py | 2 +- .../test_entities/test_core/conftest.py | 2 - .../test_entities/test_core/test_game.py | 6 - .../test_entities/test_core/test_user.py | 1 - .../test_adapters/test_game_dao.py | 1 - 39 files changed, 524 insertions(+), 503 deletions(-) rename src/ttt/application/{user/common/dto => stars_purchase}/__init__.py (100%) rename src/ttt/application/{user => }/stars_purchase/complete_stars_purchase_payment.py (63%) rename src/ttt/application/{user/stars_purchase => stars_purchase/dto}/__init__.py (100%) rename src/ttt/application/{user/common => stars_purchase}/dto/common.py (100%) rename src/ttt/application/{user => }/stars_purchase/ports/__init__.py (100%) rename src/ttt/application/{user => }/stars_purchase/ports/paid_stars_purchase_payment_inbox.py (79%) rename src/ttt/application/{user/stars_purchase/ports/user_log.py => stars_purchase/ports/stars_purchase_log.py} (68%) rename src/ttt/application/{user => }/stars_purchase/ports/stars_purchase_payment_gateway.py (80%) rename src/ttt/application/{user/stars_purchase/ports/user_views.py => stars_purchase/ports/stars_purchase_views.py} (74%) create mode 100644 src/ttt/application/stars_purchase/ports/stars_purchases.py rename src/ttt/application/{user => }/stars_purchase/start_stars_purchase.py (65%) rename src/ttt/application/{user => }/stars_purchase/start_stars_purchase_payment.py (56%) rename src/ttt/application/{user => }/stars_purchase/start_stars_purchase_payment_completion.py (60%) create mode 100644 src/ttt/entities/core/stars_purchase/__init__.py rename src/ttt/entities/core/{user => stars_purchase}/stars_purchase.py (57%) create mode 100644 src/ttt/infrastructure/adapters/stars_purchase_log.py create mode 100644 src/ttt/infrastructure/adapters/stars_purchases.py create mode 100644 src/ttt/infrastructure/sqlalchemy/tables/stars_purchase.py create mode 100644 src/ttt/presentation/adapters/stars_purchase_views.py diff --git a/src/ttt/application/user/common/dto/__init__.py b/src/ttt/application/stars_purchase/__init__.py similarity index 100% rename from src/ttt/application/user/common/dto/__init__.py rename to src/ttt/application/stars_purchase/__init__.py diff --git a/src/ttt/application/user/stars_purchase/complete_stars_purchase_payment.py b/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py similarity index 63% rename from src/ttt/application/user/stars_purchase/complete_stars_purchase_payment.py rename to src/ttt/application/stars_purchase/complete_stars_purchase_payment.py index 1d04221..6e3de3c 100644 --- a/src/ttt/application/user/stars_purchase/complete_stars_purchase_payment.py +++ b/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py @@ -3,17 +3,18 @@ from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map from ttt.application.common.ports.transaction import Transaction -from ttt.application.user.common.ports.user_views import CommonUserViews -from ttt.application.user.common.ports.users import Users -from ttt.application.user.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 +from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 PaidStarsPurchasePaymentInbox, ) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, ) -from ttt.application.user.stars_purchase.ports.user_views import ( - StarsPurchaseUserViews, +from ttt.application.stars_purchase.ports.stars_purchase_views import ( + StarsPurchaseViews, ) +from ttt.application.stars_purchase.ports.stars_purchases import StarsPurchases +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users from ttt.entities.finance.payment.payment import PaymentIsNotInProcessError from ttt.entities.tools.tracking import Tracking @@ -26,47 +27,48 @@ class CompleteStarsPurchasePayment: transaction: Transaction map_: Map common_views: CommonUserViews - stars_purchase_views: StarsPurchaseUserViews - log: StarsPurchaseUserLog + stars_purchase_views: StarsPurchaseViews + log: StarsPurchaseLog + stars_purchases: StarsPurchases async def __call__(self) -> None: async for paid_payment in self.inbox.stream(): current_datetime = await self.clock.current_datetime() async with self.transaction: - user = await self.users.user_with_id( - paid_payment.user_id, + stars_purchase = ( + await self.stars_purchases.stars_purchase_with_id( + paid_payment.purchase_id, + ) ) - if user is None: - await self.common_views.user_is_not_registered_view( - paid_payment.user_id, + if stars_purchase is None: + await self.log.no_stars_purchase_to_complete_payment( + paid_payment.purchase_id, ) - continue + return - tracking = Tracking() try: - user.complete_stars_purchase_payment( - paid_payment.purchase_id, + tracking = Tracking() + stars_purchase.complete_payment( paid_payment.success, current_datetime, tracking, ) except PaymentIsNotInProcessError: await self.log.double_stars_purchase_payment_completion( - user, + stars_purchase, paid_payment, ) else: await self.log.stars_purchase_payment_completed( - user, + stars_purchase, paid_payment, ) await self.map_(tracking) await ( self.stars_purchase_views.completed_stars_purchase_view( - user, - paid_payment.purchase_id, + stars_purchase, ) ) diff --git a/src/ttt/application/user/stars_purchase/__init__.py b/src/ttt/application/stars_purchase/dto/__init__.py similarity index 100% rename from src/ttt/application/user/stars_purchase/__init__.py rename to src/ttt/application/stars_purchase/dto/__init__.py diff --git a/src/ttt/application/user/common/dto/common.py b/src/ttt/application/stars_purchase/dto/common.py similarity index 100% rename from src/ttt/application/user/common/dto/common.py rename to src/ttt/application/stars_purchase/dto/common.py diff --git a/src/ttt/application/user/stars_purchase/ports/__init__.py b/src/ttt/application/stars_purchase/ports/__init__.py similarity index 100% rename from src/ttt/application/user/stars_purchase/ports/__init__.py rename to src/ttt/application/stars_purchase/ports/__init__.py diff --git a/src/ttt/application/user/stars_purchase/ports/paid_stars_purchase_payment_inbox.py b/src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py similarity index 79% rename from src/ttt/application/user/stars_purchase/ports/paid_stars_purchase_payment_inbox.py rename to src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py index 11c96f1..845107c 100644 --- a/src/ttt/application/user/stars_purchase/ports/paid_stars_purchase_payment_inbox.py +++ b/src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterable -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment +from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment class PaidStarsPurchasePaymentInbox(ABC): diff --git a/src/ttt/application/user/stars_purchase/ports/user_log.py b/src/ttt/application/stars_purchase/ports/stars_purchase_log.py similarity index 68% rename from src/ttt/application/user/stars_purchase/ports/user_log.py rename to src/ttt/application/stars_purchase/ports/stars_purchase_log.py index d4deca5..cfb73c2 100644 --- a/src/ttt/application/user/stars_purchase/ports/user_log.py +++ b/src/ttt/application/stars_purchase/ports/stars_purchase_log.py @@ -1,30 +1,24 @@ from abc import ABC, abstractmethod from uuid import UUID -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment +from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment from ttt.entities.core.stars import Stars +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase from ttt.entities.core.user.user import User -class StarsPurchaseUserLog(ABC): +class StarsPurchaseLog(ABC): @abstractmethod - async def user_intends_to_buy_stars( + async def stars_puchase_started( self, - user_id: int, + stars_purchase: StarsPurchase, /, ) -> None: ... @abstractmethod - async def user_started_stars_puchase( + async def stars_puchase_payment_started( self, - user: User, - /, - ) -> None: ... - - @abstractmethod - async def user_started_stars_puchase_payment( - self, - user: User, + stars_purchase: StarsPurchase, /, ) -> None: ... @@ -38,7 +32,7 @@ async def stars_purchase_payment_completion_started( @abstractmethod async def stars_purchase_payment_completed( self, - user: User, + stars_purchase: StarsPurchase, payment: PaidStarsPurchasePayment, /, ) -> None: ... @@ -46,7 +40,7 @@ async def stars_purchase_payment_completed( @abstractmethod async def double_stars_purchase_payment_completion( self, - user: User, + stars_purchase: StarsPurchase, paid_payment: PaidStarsPurchasePayment, ) -> None: ... @@ -61,15 +55,20 @@ async def invalid_stars_for_stars_purchase( @abstractmethod async def double_stars_purchase_payment_start( self, - user: User, + stars_purchase: StarsPurchase, + /, + ) -> None: ... + + @abstractmethod + async def no_stars_purchase_to_start_payment( + self, purchase_id: UUID, /, ) -> None: ... @abstractmethod - async def no_purchase_to_start_stars_purchase_payment( + async def no_stars_purchase_to_complete_payment( self, - user: User, purchase_id: UUID, /, ) -> None: ... diff --git a/src/ttt/application/user/stars_purchase/ports/stars_purchase_payment_gateway.py b/src/ttt/application/stars_purchase/ports/stars_purchase_payment_gateway.py similarity index 80% rename from src/ttt/application/user/stars_purchase/ports/stars_purchase_payment_gateway.py rename to src/ttt/application/stars_purchase/ports/stars_purchase_payment_gateway.py index 9f3e05c..55e93ac 100644 --- a/src/ttt/application/user/stars_purchase/ports/stars_purchase_payment_gateway.py +++ b/src/ttt/application/stars_purchase/ports/stars_purchase_payment_gateway.py @@ -2,8 +2,8 @@ from collections.abc import AsyncIterable from uuid import UUID -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment -from ttt.entities.core.user.stars_purchase import StarsPurchase +from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase class StarsPurchasePaymentGateway(ABC): diff --git a/src/ttt/application/user/stars_purchase/ports/user_views.py b/src/ttt/application/stars_purchase/ports/stars_purchase_views.py similarity index 74% rename from src/ttt/application/user/stars_purchase/ports/user_views.py rename to src/ttt/application/stars_purchase/ports/stars_purchase_views.py index 3469ec9..6c66381 100644 --- a/src/ttt/application/user/stars_purchase/ports/user_views.py +++ b/src/ttt/application/stars_purchase/ports/stars_purchase_views.py @@ -1,10 +1,9 @@ from abc import ABC, abstractmethod -from uuid import UUID -from ttt.entities.core.user.user import User +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase -class StarsPurchaseUserViews(ABC): +class StarsPurchaseViews(ABC): @abstractmethod async def invalid_stars_for_stars_purchase_view( self, @@ -22,7 +21,6 @@ async def stars_purchase_will_be_completed_view( @abstractmethod async def completed_stars_purchase_view( self, - user: User, - purchase_id: UUID, + stars_purchase: StarsPurchase, /, ) -> None: ... diff --git a/src/ttt/application/stars_purchase/ports/stars_purchases.py b/src/ttt/application/stars_purchase/ports/stars_purchases.py new file mode 100644 index 0000000..fab2882 --- /dev/null +++ b/src/ttt/application/stars_purchase/ports/stars_purchases.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod +from uuid import UUID + +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase + + +class StarsPurchases(ABC): + @abstractmethod + async def stars_purchase_with_id( + self, + id_: UUID, + /, + ) -> StarsPurchase | None: ... diff --git a/src/ttt/application/user/stars_purchase/start_stars_purchase.py b/src/ttt/application/stars_purchase/start_stars_purchase.py similarity index 65% rename from src/ttt/application/user/stars_purchase/start_stars_purchase.py rename to src/ttt/application/stars_purchase/start_stars_purchase.py index bc69137..41ef7c9 100644 --- a/src/ttt/application/user/stars_purchase/start_stars_purchase.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase.py @@ -5,19 +5,19 @@ from ttt.application.common.ports.map import Map from ttt.application.common.ports.transaction import Transaction from ttt.application.common.ports.uuids import UUIDs -from ttt.application.user.common.ports.user_views import CommonUserViews -from ttt.application.user.common.ports.users import Users -from ttt.application.user.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 - StarsPurchasePaymentGateway, +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, ) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, +from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 + StarsPurchasePaymentGateway, ) -from ttt.application.user.stars_purchase.ports.user_views import ( - StarsPurchaseUserViews, +from ttt.application.stars_purchase.ports.stars_purchase_views import ( + StarsPurchaseViews, ) +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users from ttt.entities.core.stars import Stars -from ttt.entities.core.user.stars_purchase import ( +from ttt.entities.core.stars_purchase.stars_purchase import ( InvalidStarsForStarsPurchaseError, StarsPurchase, ) @@ -31,10 +31,10 @@ class StartStarsPurchase: uuids: UUIDs clock: Clock common_views: CommonUserViews - stars_purchase_views: StarsPurchaseUserViews + stars_purchase_views: StarsPurchaseViews payment_gateway: StarsPurchasePaymentGateway map_: Map - log: StarsPurchaseUserLog + log: StarsPurchaseLog async def __call__(self, user_id: int, stars: Stars) -> None: async with self.transaction: @@ -47,12 +47,10 @@ async def __call__(self, user_id: int, stars: Stars) -> None: await self.common_views.user_is_not_registered_view(user_id) return - tracking = Tracking() try: - user.start_stars_purchase( - purchase_id, - stars, - tracking, + tracking = Tracking() + stars_purchase = ( + StarsPurchase.start(purchase_id, user, stars, tracking) ) except InvalidStarsForStarsPurchaseError: await self.log.invalid_stars_for_stars_purchase( @@ -64,12 +62,8 @@ async def __call__(self, user_id: int, stars: Stars) -> None: .invalid_stars_for_stars_purchase_view(user_id) ) return + else: + await self.log.stars_puchase_started(stars_purchase) - await self.log.user_started_stars_puchase(user) - - await self.map_(tracking) - await gather(*[ - self.payment_gateway.send_invoice(it) - for it in tracking.new - if isinstance(it, StarsPurchase) - ]) + await self.map_(tracking) + await self.payment_gateway.send_invoice(stars_purchase) diff --git a/src/ttt/application/user/stars_purchase/start_stars_purchase_payment.py b/src/ttt/application/stars_purchase/start_stars_purchase_payment.py similarity index 56% rename from src/ttt/application/user/stars_purchase/start_stars_purchase_payment.py rename to src/ttt/application/stars_purchase/start_stars_purchase_payment.py index 8e41b4d..7eb9a02 100644 --- a/src/ttt/application/user/stars_purchase/start_stars_purchase_payment.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase_payment.py @@ -6,16 +6,14 @@ from ttt.application.common.ports.map import Map from ttt.application.common.ports.transaction import Transaction from ttt.application.common.ports.uuids import UUIDs -from ttt.application.user.common.ports.users import Users -from ttt.application.user.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 - StarsPurchasePaymentGateway, +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, ) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, +from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 + StarsPurchasePaymentGateway, ) -from ttt.entities.core.user.user import NoPurchaseError +from ttt.application.stars_purchase.ports.stars_purchases import StarsPurchases from ttt.entities.finance.payment.payment import PaymentIsAlreadyBeingMadeError -from ttt.entities.tools.assertion import not_none from ttt.entities.tools.tracking import Tracking @@ -24,45 +22,41 @@ class StartStarsPurchasePayment: transaction: Transaction uuids: UUIDs clock: Clock - users: Users + stars_purchases: StarsPurchases payment_gateway: StarsPurchasePaymentGateway map_: Map - log: StarsPurchaseUserLog + log: StarsPurchaseLog - async def __call__(self, user_id: int, purchase_id: UUID) -> None: + async def __call__(self, purchase_id: UUID) -> None: async with self.transaction: - user, payment_id, current_datetime = await gather( - self.users.user_with_id(user_id), + stars_purchase, payment_id, current_datetime = await gather( + self.stars_purchases.stars_purchase_with_id(purchase_id), self.uuids.random_uuid(), self.clock.current_datetime(), ) - user = not_none(user) - tracking = Tracking() + if stars_purchase is None: + await self.log.no_stars_purchase_to_start_payment(purchase_id) + await self.payment_gateway.stop_payment_due_to_error(payment_id) + return + try: - user.start_stars_purchase_payment( - purchase_id, + tracking = Tracking() + stars_purchase.start_payment( payment_id, current_datetime, tracking, ) except PaymentIsAlreadyBeingMadeError: await self.log.double_stars_purchase_payment_start( - user, - purchase_id, + stars_purchase, ) await self.payment_gateway.stop_payment_due_to_dublicate( payment_id, ) - except NoPurchaseError: - await self.log.no_purchase_to_start_stars_purchase_payment( - user, - purchase_id, - ) - await self.payment_gateway.stop_payment_due_to_error( - payment_id, - ) else: - await self.log.user_started_stars_puchase_payment(user) + await self.log.stars_puchase_payment_started( + stars_purchase, + ) await self.map_(tracking) await self.payment_gateway.start_payment(payment_id) diff --git a/src/ttt/application/user/stars_purchase/start_stars_purchase_payment_completion.py b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py similarity index 60% rename from src/ttt/application/user/stars_purchase/start_stars_purchase_payment_completion.py rename to src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py index 914ced8..d6bdd87 100644 --- a/src/ttt/application/user/stars_purchase/start_stars_purchase_payment_completion.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py @@ -1,16 +1,16 @@ from dataclasses import dataclass -from ttt.application.user.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 +from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 PaidStarsPurchasePaymentInbox, ) -from ttt.application.user.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 - StarsPurchasePaymentGateway, +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, ) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, +from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 + StarsPurchasePaymentGateway, ) -from ttt.application.user.stars_purchase.ports.user_views import ( - StarsPurchaseUserViews, +from ttt.application.stars_purchase.ports.stars_purchase_views import ( + StarsPurchaseViews, ) @@ -18,8 +18,8 @@ class StartStarsPurchasePaymentCompletion: inbox: PaidStarsPurchasePaymentInbox payment_gateway: StarsPurchasePaymentGateway - views: StarsPurchaseUserViews - log: StarsPurchaseUserLog + views: StarsPurchaseViews + log: StarsPurchaseLog async def __call__(self) -> None: async for paid_payment in self.payment_gateway.paid_payment_stream(): diff --git a/src/ttt/entities/atomic.py b/src/ttt/entities/atomic.py index 1b87167..c3d9207 100644 --- a/src/ttt/entities/atomic.py +++ b/src/ttt/entities/atomic.py @@ -5,6 +5,7 @@ from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( MatchmakingQueueAtomic, ) +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchaseAtomic from ttt.entities.core.user.user import UserAtomic from ttt.entities.finance.payment.payment import PaymentAtomic @@ -12,6 +13,7 @@ type Atomic = ( GameAtomic | UserAtomic + | StarsPurchaseAtomic | MatchmakingQueueAtomic | InvitationToGameAtomic | PaymentAtomic diff --git a/src/ttt/entities/core/stars_purchase/__init__.py b/src/ttt/entities/core/stars_purchase/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ttt/entities/core/user/stars_purchase.py b/src/ttt/entities/core/stars_purchase/stars_purchase.py similarity index 57% rename from src/ttt/entities/core/user/stars_purchase.py rename to src/ttt/entities/core/stars_purchase/stars_purchase.py index 9b26e4d..d55ad6e 100644 --- a/src/ttt/entities/core/user/stars_purchase.py +++ b/src/ttt/entities/core/stars_purchase/stars_purchase.py @@ -7,7 +7,13 @@ has_stars_price, price_of_stars, ) -from ttt.entities.finance.payment.payment import Payment +from ttt.entities.core.user.user import User +from ttt.entities.finance.payment.payment import ( + Payment, + cancel_payment, + complete_payment, +) +from ttt.entities.finance.payment.success import PaymentSuccess from ttt.entities.tools.assertion import assert_ from ttt.entities.tools.tracking import Tracking @@ -27,7 +33,7 @@ class StarsPurchase: """ id_: UUID - user_id: int + user: User stars: Stars payment: Payment | None @@ -37,6 +43,28 @@ def __post_init__(self) -> None: else_=InvalidStarsForStarsPurchaseError, ) + @classmethod + def start( + cls, + purchase_id: UUID, + purchase_user: User, + purchase_stars: Stars, + tracking: Tracking, + ) -> "StarsPurchase": + """ + :raises ttt.entities.core.stars.InvalidStarsForStarsPurchaseError: + """ + + purchase = StarsPurchase( + id_=purchase_id, + user=purchase_user, + stars=purchase_stars, + payment=None, + ) + tracking.register_new(purchase) + + return purchase + def start_payment( self, payment_id: UUID, @@ -57,24 +85,40 @@ def start_payment( self.payment = payment tracking.register_mutated(self) - @classmethod - def start( - cls, - purchase_id: UUID, - purchase_user_id: int, - purchase_stars: Stars, + def complete_payment( + self, + payment_success: PaymentSuccess, + current_datetime: datetime, tracking: Tracking, - ) -> "StarsPurchase": + ) -> None: """ - :raises ttt.entities.core.stars.InvalidStarsForStarsPurchaseError: + :raises ttt.entities.finance.payment.payment.NoPaymentError: + :raises ttt.entities.finance.payment.payment.PaymentIsNotInProcessError: """ - purchase = StarsPurchase( - id_=purchase_id, - user_id=purchase_user_id, - stars=purchase_stars, - payment=None, + self.user.account = self.user.account.map( + lambda stars: stars + self.stars, ) - tracking.register_new(purchase) + tracking.register_mutated(self.user) - return purchase + complete_payment( + self.payment, + payment_success, + current_datetime, + tracking, + ) + + def cancel( + self, + current_datetime: datetime, + tracking: Tracking, + ) -> None: + """ + :raises ttt.entities.finance.payment.payment.NoPaymentError: + :raises ttt.entities.finance.payment.payment.PaymentIsNotInProcessError: + """ + + cancel_payment(self.payment, current_datetime, tracking) + + +StarsPurchaseAtomic = StarsPurchase diff --git a/src/ttt/entities/core/user/user.py b/src/ttt/entities/core/user/user.py index 31f0f84..c8eb633 100644 --- a/src/ttt/entities/core/user/user.py +++ b/src/ttt/entities/core/user/user.py @@ -15,7 +15,6 @@ from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.loss import UserLoss from ttt.entities.core.user.rank import Rank, rank_for_rating -from ttt.entities.core.user.stars_purchase import StarsPurchase from ttt.entities.core.user.win import UserWin from ttt.entities.elo.rating import ( EloRating, @@ -24,11 +23,6 @@ new_elo_rating, ) from ttt.entities.elo.score import WinningScore -from ttt.entities.finance.payment.payment import ( - cancel_payment, - complete_payment, -) -from ttt.entities.finance.payment.success import PaymentSuccess from ttt.entities.math.random import Random, deviated_int from ttt.entities.text.emoji import Emoji from ttt.entities.text.token import Token @@ -81,12 +75,11 @@ class NotAuthorizedAsAdminViaAdminTokenError(Exception): ... class UserAlredyAdminToAuthorizeAsAdminError(Exception): ... -@dataclass # noqa: PLR0904 +@dataclass class User: id: int account: Account emojis: list[UserEmoji] - stars_purchases: list[StarsPurchase] selected_emoji_id: UUID | None rating: EloRating admin_right: AdminRight | None @@ -439,98 +432,14 @@ def select_emoji(self, emoji: Emoji, tracking: Tracking) -> None: tracking.register_mutated(self) - def start_stars_purchase( - self, - purchase_id: UUID, - purchase_stars: Stars, - tracking: Tracking, - ) -> None: - """ - :raises ttt.entities.core.stars.InvalidStarsForStarsPurchaseError: - """ - - stars_purchase = StarsPurchase.start( - purchase_id, - self.id, - purchase_stars, - tracking, - ) - self.stars_purchases.append(stars_purchase) - - def start_stars_purchase_payment( - self, - purchase_id: UUID, - payment_id: UUID, - current_datetime: datetime, - tracking: Tracking, - ) -> None: - """ - :raises ttt.entities.user.user.NoPurchaseError: - :raises ttt.entities.finance.payment.payment.PaymentIsAlreadyBeingMadeError: - """ # noqa: E501 - - purchase = self._stars_purchase(purchase_id) - purchase.start_payment(payment_id, current_datetime, tracking) - - def complete_stars_purchase_payment( - self, - purchase_id: UUID, - payment_success: PaymentSuccess, - current_datetime: datetime, - tracking: Tracking, - ) -> None: - """ - :raises ttt.entities.user.user.NoPurchaseError: - :raises ttt.entities.finance.payment.payment.NoPaymentError: - :raises ttt.entities.finance.payment.payment.PaymentIsNotInProcessError: - """ - - purchase = self._stars_purchase(purchase_id) - - self.account = self.account.map(lambda stars: stars + purchase.stars) - tracking.register_mutated(self) - complete_payment( - purchase.payment, - payment_success, - current_datetime, - tracking, - ) - - def cancel_stars_purchase( - self, - purchase_id: UUID, - current_datetime: datetime, - tracking: Tracking, - ) -> None: - """ - :raises ttt.entities.user.user.NoPurchaseError: - :raises ttt.entities.finance.payment.payment.NoPaymentError: - :raises ttt.entities.finance.payment.payment.PaymentIsNotInProcessError: - """ - - purchase = self._stars_purchase(purchase_id) - cancel_payment(purchase.payment, current_datetime, tracking) - - def _stars_purchase(self, purchase_id: UUID) -> StarsPurchase: - """ - :raises ttt.entities.user.user.NoPurchaseError: - """ - - for purchase in self.stars_purchases: - if purchase.id_ == purchase_id: - return purchase - - raise NoPurchaseError - -UserAtomic = User | UserEmoji | StarsPurchase +UserAtomic = User | UserEmoji def register_user(user_id: int, tracking: Tracking) -> User: user = User( id=user_id, account=Account(0), - stars_purchases=[], emojis=[], selected_emoji_id=None, rating=initial_elo_rating, diff --git a/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py b/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py index 063f433..369995b 100644 --- a/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py +++ b/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py @@ -1,8 +1,8 @@ from collections.abc import AsyncIterable from dataclasses import dataclass -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment -from ttt.application.user.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 +from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment +from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 PaidStarsPurchasePaymentInbox, ) from ttt.infrastructure.nats.paid_stars_purchase_payment_inbox import ( diff --git a/src/ttt/infrastructure/adapters/stars_purchase_log.py b/src/ttt/infrastructure/adapters/stars_purchase_log.py new file mode 100644 index 0000000..b955217 --- /dev/null +++ b/src/ttt/infrastructure/adapters/stars_purchase_log.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass +from uuid import UUID + +from structlog.types import FilteringBoundLogger + +from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, +) +from ttt.entities.core.stars import Stars +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase +from ttt.entities.core.user.user import User + + +@dataclass(frozen=True, unsafe_hash=False) +class StructlogStarsPurchaseLog(StarsPurchaseLog): + _logger: FilteringBoundLogger + + async def stars_puchase_started( + self, + stars_purchase: StarsPurchase, + /, + ) -> None: + await self._logger.ainfo( + "stars_puchase_started", + user_id=stars_purchase.user.id, + stars_purchase_id=stars_purchase.id_.hex, + ) + + async def stars_puchase_payment_started( + self, + stars_purchase: StarsPurchase, + /, + ) -> None: + await self._logger.ainfo( + "stars_puchase_payment_started", + user_id=stars_purchase.user.id, + purchase_id=stars_purchase.id_.hex, + ) + + async def stars_purchase_payment_completion_started( + self, + payment: PaidStarsPurchasePayment, + /, + ) -> None: + await self._logger.ainfo( + "stars_purchase_payment_completion_started", + user_id=payment.user_id, + chat_id=payment.user_id, + purchase_id=payment.purchase_id.hex, + ) + + async def stars_purchase_payment_completed( + self, + stars_purchase: StarsPurchase, + payment: PaidStarsPurchasePayment, + /, + ) -> None: + await self._logger.ainfo( + "stars_purchase_payment_completed", + user_id=stars_purchase.user.id, + chat_id=stars_purchase.user.id, + purchase_id=stars_purchase.id_.hex, + ) + + async def double_stars_purchase_payment_completion( + self, + stars_purchase: StarsPurchase, + paid_payment: PaidStarsPurchasePayment, + ) -> None: + await self._logger.awarning( + "double_stars_purchase_payment_completion", + user_id=stars_purchase.user.id, + chat_id=stars_purchase.user.id, + purchase_id=stars_purchase.id_.hex, + ) + + async def invalid_stars_for_stars_purchase( + self, + user: User, + stars: Stars, + ) -> None: + await self._logger.aerror( + "invalid_stars_for_stars_purchase", + user_id=user.id, + chat_id=user.id, + stars=stars, + ) + + async def double_stars_purchase_payment_start( + self, + stars_purchase: StarsPurchase, + ) -> None: + await self._logger.ainfo( + "double_stars_purchase_payment_start", + user_id=stars_purchase.user.id, + chat_id=stars_purchase.user.id, + purchase_id=stars_purchase.id_.hex, + ) + + async def no_stars_purchase_to_start_payment( + self, + purchase_id: UUID, + /, + ) -> None: ... + + async def no_stars_purchase_to_complete_payment( + self, + purchase_id: UUID, + /, + ) -> None: ... diff --git a/src/ttt/infrastructure/adapters/stars_purchases.py b/src/ttt/infrastructure/adapters/stars_purchases.py new file mode 100644 index 0000000..0c16e8b --- /dev/null +++ b/src/ttt/infrastructure/adapters/stars_purchases.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql import select + +from ttt.application.stars_purchase.ports.stars_purchases import StarsPurchases +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase +from ttt.infrastructure.sqlalchemy.tables.stars_purchase import ( + TableStarsPurchase, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class PostgresStarsPurchases(StarsPurchases): + _session: AsyncSession + + async def stars_purchase_with_id( + self, + id_: UUID, + /, + ) -> StarsPurchase | None: + stmt = ( + select(TableStarsPurchase) + .where(TableStarsPurchase.id == id_) + .with_for_update() + ) + table_stars_purchase = await self._session.scalar(stmt) + + if table_stars_purchase is None: + return None + + return table_stars_purchase.entity() diff --git a/src/ttt/infrastructure/adapters/user_log.py b/src/ttt/infrastructure/adapters/user_log.py index b68f517..c2aa5b1 100644 --- a/src/ttt/infrastructure/adapters/user_log.py +++ b/src/ttt/infrastructure/adapters/user_log.py @@ -1,12 +1,10 @@ from dataclasses import dataclass -from uuid import UUID from structlog.types import FilteringBoundLogger from ttt.application.user.change_other_user_account.ports.user_log import ( ChangeOtherUserAccountLog, ) -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.emoji_purchase.ports.user_log import ( EmojiPurchaseUserLog, @@ -14,9 +12,6 @@ from ttt.application.user.emoji_selection.ports.user_log import ( EmojiSelectionUserLog, ) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, -) from ttt.entities.core.stars import Stars from ttt.entities.core.user.user import User from ttt.entities.text.emoji import Emoji @@ -273,114 +268,6 @@ async def emoji_not_purchased_to_select( ) -@dataclass(frozen=True, unsafe_hash=False) -class StructlogStarsPurchaseUserLog(StarsPurchaseUserLog): - _logger: FilteringBoundLogger - - async def user_intends_to_buy_stars( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "user_intends_to_buy_stars", - chat_id=user_id, - user_id=user_id, - ) - - async def user_started_stars_puchase( - self, - user: User, - /, - ) -> None: - await self._logger.ainfo( - "user_started_stars_puchase", - chat_id=user.id, - user_id=user.id, - ) - - async def user_started_stars_puchase_payment( - self, - user: User, - /, - ) -> None: - await self._logger.ainfo( - "user_started_stars_puchase_payment", - user_id=user.id, - ) - - async def stars_purchase_payment_completion_started( - self, - payment: PaidStarsPurchasePayment, - /, - ) -> None: - await self._logger.ainfo( - "stars_purchase_payment_completion_started", - user_id=payment.user_id, - chat_id=payment.user_id, - purchase_id=payment.purchase_id.hex, - ) - - async def stars_purchase_payment_completed( - self, - user: User, - payment: PaidStarsPurchasePayment, - /, - ) -> None: - await self._logger.ainfo( - "stars_purchase_payment_completed", - user_id=payment.user_id, - chat_id=payment.user_id, - purchase_id=payment.purchase_id.hex, - ) - - async def double_stars_purchase_payment_completion( - self, - user: User, - paid_payment: PaidStarsPurchasePayment, - ) -> None: - await self._logger.awarning( - "double_stars_purchase_payment_completion", - user_id=paid_payment.user_id, - chat_id=paid_payment.user_id, - purchase_id=paid_payment.purchase_id.hex, - ) - - async def invalid_stars_for_stars_purchase( - self, - user: User, - stars: Stars, - ) -> None: - await self._logger.aerror( - "invalid_stars_for_stars_purchase", - user_id=user.id, - chat_id=user.id, - stars=stars, - ) - - async def double_stars_purchase_payment_start( - self, - user: User, - purchase_id: UUID, - ) -> None: - await self._logger.ainfo( - "double_stars_purchase_payment_start", - user_id=user.id, - purchase_id=purchase_id.hex, - ) - - async def no_purchase_to_start_stars_purchase_payment( - self, - user: User, - purchase_id: UUID, - ) -> None: - await self._logger.aerror( - "no_purchase_to_start_stars_purchase_payment", - user_id=user.id, - purchase_id=purchase_id.hex, - ) - - @dataclass(frozen=True, unsafe_hash=False) class StructlogChangeOtherUserAccountLog(ChangeOtherUserAccountLog): _logger: FilteringBoundLogger diff --git a/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py b/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py index f64811e..502571d 100644 --- a/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py +++ b/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py @@ -6,7 +6,7 @@ from nats.js import JetStreamContext from pydantic import TypeAdapter -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment +from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment from ttt.infrastructure.nats.messages import at_least_once_messages diff --git a/src/ttt/infrastructure/sqlalchemy/tables/__init__.py b/src/ttt/infrastructure/sqlalchemy/tables/__init__.py index 440c596..288d38a 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/__init__.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/__init__.py @@ -7,4 +7,7 @@ TableMatchmakingQueue, ) from ttt.infrastructure.sqlalchemy.tables.payment import TablePayment +from ttt.infrastructure.sqlalchemy.tables.stars_purchase import ( + TableStarsPurchase, +) from ttt.infrastructure.sqlalchemy.tables.user import TableUser diff --git a/src/ttt/infrastructure/sqlalchemy/tables/atomic.py b/src/ttt/infrastructure/sqlalchemy/tables/atomic.py index 590e2a7..a12e4a4 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/atomic.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/atomic.py @@ -10,6 +10,7 @@ from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( MatchmakingQueueAtomic, ) +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchaseAtomic from ttt.entities.core.user.user import UserAtomic from ttt.entities.finance.payment.payment import ( PaymentAtomic, @@ -30,6 +31,10 @@ TablePaymentAtomic, table_payment_atomic, ) +from ttt.infrastructure.sqlalchemy.tables.stars_purchase import ( + TableStarsPurchaseAtomic, + table_stars_purchase_atomic, +) from ttt.infrastructure.sqlalchemy.tables.user import ( TableUserAtomic, table_user_atomic, @@ -38,6 +43,7 @@ type TableAtomic = ( TableUserAtomic + | TableStarsPurchaseAtomic | TableGameAtomic | TableMatchmakingQueueAtomic | TableInvitationToGameAtomic @@ -61,6 +67,9 @@ def mapped_table_atomic(entity: Atomic) -> TableAtomic: # noqa: RET503 if isinstance(entity, InvitationToGameAtomic): return table_invitation_to_game_atomic(entity) + if isinstance(entity, StarsPurchaseAtomic): + return table_stars_purchase_atomic(entity) + def linked_table_atomic(entity: Atomic) -> TableAtomic: if hasattr(entity, "_table_entity"): diff --git a/src/ttt/infrastructure/sqlalchemy/tables/stars_purchase.py b/src/ttt/infrastructure/sqlalchemy/tables/stars_purchase.py new file mode 100644 index 0000000..5d91b7f --- /dev/null +++ b/src/ttt/infrastructure/sqlalchemy/tables/stars_purchase.py @@ -0,0 +1,67 @@ +from uuid import UUID + +from sqlalchemy import ForeignKey, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ttt.entities.core.stars_purchase.stars_purchase import ( + StarsPurchase, + StarsPurchaseAtomic, +) +from ttt.infrastructure.sqlalchemy.tables.common import Base +from ttt.infrastructure.sqlalchemy.tables.payment import TablePayment +from ttt.infrastructure.sqlalchemy.tables.user import TableUser + + +class TableStarsPurchase(Base[StarsPurchase]): + __tablename__ = "stars_purchases" + + id: Mapped[UUID] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", deferrable=True, initially="DEFERRED"), + index=True, + ) + stars: Mapped[int] + payment_id: Mapped[UUID | None] = mapped_column( + ForeignKey("payments.id", deferrable=True, initially="DEFERRED"), + ) + + payment: Mapped[TablePayment | None] = relationship( + TablePayment, lazy="selectin", + ) + user: Mapped[TableUser] = relationship(TableUser, lazy="selectin") + + __table_args__ = ( + Index( + "ix_stars_purchases_payment_id", + payment_id, + postgresql_where=(payment_id.is_not(None)), + ), + ) + + def __entity__(self) -> StarsPurchase: + return StarsPurchase( + id_=self.id, + user=self.user.entity(), + stars=self.stars, + payment=None if self.payment is None else self.payment.entity(), + ) + + @classmethod + def of(cls, it: StarsPurchase) -> "TableStarsPurchase": + return TableStarsPurchase( + id=it.id_, + user_id=it.user.id, + stars=it.stars, + payment_id=None if it.payment is None else it.payment.id_, + ) + + +type TableStarsPurchaseAtomic = TableStarsPurchase + + +def table_stars_purchase_atomic( + entity: StarsPurchaseAtomic, +) -> TableStarsPurchaseAtomic: + match entity: + case StarsPurchase(): + return TableStarsPurchase.of(entity) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/user.py b/src/ttt/infrastructure/sqlalchemy/tables/user.py index ccd0091..a6e09d2 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/user.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/user.py @@ -14,12 +14,10 @@ ) from ttt.entities.core.user.emoji import UserEmoji from ttt.entities.core.user.location import UserGameLocation -from ttt.entities.core.user.stars_purchase import StarsPurchase from ttt.entities.core.user.user import User, UserAtomic from ttt.entities.text.emoji import Emoji from ttt.entities.tools.assertion import not_none from ttt.infrastructure.sqlalchemy.tables.common import Base -from ttt.infrastructure.sqlalchemy.tables.payment import TablePayment class TableUserEmoji(Base[UserEmoji]): @@ -51,50 +49,6 @@ def of(cls, it: UserEmoji) -> "TableUserEmoji": ) -class TableStarsPurchase(Base[StarsPurchase]): - __tablename__ = "stars_purchases" - - id: Mapped[UUID] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column( - ForeignKey("users.id", deferrable=True, initially="DEFERRED"), - index=True, - ) - stars: Mapped[int] - payment_id: Mapped[UUID | None] = mapped_column( - ForeignKey("payments.id", deferrable=True, initially="DEFERRED"), - ) - - payment: Mapped[TablePayment | None] = relationship( - TablePayment, - lazy="joined", - ) - - __table_args__ = ( - Index( - "ix_stars_purchases_payment_id", - payment_id, - postgresql_where=(payment_id.is_not(None)), - ), - ) - - def __entity__(self) -> StarsPurchase: - return StarsPurchase( - id_=self.id, - user_id=self.user_id, - stars=self.stars, - payment=None if self.payment is None else self.payment.entity(), - ) - - @classmethod - def of(cls, it: StarsPurchase) -> "TableStarsPurchase": - return TableStarsPurchase( - id=it.id_, - user_id=it.user_id, - stars=it.stars, - payment_id=None if it.payment is None else it.payment.id_, - ) - - class TableAdminRight(StrEnum): via_admin_token = "via_admin_token" # noqa: S105 via_other_admin = "via_other_admin" @@ -144,10 +98,6 @@ class TableUser(Base[User]): lazy="selectin", foreign_keys=[TableUserEmoji.user_id], ) - stars_purchases: Mapped[list[TableStarsPurchase]] = relationship( - lazy="selectin", - foreign_keys=[TableStarsPurchase.user_id], - ) __table_args__ = ( Index( @@ -182,7 +132,6 @@ def __entity__(self) -> User: id=self.id, account=Account(self.account_stars), emojis=[it.entity() for it in self.emojis], - stars_purchases=[it.entity() for it in self.stars_purchases], selected_emoji_id=self.selected_emoji_id, rating=self.rating, number_of_wins=self.number_of_wins, @@ -226,7 +175,7 @@ def of(cls, it: User) -> "TableUser": ) -type TableUserAtomic = TableUser | TableUserEmoji | TableStarsPurchase +type TableUserAtomic = TableUser | TableUserEmoji def table_user_atomic(entity: UserAtomic) -> TableUserAtomic: @@ -235,5 +184,3 @@ def table_user_atomic(entity: UserAtomic) -> TableUserAtomic: return TableUser.of(entity) case UserEmoji(): return TableUserEmoji.of(entity) - case StarsPurchase(): - return TableStarsPurchase.of(entity) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 5424e7a..2262454 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -36,6 +36,13 @@ from ttt.application.matchmaking_queue.common.shared_matchmaking_queue import ( SharedMatchmakingQueue, ) +from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 + PaidStarsPurchasePaymentInbox, +) +from ttt.application.stars_purchase.ports.stars_purchase_log import ( + StarsPurchaseLog, +) +from ttt.application.stars_purchase.ports.stars_purchases import StarsPurchases from ttt.application.user.change_other_user_account.ports.user_log import ( ChangeOtherUserAccountLog, ) @@ -50,12 +57,6 @@ from ttt.application.user.emoji_selection.ports.user_log import ( EmojiSelectionUserLog, ) -from ttt.application.user.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 - PaidStarsPurchasePaymentInbox, -) -from ttt.application.user.stars_purchase.ports.user_log import ( - StarsPurchaseUserLog, -) from ttt.infrastructure.adapters.clock import NotMonotonicUtcClock from ttt.infrastructure.adapters.game_ai_gateway import GeminiGameAiGateway from ttt.infrastructure.adapters.game_dao import PostgresGameDao @@ -84,13 +85,16 @@ from ttt.infrastructure.adapters.shared_matchmaking_queue import ( InPostgresSharedMatchmakingQueue, ) +from ttt.infrastructure.adapters.stars_purchase_log import ( + StructlogStarsPurchaseLog, +) +from ttt.infrastructure.adapters.stars_purchases import PostgresStarsPurchases from ttt.infrastructure.adapters.transaction import InPostgresTransaction from ttt.infrastructure.adapters.user_log import ( StructlogChangeOtherUserAccountLog, StructlogCommonUserLog, StructlogEmojiPurchaseUserLog, StructlogEmojiSelectionUserLog, - StructlogStarsPurchaseUserLog, ) from ttt.infrastructure.adapters.users import InPostgresUsers from ttt.infrastructure.adapters.uuids import UUIDv4s @@ -236,6 +240,12 @@ def provide_logger( scope=Scope.REQUEST, ) + provide_stars_purchases = provide( + PostgresStarsPurchases, + provides=StarsPurchases, + scope=Scope.REQUEST, + ) + provide_shared_matchmaking_queue = provide( InPostgresSharedMatchmakingQueue, provides=SharedMatchmakingQueue, @@ -313,8 +323,8 @@ def provide_randoms(self) -> Randoms: ) provide_stars_purchase_user_log = provide( - StructlogStarsPurchaseUserLog, - provides=StarsPurchaseUserLog, + StructlogStarsPurchaseLog, + provides=StarsPurchaseLog, scope=Scope.REQUEST, ) diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index 0960fbc..3efbde6 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -62,6 +62,25 @@ CommonMatchmakingQueueViews, ) from ttt.application.matchmaking_queue.game.wait_game import WaitGame +from ttt.application.stars_purchase.complete_stars_purchase_payment import ( + CompleteStarsPurchasePayment, +) +from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment +from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 + StarsPurchasePaymentGateway, +) +from ttt.application.stars_purchase.ports.stars_purchase_views import ( + StarsPurchaseViews, +) +from ttt.application.stars_purchase.start_stars_purchase import ( + StartStarsPurchase, +) +from ttt.application.stars_purchase.start_stars_purchase_payment import ( + StartStarsPurchasePayment, +) +from ttt.application.stars_purchase.start_stars_purchase_payment_completion import ( # noqa: E501 + StartStarsPurchasePaymentCompletion, +) from ttt.application.user.authorize_as_admin import AuthorizeAsAdmin from ttt.application.user.authorize_other_user_as_admin import ( AuthorizeOtherUserAsAdmin, @@ -78,7 +97,6 @@ from ttt.application.user.change_other_user_account.view_user_account_to_change import ( # noqa: E501 ViewUserAccountToChange, ) -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.deauthorize_other_user_as_admin import ( DeauthorizeOtherUserAsAdmin, @@ -93,24 +111,6 @@ from ttt.application.user.emoji_selection.select_emoji import SelectEmoji from ttt.application.user.register_user import RegisterUser from ttt.application.user.relinquish_admin_right import RelinquishAdminRight -from ttt.application.user.stars_purchase.complete_stars_purchase_payment import ( # noqa: E501 - CompleteStarsPurchasePayment, -) -from ttt.application.user.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 - StarsPurchasePaymentGateway, -) -from ttt.application.user.stars_purchase.ports.user_views import ( - StarsPurchaseUserViews, -) -from ttt.application.user.stars_purchase.start_stars_purchase import ( - StartStarsPurchase, -) -from ttt.application.user.stars_purchase.start_stars_purchase_payment import ( - StartStarsPurchasePayment, -) -from ttt.application.user.stars_purchase.start_stars_purchase_payment_completion import ( # noqa: E501 - StartStarsPurchasePaymentCompletion, -) from ttt.application.user.view_admin_menu import ViewAdminMenu from ttt.application.user.view_main_menu import ViewMainMenu from ttt.application.user.view_other_user import ViewOtherUser @@ -131,12 +131,14 @@ from ttt.presentation.adapters.stars_purchase_payment_gateway import ( AiogramPaymentGateway, ) +from ttt.presentation.adapters.stars_purchase_views import ( + AiogramStarsPurchaseViews, +) from ttt.presentation.adapters.user_views import ( AiogramChangeOtherUserAccountViews, AiogramCommonUserViews, AiogramEmojiPurchaseUserViews, AiogramEmojiSelectionUserViews, - AiogramStarsPurchaseUserViews, ) from ttt.presentation.aiogram.common.bots import ttt_bot from ttt.presentation.aiogram.common.routes.all import common_routers @@ -181,7 +183,7 @@ def provide_manager_impl( if middleware_data is None: return None - return cast(ManagerImpl, middleware_data["dialog_manager"]) + return cast(ManagerImpl, middleware_data.get("dialog_manager")) @provide(scope=Scope.APP) async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: @@ -213,8 +215,8 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: scope=Scope.REQUEST, ) provide_stars_purchase_user_views = provide( - AiogramStarsPurchaseUserViews, - provides=StarsPurchaseUserViews, + AiogramStarsPurchaseViews, + provides=StarsPurchaseViews, scope=Scope.REQUEST, ) provide_emoji_selection_user_views = provide( @@ -343,14 +345,6 @@ def provide_stars_purchase_payment_gateway( class ApplicationProvider(Provider): provide_buy_emoji = provide(BuyEmoji, scope=Scope.REQUEST) provide_select_emoji = provide(SelectEmoji, scope=Scope.REQUEST) - provide_start_stars_purchase = provide( - StartStarsPurchase, - scope=Scope.REQUEST, - ) - provide_start_stars_purchase_payment = provide( - StartStarsPurchasePayment, - scope=Scope.REQUEST, - ) provide_view_user_emojis = provide( ViewUserEmojis, scope=Scope.REQUEST, @@ -361,14 +355,6 @@ class ApplicationProvider(Provider): ) provide_view_user = provide(ViewUser, scope=Scope.REQUEST) provide_register_user = provide(RegisterUser, scope=Scope.REQUEST) - probide_complete_stars_purchase_payment = provide( - CompleteStarsPurchasePayment, - scope=Scope.REQUEST, - ) - probide_start_stars_purchase_payment_completion = provide( - StartStarsPurchasePaymentCompletion, - scope=Scope.REQUEST, - ) provide_authorize_as_admin = provide(AuthorizeAsAdmin, scope=Scope.REQUEST) provide_relinquish_admin_right = provide( RelinquishAdminRight, @@ -392,6 +378,23 @@ class ApplicationProvider(Provider): ViewUserAccountToChange, scope=Scope.REQUEST, ) + provide_start_stars_purchase = provide( + StartStarsPurchase, + scope=Scope.REQUEST, + ) + provide_start_stars_purchase_payment = provide( + StartStarsPurchasePayment, + scope=Scope.REQUEST, + ) + probide_complete_stars_purchase_payment = provide( + CompleteStarsPurchasePayment, + scope=Scope.REQUEST, + ) + probide_start_stars_purchase_payment_completion = provide( + StartStarsPurchasePaymentCompletion, + scope=Scope.REQUEST, + ) + provide_start_game_with_ai = provide( StartGameWithAi, scope=Scope.REQUEST, diff --git a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py index bb8257d..ad84511 100644 --- a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py +++ b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py @@ -6,11 +6,11 @@ from aiogram.types import PreCheckoutQuery from aiogram_dialog import ShowMode, StartMode -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment -from ttt.application.user.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 +from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment +from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 StarsPurchasePaymentGateway, ) -from ttt.entities.core.user.stars_purchase import StarsPurchase +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase from ttt.entities.tools.assertion import not_none from ttt.infrastructure.buffer import Buffer from ttt.presentation.aiogram.user.invoices import stars_invoce @@ -32,7 +32,7 @@ async def send_invoice( self, purchase: StarsPurchase, ) -> None: - manager = self._dialog_manager_for_user(purchase.user_id) + manager = self._dialog_manager_for_user(purchase.user.id) await stars_invoce(self._bot, purchase, self._payments_token) await manager.start( diff --git a/src/ttt/presentation/adapters/stars_purchase_views.py b/src/ttt/presentation/adapters/stars_purchase_views.py new file mode 100644 index 0000000..a28ad66 --- /dev/null +++ b/src/ttt/presentation/adapters/stars_purchase_views.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass + +from aiogram_dialog import ShowMode, StartMode + +from ttt.application.stars_purchase.ports.stars_purchase_views import ( + StarsPurchaseViews, +) +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase +from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( + DialogManagerForUser, +) +from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState + + +@dataclass(frozen=True, unsafe_hash=False) +class AiogramStarsPurchaseViews(StarsPurchaseViews): + _dialog_manager_for_user: DialogManagerForUser + + async def invalid_stars_for_stars_purchase_view( + self, + user_id: int, + /, + ) -> None: + raise NotImplementedError + + async def stars_purchase_will_be_completed_view( + self, + user_id: int, + /, + ) -> None: + manager = self._dialog_manager_for_user(user_id) + await manager.start( + MainDialogState.stars_shop, + {"hint": "🌟 Звёзды скоро начислятся!"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def completed_stars_purchase_view( + self, + stars_purchase: StarsPurchase, + /, + ) -> None: + manager = self._dialog_manager_for_user(stars_purchase.user.id) + await manager.start( + MainDialogState.stars_shop, + {"hint": "🌟 Звезды начислились!"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) diff --git a/src/ttt/presentation/adapters/user_views.py b/src/ttt/presentation/adapters/user_views.py index 3a11f1b..0f2ac6e 100644 --- a/src/ttt/presentation/adapters/user_views.py +++ b/src/ttt/presentation/adapters/user_views.py @@ -1,7 +1,6 @@ from collections import OrderedDict from dataclasses import dataclass from typing import cast -from uuid import UUID from aiogram import Bot from aiogram_dialog import ShowMode, StartMode @@ -18,9 +17,6 @@ from ttt.application.user.emoji_selection.ports.user_views import ( EmojiSelectionUserViews, ) -from ttt.application.user.stars_purchase.ports.user_views import ( - StarsPurchaseUserViews, -) from ttt.entities.core.stars import Stars from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import User, is_user_in_game, user_stars @@ -449,45 +445,6 @@ async def user_deauthorized_other_user_as_admin_view( ) -@dataclass(frozen=True, unsafe_hash=False) -class AiogramStarsPurchaseUserViews(StarsPurchaseUserViews): - _dialog_manager_for_user: DialogManagerForUser - - async def invalid_stars_for_stars_purchase_view( - self, - user_id: int, - /, - ) -> None: - raise NotImplementedError - - async def stars_purchase_will_be_completed_view( - self, - user_id: int, - /, - ) -> None: - manager = self._dialog_manager_for_user(user_id) - await manager.start( - MainDialogState.stars_shop, - {"hint": "🌟 Звёзды скоро начислятся!"}, - StartMode.RESET_STACK, - ShowMode.DELETE_AND_SEND, - ) - - async def completed_stars_purchase_view( - self, - user: User, - purchase_id: UUID, - /, - ) -> None: - manager = self._dialog_manager_for_user(user.id) - await manager.start( - MainDialogState.stars_shop, - {"hint": "🌟 Звезды начислились!"}, - StartMode.RESET_STACK, - ShowMode.DELETE_AND_SEND, - ) - - @dataclass(frozen=True, unsafe_hash=False) class AiogramEmojiSelectionUserViews(EmojiSelectionUserViews): async def invalid_emoji_to_select_view( diff --git a/src/ttt/presentation/aiogram/user/invoices.py b/src/ttt/presentation/aiogram/user/invoices.py index 65965e3..63c15ef 100644 --- a/src/ttt/presentation/aiogram/user/invoices.py +++ b/src/ttt/presentation/aiogram/user/invoices.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field, TypeAdapter from ttt.entities.core.stars import price_of_stars -from ttt.entities.core.user.stars_purchase import StarsPurchase +from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase class StarsPurchaseInvoicePayload(BaseModel): @@ -42,7 +42,7 @@ async def stars_invoce( ) payload_model = StarsPurchaseInvoicePayload.of( - purchase.id_, purchase.user_id, + purchase.id_, purchase.user.id, ) payload = payload_model.model_dump_json(by_alias=True) @@ -63,7 +63,7 @@ async def stars_invoce( }) await bot.send_invoice( - purchase.user_id, + purchase.user.id, title="Звёзды", description="Покупка звёзд", payload=payload, diff --git a/src/ttt/presentation/aiogram/user/routes/handle_payment.py b/src/ttt/presentation/aiogram/user/routes/handle_payment.py index a4dae4f..c90324c 100644 --- a/src/ttt/presentation/aiogram/user/routes/handle_payment.py +++ b/src/ttt/presentation/aiogram/user/routes/handle_payment.py @@ -3,7 +3,7 @@ from dishka import AsyncContainer from dishka.integrations.aiogram import inject -from ttt.application.user.common.dto.common import PaidStarsPurchasePayment +from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment from ttt.entities.finance.payment.success import PaymentSuccess from ttt.entities.tools.assertion import not_none from ttt.infrastructure.buffer import Buffer diff --git a/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py b/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py index 4c4ca3e..1508cf0 100644 --- a/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py +++ b/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py @@ -1,8 +1,9 @@ from aiogram import Router from aiogram.types import PreCheckoutQuery from dishka import AsyncContainer +from dishka.integrations.aiogram import inject -from ttt.application.user.stars_purchase.start_stars_purchase_payment import ( +from ttt.application.stars_purchase.start_stars_purchase_payment import ( StartStarsPurchasePayment, ) from ttt.presentation.aiogram.user.invoices import ( @@ -15,6 +16,7 @@ @handle_pre_checkout_query_router.pre_checkout_query() +@inject async def _( pre_checkout_query: PreCheckoutQuery, dishka_container: AsyncContainer, @@ -26,7 +28,4 @@ async def _( match invoce_payload: case StarsPurchaseInvoicePayload(): action = await dishka_container.get(StartStarsPurchasePayment) - await action( - invoce_payload.user_id, - invoce_payload.purchase_id, - ) + await action(invoce_payload.purchase_id) diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py index 2a39e89..42bb68a 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py @@ -12,7 +12,7 @@ from dishka.integrations.aiogram_dialog import inject from magic_filter import F -from ttt.application.user.stars_purchase.start_stars_purchase import ( +from ttt.application.stars_purchase.start_stars_purchase import ( StartStarsPurchase, ) from ttt.presentation.aiogram_dialog.common.wigets.hint import hint diff --git a/tests/test_ttt/test_entities/test_core/conftest.py b/tests/test_ttt/test_entities/test_core/conftest.py index 1f0e93a..8a45deb 100644 --- a/tests/test_ttt/test_entities/test_core/conftest.py +++ b/tests/test_ttt/test_entities/test_core/conftest.py @@ -26,7 +26,6 @@ def user1() -> User: id=1, account=Account(0), emojis=[], - stars_purchases=[], selected_emoji_id=None, rating=1000., number_of_wins=0, @@ -43,7 +42,6 @@ def user2() -> User: id=2, account=Account(0), emojis=[], - stars_purchases=[], rating=1000., selected_emoji_id=None, number_of_wins=0, diff --git a/tests/test_ttt/test_entities/test_core/test_game.py b/tests/test_ttt/test_entities/test_core/test_game.py index c2937c6..1737e06 100644 --- a/tests/test_ttt/test_entities/test_core/test_game.py +++ b/tests/test_ttt/test_entities/test_core/test_game.py @@ -427,7 +427,6 @@ def test_winning_game( # noqa: PLR0913, PLR0917 account=Account(50), emojis=[], rating=1020.0, - stars_purchases=[], selected_emoji_id=None, number_of_wins=1, number_of_draws=0, @@ -442,7 +441,6 @@ def test_winning_game( # noqa: PLR0913, PLR0917 account=Account(0), emojis=[], rating=981.1500225556907, - stars_purchases=[], selected_emoji_id=None, number_of_wins=0, number_of_draws=0, @@ -522,7 +520,6 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 account=Account(0), emojis=[], rating=1020.0, - stars_purchases=[], selected_emoji_id=None, number_of_wins=0, number_of_draws=1, @@ -536,7 +533,6 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 id=2, account=Account(0), emojis=[], - stars_purchases=[], rating=1020.0, selected_emoji_id=None, number_of_wins=0, @@ -617,7 +613,6 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 account=Account(50), emojis=[], rating=1020.0, - stars_purchases=[], selected_emoji_id=None, number_of_wins=1, number_of_draws=0, @@ -632,7 +627,6 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 account=Account(0), emojis=[], rating=981.1500225556907, - stars_purchases=[], selected_emoji_id=None, number_of_wins=0, number_of_draws=0, diff --git a/tests/test_ttt/test_entities/test_core/test_user.py b/tests/test_ttt/test_entities/test_core/test_user.py index 22a1864..aa96279 100644 --- a/tests/test_ttt/test_entities/test_core/test_user.py +++ b/tests/test_ttt/test_entities/test_core/test_user.py @@ -15,7 +15,6 @@ def test_create_user(tracking: Tracking, object_: str) -> None: account=Account(0), emojis=[], rating=1000., - stars_purchases=[], selected_emoji_id=None, number_of_wins=0, number_of_draws=0, diff --git a/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py index d0b3355..9fb7d8b 100644 --- a/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py +++ b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py @@ -29,7 +29,6 @@ def player1() -> User: id=1, account=Account(0), emojis=[], - stars_purchases=[], selected_emoji_id=None, rating=1000., number_of_wins=0, From bcc20f39b5fcb7817af7f292a4f816fa67773332 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 15 Sep 2025 21:37:52 +0700 Subject: [PATCH 07/45] ref(`adapters`): remove `chat_id` from logs --- .../adapters/invitation_to_game_log.py | 53 +++++++------------ .../adapters/stars_purchase_log.py | 5 -- src/ttt/infrastructure/adapters/user_log.py | 23 -------- 3 files changed, 19 insertions(+), 62 deletions(-) diff --git a/src/ttt/infrastructure/adapters/invitation_to_game_log.py b/src/ttt/infrastructure/adapters/invitation_to_game_log.py index b217c3d..a7d1fad 100644 --- a/src/ttt/infrastructure/adapters/invitation_to_game_log.py +++ b/src/ttt/infrastructure/adapters/invitation_to_game_log.py @@ -45,7 +45,6 @@ async def invitation_self_to_game( ) -> None: await self._logger.ainfo( "invitation_self_to_game", - chat_id=user.id, user_id=user.id, ) @@ -56,10 +55,9 @@ async def user_invited_other_user_to_game( ) -> None: await self._logger.ainfo( "user_invited_other_user_to_game", - chat_id=invitation_to_game.inviting_user.id, user_id=invitation_to_game.inviting_user.id, invited_user_id=invitation_to_game.invited_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def double_invitation_to_game( @@ -69,9 +67,9 @@ async def double_invitation_to_game( ) -> None: await self._logger.ainfo( "double_invitation_to_game", - chat_id=invitation_to_game.inviting_user.id, user_id=invitation_to_game.inviting_user.id, invited_user_id=invitation_to_game.invited_user.id, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def invitation_to_game_is_not_active_to_cancel( @@ -82,9 +80,8 @@ async def invitation_to_game_is_not_active_to_cancel( ) -> None: await self._logger.ainfo( "invitation_to_game_is_not_active_to_cancel", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, invitation_to_game_state=invitation_to_game_state_in_log( invitation_to_game.state, ), @@ -98,9 +95,8 @@ async def user_is_not_inviting_user_to_cancel_invitation_to_game( ) -> None: await self._logger.ainfo( "user_is_not_inviting_user_to_cancel_invitation_to_game", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def invitation_to_game_is_not_active_to_reject( @@ -111,9 +107,8 @@ async def invitation_to_game_is_not_active_to_reject( ) -> None: await self._logger.ainfo( "invitation_to_game_is_not_active_to_reject", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, invitation_to_game_state=invitation_to_game_state_in_log( invitation_to_game.state, ), @@ -127,9 +122,8 @@ async def user_is_not_invited_user_to_reject_invitation_to_game( ) -> None: await self._logger.ainfo( "user_is_not_invited_user_to_reject_invitation_to_game", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def invitation_to_game_is_not_active_to_accept( @@ -140,9 +134,8 @@ async def invitation_to_game_is_not_active_to_accept( ) -> None: await self._logger.ainfo( "invitation_to_game_is_not_active_to_accept", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, invitation_to_game_state=invitation_to_game_state_in_log( invitation_to_game.state, ), @@ -156,10 +149,9 @@ async def user_is_not_invited_user_to_accept_invitation_to_game( ) -> None: await self._logger.ainfo( "user_is_not_invited_user_to_accept_invitation_to_game", - chat_id=user_id, user_id=user_id, invited_user_id=invitation_to_game.invited_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def users_already_in_game_to_accept_invitation_to_game( @@ -170,9 +162,8 @@ async def users_already_in_game_to_accept_invitation_to_game( ) -> None: await self._logger.ainfo( "users_already_in_game_to_accept_invitation_to_game", - chat_id=invitation_to_game.invited_user.id, user_id=invitation_to_game.invited_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, is_invited_user_in_game=( invitation_to_game.invited_user in users_in_game ), @@ -188,9 +179,8 @@ async def user_cancelled_invitation_to_game( ) -> None: await self._logger.ainfo( "user_cancelled_invitation_to_game", - chat_id=invitation_to_game.inviting_user.id, user_id=invitation_to_game.inviting_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def user_rejected_invitation_to_game( @@ -200,9 +190,8 @@ async def user_rejected_invitation_to_game( ) -> None: await self._logger.ainfo( "user_rejected_invitation_to_game", - chat_id=invitation_to_game.invited_user.id, user_id=invitation_to_game.invited_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def user_accepted_invitation_to_game( @@ -213,9 +202,8 @@ async def user_accepted_invitation_to_game( ) -> None: await self._logger.ainfo( "user_accepted_invitation_to_game", - chat_id=invitation_to_game.invited_user.id, user_id=invitation_to_game.invited_user.id, - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def no_invitation_to_game_to_accept( @@ -223,9 +211,8 @@ async def no_invitation_to_game_to_accept( ) -> None: await self._logger.ainfo( "no_invitation_to_game_to_accept", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game_id, + invitation_to_game_id=invitation_to_game_id.hex, ) async def no_invitation_to_game_to_reject( @@ -233,9 +220,8 @@ async def no_invitation_to_game_to_reject( ) -> None: await self._logger.ainfo( "no_invitation_to_game_to_reject", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game_id, + invitation_to_game_id=invitation_to_game_id.hex, ) async def no_invitation_to_game_to_cancel( @@ -243,9 +229,8 @@ async def no_invitation_to_game_to_cancel( ) -> None: await self._logger.ainfo( "no_invitation_to_game_to_cancel", - chat_id=user_id, user_id=user_id, - invitation_to_game_id=invitation_to_game_id, + invitation_to_game_id=invitation_to_game_id.hex, ) async def invitations_to_game_auto_cancelled( @@ -256,7 +241,7 @@ async def invitations_to_game_auto_cancelled( await gather(*( self._logger.ainfo( "invitation_to_game_auto_cancelled", - invitation_to_game_id=invitation_to_game_id, + invitation_to_game_id=invitation_to_game_id.hex, ) for invitation_to_game_id in ids )) @@ -266,7 +251,7 @@ async def no_invitation_to_game_to_auto_cancel( ) -> None: await self._logger.awarning( "no_invitation_to_game_to_auto_cancel", - invitation_to_game_id=invitation_to_game_id, + invitation_to_game_id=invitation_to_game_id.hex, ) async def not_expired_invitation_to_game_to_auto_cancel( @@ -274,7 +259,7 @@ async def not_expired_invitation_to_game_to_auto_cancel( ) -> None: await self._logger.aerror( "not_expired_invitation_to_game_to_auto_cancel", - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) async def invitation_to_game_state_is_not_active_to_game_to_auto_cancel( @@ -282,5 +267,5 @@ async def invitation_to_game_state_is_not_active_to_game_to_auto_cancel( ) -> None: await self._logger.ainfo( "invitation_to_game_state_is_not_active_to_game_to_auto_cancel", - invitation_to_game_id=invitation_to_game.id_, + invitation_to_game_id=invitation_to_game.id_.hex, ) diff --git a/src/ttt/infrastructure/adapters/stars_purchase_log.py b/src/ttt/infrastructure/adapters/stars_purchase_log.py index b955217..0b66f74 100644 --- a/src/ttt/infrastructure/adapters/stars_purchase_log.py +++ b/src/ttt/infrastructure/adapters/stars_purchase_log.py @@ -46,7 +46,6 @@ async def stars_purchase_payment_completion_started( await self._logger.ainfo( "stars_purchase_payment_completion_started", user_id=payment.user_id, - chat_id=payment.user_id, purchase_id=payment.purchase_id.hex, ) @@ -59,7 +58,6 @@ async def stars_purchase_payment_completed( await self._logger.ainfo( "stars_purchase_payment_completed", user_id=stars_purchase.user.id, - chat_id=stars_purchase.user.id, purchase_id=stars_purchase.id_.hex, ) @@ -71,7 +69,6 @@ async def double_stars_purchase_payment_completion( await self._logger.awarning( "double_stars_purchase_payment_completion", user_id=stars_purchase.user.id, - chat_id=stars_purchase.user.id, purchase_id=stars_purchase.id_.hex, ) @@ -83,7 +80,6 @@ async def invalid_stars_for_stars_purchase( await self._logger.aerror( "invalid_stars_for_stars_purchase", user_id=user.id, - chat_id=user.id, stars=stars, ) @@ -94,7 +90,6 @@ async def double_stars_purchase_payment_start( await self._logger.ainfo( "double_stars_purchase_payment_start", user_id=stars_purchase.user.id, - chat_id=stars_purchase.user.id, purchase_id=stars_purchase.id_.hex, ) diff --git a/src/ttt/infrastructure/adapters/user_log.py b/src/ttt/infrastructure/adapters/user_log.py index c2aa5b1..a98f9ed 100644 --- a/src/ttt/infrastructure/adapters/user_log.py +++ b/src/ttt/infrastructure/adapters/user_log.py @@ -28,7 +28,6 @@ async def user_registered( ) -> None: await self._logger.ainfo( "user_registered", - chat_id=user.id, user_id=user.id, ) @@ -39,14 +38,12 @@ async def user_double_registration( ) -> None: await self._logger.ainfo( "user_double_registration", - chat_id=user.id, user_id=user.id, ) async def user_viewed(self, user_id: int, /) -> None: await self._logger.ainfo( "user_viewed", - chat_id=user_id, user_id=user_id, ) @@ -57,21 +54,18 @@ async def user_removed_emoji( ) -> None: await self._logger.ainfo( "user_removed_emoji", - chat_id=user.id, user_id=user.id, ) async def menu_viewed(self, user_id: int) -> None: await self._logger.ainfo( "menu_viewed", - chat_id=user_id, user_id=user_id, ) async def emoji_menu_viewed(self, user_id: int) -> None: await self._logger.ainfo( "emoji_menu_viewed", - chat_id=user_id, user_id=user_id, ) @@ -82,7 +76,6 @@ async def user_authorized_as_admin( ) -> None: await self._logger.ainfo( "user_authorized_as_admin", - chat_id=user.id, user_id=user.id, ) @@ -93,7 +86,6 @@ async def user_already_admin_to_get_admin_rights( ) -> None: await self._logger.ainfo( "user_already_admin_to_get_admin_rights", - chat_id=user.id, user_id=user.id, ) @@ -104,7 +96,6 @@ async def admin_token_mismatch_to_get_admin_rights( ) -> None: await self._logger.ainfo( "admin_token_mismatch_to_get_admin_rights", - chat_id=user.id, user_id=user.id, ) @@ -115,14 +106,12 @@ async def not_admin_to_relinquish_admin_right( ) -> None: await self._logger.ainfo( "not_admin_to_relinquish_admin_right", - chat_id=user.id, user_id=user.id, ) async def user_relinquished_admin_rights(self, user: User, /) -> None: await self._logger.ainfo( "user_relinquished_admin_rights", - chat_id=user.id, user_id=user.id, ) @@ -131,7 +120,6 @@ async def not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_adm ) -> None: await self._logger.ainfo( "not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_admin", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -141,7 +129,6 @@ async def other_user_already_admin_to_authorize_other_user_as_admin( ) -> None: await self._logger.ainfo( "other_user_already_admin_to_authorize_other_user_as_admin", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -151,7 +138,6 @@ async def user_authorized_other_user_as_admin( ) -> None: await self._logger.ainfo( "user_authorized_other_user_as_admin", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -161,7 +147,6 @@ async def not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_a ) -> None: await self._logger.ainfo( "not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_admin", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -171,7 +156,6 @@ async def other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize( ) -> None: await self._logger.ainfo( "other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -181,7 +165,6 @@ async def user_deauthorized_other_user_as_admin( ) -> None: await self._logger.ainfo( "user_deauthorized_other_user_as_admin", - chat_id=user.id, user_id=user.id, other_user_id=None if other_user is None else other_user.id, ) @@ -199,7 +182,6 @@ async def user_bought_emoji( ) -> None: await self._logger.ainfo( "user_bought_emoji", - chat_id=user.id, user_id=user.id, emoji=emoji.str_, ) @@ -211,7 +193,6 @@ async def user_intends_to_buy_emoji( ) -> None: await self._logger.ainfo( "user_intends_to_buy_emoji", - chat_id=user_id, user_id=user_id, ) @@ -222,7 +203,6 @@ async def emoji_already_purchased_to_buy( ) -> None: await self._logger.ainfo( "emoji_already_purchased_to_buy", - chat_id=user.id, user_id=user.id, emoji=emoji.str_, ) @@ -240,7 +220,6 @@ async def user_selected_emoji( ) -> None: await self._logger.ainfo( "user_selected_emoji", - chat_id=user.id, user_id=user.id, emoji=emoji.str_, ) @@ -252,7 +231,6 @@ async def user_intends_to_select_emoji( ) -> None: await self._logger.ainfo( "user_intends_to_select_emoji", - chat_id=user_id, user_id=user_id, ) @@ -263,7 +241,6 @@ async def emoji_not_purchased_to_select( ) -> None: await self._logger.ainfo( "emoji_not_purchased_to_select", - chat_id=user.id, user_id=user.id, ) From 5d3c8f33ce66c84d4f6e14a57b175f7b01fc3a85 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Tue, 16 Sep 2025 12:00:59 +0700 Subject: [PATCH 08/45] ref(`User`): remove aggregation data (#59) --- src/ttt/entities/core/user/user.py | 18 ------ ...95d1_remove_aggregation_data_from_users.py | 59 ++++++++++++++++++ .../infrastructure/sqlalchemy/tables/user.py | 9 --- src/ttt/presentation/adapters/user_views.py | 62 ++++++++++++++----- .../test_entities/test_core/conftest.py | 6 -- .../test_entities/test_core/test_game.py | 18 ------ .../test_entities/test_core/test_user.py | 3 - .../test_adapters/test_game_dao.py | 11 +--- 8 files changed, 106 insertions(+), 80 deletions(-) create mode 100644 src/ttt/infrastructure/alembic/versions/679c935495d1_remove_aggregation_data_from_users.py diff --git a/src/ttt/entities/core/user/user.py b/src/ttt/entities/core/user/user.py index c8eb633..b987900 100644 --- a/src/ttt/entities/core/user/user.py +++ b/src/ttt/entities/core/user/user.py @@ -83,10 +83,6 @@ class User: selected_emoji_id: UUID | None rating: EloRating admin_right: AdminRight | None - - number_of_wins: int - number_of_draws: int - number_of_defeats: int game_location: UserGameLocation | None emoji_cost: ClassVar[Stars] = 1000 @@ -253,7 +249,6 @@ def lose_to_user( self.leave_game(tracking) - self.number_of_defeats += 1 new_rating = new_elo_rating( self.rating, enemy_rating, @@ -272,8 +267,6 @@ def lose_to_ai(self, tracking: Tracking) -> UserLoss: """ self.leave_game(tracking) - - self.number_of_defeats += 1 tracking.register_mutated(self) return UserLoss(user_id=self.id, rating_vector=None) @@ -290,9 +283,6 @@ def win_against_user( """ self.leave_game(tracking) - - self.number_of_wins += 1 - new_rating = new_elo_rating( self.rating, enemy_rating, @@ -327,9 +317,6 @@ def be_draw_against_user( """ self.leave_game(tracking) - - self.number_of_draws += 1 - new_rating = new_elo_rating( self.rating, enemy_rating, @@ -348,8 +335,6 @@ def be_draw_against_ai(self, tracking: Tracking) -> UserDraw: """ self.leave_game(tracking) - - self.number_of_draws += 1 tracking.register_mutated(self) return UserDraw(self.id, rating_vector=None) @@ -443,9 +428,6 @@ def register_user(user_id: int, tracking: Tracking) -> User: emojis=[], selected_emoji_id=None, rating=initial_elo_rating, - number_of_wins=0, - number_of_draws=0, - number_of_defeats=0, game_location=None, admin_right=None, ) diff --git a/src/ttt/infrastructure/alembic/versions/679c935495d1_remove_aggregation_data_from_users.py b/src/ttt/infrastructure/alembic/versions/679c935495d1_remove_aggregation_data_from_users.py new file mode 100644 index 0000000..58cdb81 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/679c935495d1_remove_aggregation_data_from_users.py @@ -0,0 +1,59 @@ +""" +remove aggregation data from `users`. + +Revision ID: 679c935495d1 +Revises: dc59db88676c +Create Date: 2025-09-16 04:42:25.995128 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = "679c935495d1" +down_revision: str | None = "dc59db88676c" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "number_of_draws") + op.drop_column("users", "number_of_wins") + op.drop_column("users", "number_of_defeats") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "users", + sa.Column( + "number_of_defeats", + sa.INTEGER(), + autoincrement=False, + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column( + "number_of_wins", + sa.INTEGER(), + autoincrement=False, + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column( + "number_of_draws", + sa.INTEGER(), + autoincrement=False, + nullable=False, + ), + ) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/sqlalchemy/tables/user.py b/src/ttt/infrastructure/sqlalchemy/tables/user.py index a6e09d2..9234257 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/user.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/user.py @@ -82,9 +82,6 @@ class TableUser(Base[User]): index=True, ) rating: Mapped[float] - number_of_wins: Mapped[int] - number_of_draws: Mapped[int] - number_of_defeats: Mapped[int] game_location_game_id: Mapped[UUID | None] = mapped_column( ForeignKey("games.id", deferrable=True, initially="DEFERRED"), index=True, @@ -134,9 +131,6 @@ def __entity__(self) -> User: emojis=[it.entity() for it in self.emojis], selected_emoji_id=self.selected_emoji_id, rating=self.rating, - number_of_wins=self.number_of_wins, - number_of_draws=self.number_of_draws, - number_of_defeats=self.number_of_defeats, game_location=location, admin_right=admin_right, ) @@ -164,9 +158,6 @@ def of(cls, it: User) -> "TableUser": account_stars=it.account.stars, selected_emoji_id=it.selected_emoji_id, rating=it.rating, - number_of_wins=it.number_of_wins, - number_of_draws=it.number_of_draws, - number_of_defeats=it.number_of_defeats, game_location_game_id=game_location_game_id, admin_right=admin_right, admin_right_via_other_admin_admin_id=( diff --git a/src/ttt/presentation/adapters/user_views.py b/src/ttt/presentation/adapters/user_views.py index 0f2ac6e..338f2db 100644 --- a/src/ttt/presentation/adapters/user_views.py +++ b/src/ttt/presentation/adapters/user_views.py @@ -20,10 +20,12 @@ from ttt.entities.core.stars import Stars from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import User, is_user_in_game, user_stars +from ttt.entities.tools.assertion import not_none from ttt.infrastructure.sqlalchemy.stmts import ( selected_user_emoji_str_from_postgres, user_emojis_from_postgres, ) +from ttt.infrastructure.sqlalchemy.tables.game import TableGame from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( TableInvitationToGame, TableInvitationToGameState, @@ -80,13 +82,7 @@ async def view_of_user_with_id( /, ) -> None: user_stmt = ( - select( - TableUser.number_of_wins, - TableUser.number_of_draws, - TableUser.number_of_defeats, - TableUser.account_stars, - TableUser.rating, - ) + select(TableUser.account_stars, TableUser.rating) .where(TableUser.id == user_id) ) result = await self._session.execute(user_stmt) @@ -96,10 +92,31 @@ async def view_of_user_with_id( await need_to_start_message(self._bot, user_id) return + wins_stmt = ( + select(func.count(1)) + .where(TableGame.result_decided_game_user_win_user_id == user_id) + ) + wins = not_none(await self._session.scalar(wins_stmt)) + + draws_stmt = ( + select(func.count(1)) + .where( + (TableGame.result_draw_game_user_draw1_user_id == user_id) + | (TableGame.result_draw_game_user_draw2_user_id == user_id), + ) + ) + draws = not_none(await self._session.scalar(draws_stmt)) + + defeats_stmt = ( + select(func.count(1)) + .where(TableGame.result_decided_game_user_loss_user_id == user_id) + ) + defeats = not_none(await self._session.scalar(defeats_stmt)) + view = UserProfileView.of( - user_row.number_of_wins, - user_row.number_of_draws, - user_row.number_of_defeats, + wins, + draws, + defeats, user_row.account_stars, user_row.rating, ) @@ -326,9 +343,6 @@ async def user_is_not_admin_view(self, user: User, /) -> None: async def other_user_view(self, user: User, other_user_id: int, /) -> None: stmt = ( select( - TableUser.number_of_wins, - TableUser.number_of_draws, - TableUser.number_of_defeats, TableUser.account_stars, TableUser.rating, TableUser.admin_right, @@ -349,6 +363,22 @@ async def other_user_view(self, user: User, other_user_id: int, /) -> None: ) return + wins_stmt = select(func.count(1)).where( + TableGame.result_decided_game_user_win_user_id == other_user_id, + ) + wins = not_none(await self._session.scalar(wins_stmt)) + + draws_stmt = select(func.count(1)).where( + (TableGame.result_draw_game_user_draw1_user_id == other_user_id) + | (TableGame.result_draw_game_user_draw2_user_id == other_user_id), + ) + draws = not_none(await self._session.scalar(draws_stmt)) + + defeats_stmt = select(func.count(1)).where( + TableGame.result_decided_game_user_loss_user_id == other_user_id, + ) + defeats = not_none(await self._session.scalar(defeats_stmt)) + if row.admin_right is None: admin_right = None else: @@ -359,9 +389,9 @@ async def other_user_view(self, user: User, other_user_id: int, /) -> None: view = OtherUserProfileView.of( other_user_id, admin_right, - row.number_of_wins, - row.number_of_draws, - row.number_of_defeats, + wins, + draws, + defeats, row.account_stars, row.rating, ) diff --git a/tests/test_ttt/test_entities/test_core/conftest.py b/tests/test_ttt/test_entities/test_core/conftest.py index 8a45deb..59b8ef3 100644 --- a/tests/test_ttt/test_entities/test_core/conftest.py +++ b/tests/test_ttt/test_entities/test_core/conftest.py @@ -28,9 +28,6 @@ def user1() -> User: emojis=[], selected_emoji_id=None, rating=1000., - number_of_wins=0, - number_of_draws=0, - number_of_defeats=0, game_location=UserGameLocation(1, UUID(int=0)), admin_right=None, ) @@ -44,9 +41,6 @@ def user2() -> User: emojis=[], rating=1000., selected_emoji_id=None, - number_of_wins=0, - number_of_draws=0, - number_of_defeats=0, game_location=UserGameLocation(2, UUID(int=0)), admin_right=None, ) diff --git a/tests/test_ttt/test_entities/test_core/test_game.py b/tests/test_ttt/test_entities/test_core/test_game.py index 1737e06..d4091f8 100644 --- a/tests/test_ttt/test_entities/test_core/test_game.py +++ b/tests/test_ttt/test_entities/test_core/test_game.py @@ -428,9 +428,6 @@ def test_winning_game( # noqa: PLR0913, PLR0917 emojis=[], rating=1020.0, selected_emoji_id=None, - number_of_wins=1, - number_of_draws=0, - number_of_defeats=0, game_location=None, admin_right=None, ) @@ -442,9 +439,6 @@ def test_winning_game( # noqa: PLR0913, PLR0917 emojis=[], rating=981.1500225556907, selected_emoji_id=None, - number_of_wins=0, - number_of_draws=0, - number_of_defeats=1, game_location=None, admin_right=None, ) @@ -521,9 +515,6 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 emojis=[], rating=1020.0, selected_emoji_id=None, - number_of_wins=0, - number_of_draws=1, - number_of_defeats=0, game_location=None, admin_right=None, ) @@ -535,9 +526,6 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 emojis=[], rating=1020.0, selected_emoji_id=None, - number_of_wins=0, - number_of_draws=1, - number_of_defeats=0, game_location=None, admin_right=None, ) @@ -614,9 +602,6 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 emojis=[], rating=1020.0, selected_emoji_id=None, - number_of_wins=1, - number_of_draws=0, - number_of_defeats=0, game_location=None, admin_right=None, ) @@ -628,9 +613,6 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 emojis=[], rating=981.1500225556907, selected_emoji_id=None, - number_of_wins=0, - number_of_draws=0, - number_of_defeats=1, game_location=None, admin_right=None, ) diff --git a/tests/test_ttt/test_entities/test_core/test_user.py b/tests/test_ttt/test_entities/test_core/test_user.py index aa96279..365c44c 100644 --- a/tests/test_ttt/test_entities/test_core/test_user.py +++ b/tests/test_ttt/test_entities/test_core/test_user.py @@ -16,9 +16,6 @@ def test_create_user(tracking: Tracking, object_: str) -> None: emojis=[], rating=1000., selected_emoji_id=None, - number_of_wins=0, - number_of_draws=0, - number_of_defeats=0, game_location=None, admin_right=None, ) diff --git a/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py index 9fb7d8b..42fa771 100644 --- a/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py +++ b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py @@ -31,9 +31,6 @@ def player1() -> User: emojis=[], selected_emoji_id=None, rating=1000., - number_of_wins=0, - number_of_draws=0, - number_of_defeats=0, game_location=UserGameLocation(1, UUID(int=0)), admin_right=None, ) @@ -97,13 +94,7 @@ async def test_games_played_by_player_id( ) -> None: async with session.begin(): await session.execute( - insert(TableUser).values({ - "id": 1, - "rating": 1000, - "number_of_wins": 0, - "number_of_draws": 0, - "number_of_defeats": 0, - }), + insert(TableUser).values({"id": 1, "rating": 1000}), ) if games: await session.execute( From cd76b0f20299e5c127b529820167cb74d7f938ab Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:20:53 +0700 Subject: [PATCH 09/45] ref(`user`): remove `UserGameLocation` --- src/ttt/application/game/game/cancel_game.py | 15 +--- .../game/game/make_move_in_game.py | 19 +---- .../application/game/game/ports/game_views.py | 12 +-- src/ttt/application/game/game/ports/games.py | 6 +- .../game/game/start_game_with_ai.py | 16 +--- .../matchmaking_queue/game/wait_game.py | 5 +- src/ttt/entities/core/game/game.py | 22 +++--- src/ttt/entities/core/user/location.py | 8 -- src/ttt/entities/core/user/user.py | 15 ++-- src/ttt/infrastructure/adapters/games.py | 12 +-- ..._rename_users_game_location_game_id_to_.py | 46 +++++++++++ .../infrastructure/sqlalchemy/tables/user.py | 20 +---- src/ttt/presentation/adapters/game_views.py | 76 ++++++------------- src/ttt/presentation/adapters/user_views.py | 12 +-- 14 files changed, 107 insertions(+), 177 deletions(-) delete mode 100644 src/ttt/entities/core/user/location.py create mode 100644 src/ttt/infrastructure/alembic/versions/c122fc5b8ec6_rename_users_game_location_game_id_to_.py diff --git a/src/ttt/application/game/game/cancel_game.py b/src/ttt/application/game/game/cancel_game.py index b0c0e25..6e75aeb 100644 --- a/src/ttt/application/game/game/cancel_game.py +++ b/src/ttt/application/game/game/cancel_game.py @@ -7,8 +7,6 @@ from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games from ttt.entities.core.game.game import AlreadyCompletedGameError -from ttt.entities.core.user.user import User -from ttt.entities.tools.assertion import not_none from ttt.entities.tools.tracking import Tracking @@ -23,18 +21,12 @@ class CancelGame: async def __call__(self, user_id: int) -> None: async with self.transaction: - game = await self.games.game_with_game_location(user_id) + game = await self.games.current_user_game(user_id) if game is None: await self.game_views.no_game_view(user_id) return - locations = tuple( - not_none(user.game_location) - for user in (game.player1, game.player2) - if isinstance(user, User) - ) - try: tracking = Tracking() game.cancel(user_id, tracking) @@ -49,7 +41,4 @@ async def __call__(self, user_id: int) -> None: await self.log.game_cancelled(user_id, game) await self.map_(tracking) - await self.game_views.game_view_with_locations( - locations, - game, - ) + await self.game_views.game_view(game) diff --git a/src/ttt/application/game/game/make_move_in_game.py b/src/ttt/application/game/game/make_move_in_game.py index 85ad186..1917575 100644 --- a/src/ttt/application/game/game/make_move_in_game.py +++ b/src/ttt/application/game/game/make_move_in_game.py @@ -17,8 +17,6 @@ NoCellError, NotCurrentPlayerError, ) -from ttt.entities.core.user.user import User -from ttt.entities.tools.assertion import not_none from ttt.entities.tools.tracking import Tracking @@ -41,17 +39,12 @@ async def __call__( cell_number_int: int, ) -> None: async with self.transaction: - game = await self.games.game_with_game_location(user_id) + game = await self.games.current_user_game(user_id) if game is None: await self.game_views.no_game_view(user_id) return - locations = tuple( - not_none(user.game_location) - for user in (game.player1, game.player2) - if isinstance(user, User) - ) ( random, games_played_by_player_id, @@ -110,10 +103,7 @@ async def __call__( await self.log.user_move_maked(user_id, game, user_move) if user_move.next_move_ai_id is not None: - await self.game_views.game_view_with_locations( - locations, - game, - ) + await self.game_views.game_view(game) ( free_cell_random, @@ -142,7 +132,4 @@ async def __call__( await self.log.game_completed(user_id, game) await self.map_(tracking) - await self.game_views.game_view_with_locations( - locations, - game, - ) + await self.game_views.game_view(game) diff --git a/src/ttt/application/game/game/ports/game_views.py b/src/ttt/application/game/game/ports/game_views.py index a06fb88..fc5653e 100644 --- a/src/ttt/application/game/game/ports/game_views.py +++ b/src/ttt/application/game/game/ports/game_views.py @@ -1,8 +1,6 @@ from abc import ABC, abstractmethod -from collections.abc import Sequence from ttt.entities.core.game.game import Game -from ttt.entities.core.user.location import UserGameLocation class GameViews(ABC): @@ -10,17 +8,11 @@ class GameViews(ABC): async def current_game_view_with_user_id(self, user_id: int, /) -> None: ... @abstractmethod - async def game_view_with_locations( - self, - user_locations: Sequence[UserGameLocation], - game: Game, - /, - ) -> None: ... + async def game_view(self, game: Game, /) -> None: ... @abstractmethod - async def started_game_view_with_locations( + async def started_game_view( self, - user_locations: Sequence[UserGameLocation], game: Game, /, ) -> None: ... diff --git a/src/ttt/application/game/game/ports/games.py b/src/ttt/application/game/game/ports/games.py index b339f96..46ce560 100644 --- a/src/ttt/application/game/game/ports/games.py +++ b/src/ttt/application/game/game/ports/games.py @@ -8,8 +8,4 @@ class NoGameError(Exception): ... class Games(ABC): @abstractmethod - async def game_with_game_location( - self, - game_location_user_id: int, - /, - ) -> Game | None: ... + async def current_user_game(self, user_id: int, /) -> Game | None: ... diff --git a/src/ttt/application/game/game/start_game_with_ai.py b/src/ttt/application/game/game/start_game_with_ai.py index ee89faf..ff17603 100644 --- a/src/ttt/application/game/game/start_game_with_ai.py +++ b/src/ttt/application/game/game/start_game_with_ai.py @@ -14,7 +14,6 @@ from ttt.application.user.common.ports.users import Users from ttt.entities.core.game.ai import AiType from ttt.entities.core.game.game import start_game_with_ai -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import UserAlreadyInGameError from ttt.entities.tools.tracking import Tracking @@ -71,15 +70,9 @@ async def __call__(self, user_id: int, ai_type: AiType) -> None: if started_game.next_move_ai_id is None: await self.map_(tracking) - await self.game_views.started_game_view_with_locations( - [UserGameLocation(user_id, started_game.game.id)], - started_game.game, - ) + await self.game_views.started_game_view(started_game.game) else: - await self.game_views.started_game_view_with_locations( - [UserGameLocation(user_id, started_game.game.id)], - started_game.game, - ) + await self.game_views.started_game_view(started_game.game) ( free_cell_random, @@ -104,7 +97,4 @@ async def __call__(self, user_id: int, ai_type: AiType) -> None: ) await self.map_(tracking) - await self.game_views.game_view_with_locations( - [UserGameLocation(user_id, started_game.game.id)], - started_game.game, - ) + await self.game_views.game_view(started_game.game) diff --git a/src/ttt/application/matchmaking_queue/game/wait_game.py b/src/ttt/application/matchmaking_queue/game/wait_game.py index 436455e..19db28a 100644 --- a/src/ttt/application/matchmaking_queue/game/wait_game.py +++ b/src/ttt/application/matchmaking_queue/game/wait_game.py @@ -97,7 +97,4 @@ async def __call__(self, user_id: int) -> None: await self.game_log.game_against_user_started(game) await self.map_(tracking) - await self.game_views.started_game_view_with_locations( - game.locations(), - game, - ) + await self.game_views.started_game_view(game) diff --git a/src/ttt/entities/core/game/game.py b/src/ttt/entities/core/game/game.py index 4a83231..0d1de58 100644 --- a/src/ttt/entities/core/game/game.py +++ b/src/ttt/entities/core/game/game.py @@ -28,7 +28,6 @@ PlayerLoss, PlayerWin, ) -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import ( User, UserAlreadyInGameError, @@ -111,7 +110,7 @@ class Game: def __post_init__(self) -> None: assert_( - not all(isinstance(player, Ai) for player in self._players()), + not all(isinstance(player, Ai) for player in self.players()), else_=OnlyAiGameError, ) @@ -141,7 +140,7 @@ def is_against_user(self) -> bool: return not self.is_against_ai() def user(self, user_id: int) -> User | None: - for user in self._users(): + for user in self.users(): if user.id == user_id: return user @@ -195,10 +194,10 @@ def make_user_move( # noqa: C901 if not isinstance(current_player, User): raise TypeError - not_current_player = not_none(self._not_current_player()) + not_current_player = not_none(self.not_current_player()) assert_( - user_id in {user.id for user in self._users()}, + user_id in {user.id for user in self.users()}, else_=NotPlayerError(), ) assert_(current_player.id == user_id, else_=NotCurrentPlayerError()) @@ -296,7 +295,7 @@ def make_ai_move( if not isinstance(current_player, Ai): raise NotAiCurrentMoveError - not_current_player = self._not_current_player() + not_current_player = self.not_current_player() if not isinstance(not_current_player, User): raise TypeError @@ -384,9 +383,6 @@ def is_player_move_expected(self, player_id: int | UUID) -> bool: case _: raise ValueError(self.state, player_id) - def locations(self) -> tuple[UserGameLocation, ...]: - return tuple(not_none(user.game_location) for user in self._users()) - def _make_random_ai_move( self, current_player: Ai, @@ -459,15 +455,15 @@ def _current_player(self) -> Player | None: case GameState.completed: return None - def _players(self) -> tuple[Player, ...]: + def players(self) -> tuple[Player, ...]: return self.player1, self.player2 - def _users(self) -> tuple[User, ...]: + def users(self) -> tuple[User, ...]: return tuple( - player for player in self._players() if isinstance(player, User) + player for player in self.players() if isinstance(player, User) ) - def _not_current_player(self) -> Player | None: + def not_current_player(self) -> Player | None: match self.state: case GameState.wait_player1: return self.player2 diff --git a/src/ttt/entities/core/user/location.py b/src/ttt/entities/core/user/location.py deleted file mode 100644 index d544100..0000000 --- a/src/ttt/entities/core/user/location.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass -from uuid import UUID - - -@dataclass -class UserGameLocation: - user_id: int - game_id: UUID diff --git a/src/ttt/entities/core/user/user.py b/src/ttt/entities/core/user/user.py index b987900..ee78695 100644 --- a/src/ttt/entities/core/user/user.py +++ b/src/ttt/entities/core/user/user.py @@ -12,7 +12,6 @@ ) from ttt.entities.core.user.draw import UserDraw from ttt.entities.core.user.emoji import UserEmoji -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.loss import UserLoss from ttt.entities.core.user.rank import Rank, rank_for_rating from ttt.entities.core.user.win import UserWin @@ -83,7 +82,7 @@ class User: selected_emoji_id: UUID | None rating: EloRating admin_right: AdminRight | None - game_location: UserGameLocation | None + current_game_id: UUID | None emoji_cost: ClassVar[Stars] = 1000 @@ -221,7 +220,7 @@ def set_user_account( return user def is_in_game(self) -> bool: - return self.game_location is not None + return self.current_game_id is not None def be_in_game( self, @@ -234,7 +233,7 @@ def be_in_game( assert_(not self.is_in_game(), else_=UserAlreadyInGameError(self)) - self.game_location = UserGameLocation(self.id, game_id) + self.current_game_id = game_id tracking.register_mutated(self) def lose_to_user( @@ -346,7 +345,7 @@ def leave_game(self, tracking: Tracking) -> None: assert_(self.is_in_game(), else_=UserNotInGameError(self)) - self.game_location = None + self.current_game_id = None tracking.register_mutated(self) def buy_emoji( @@ -428,7 +427,7 @@ def register_user(user_id: int, tracking: Tracking) -> User: emojis=[], selected_emoji_id=None, rating=initial_elo_rating, - game_location=None, + current_game_id=None, admin_right=None, ) tracking.register_new(user) @@ -436,8 +435,8 @@ def register_user(user_id: int, tracking: Tracking) -> User: return user -def is_user_in_game(game_location: UserGameLocation | None) -> bool: - return game_location is not None +def is_user_in_game(current_game_id: UUID | None) -> bool: + return current_game_id is not None def is_user_admin(admin_right: AdminRight | None) -> bool: diff --git a/src/ttt/infrastructure/adapters/games.py b/src/ttt/infrastructure/adapters/games.py index 4dc5a69..6d611a1 100644 --- a/src/ttt/infrastructure/adapters/games.py +++ b/src/ttt/infrastructure/adapters/games.py @@ -13,21 +13,17 @@ class InPostgresGames(Games): _session: AsyncSession - async def game_with_game_location( - self, - game_location_user_id: int, - /, - ) -> Game | None: + async def current_user_game(self, user_id: int, /) -> Game | None: lock_stmt = ( select(TableGame.id) - .where(TableUser.game_location_game_id == TableGame.id) + .where(TableUser.current_game_id == TableGame.id) .with_for_update() ) await self._session.execute(lock_stmt) join_condition = ( - (TableUser.id == game_location_user_id) - & (TableUser.game_location_game_id == TableGame.id) + (TableUser.id == user_id) + & (TableUser.current_game_id == TableGame.id) ) stmt = select(TableGame).join(TableUser, join_condition) table_game = await self._session.scalar(stmt) diff --git a/src/ttt/infrastructure/alembic/versions/c122fc5b8ec6_rename_users_game_location_game_id_to_.py b/src/ttt/infrastructure/alembic/versions/c122fc5b8ec6_rename_users_game_location_game_id_to_.py new file mode 100644 index 0000000..b4eaa8d --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/c122fc5b8ec6_rename_users_game_location_game_id_to_.py @@ -0,0 +1,46 @@ +""" +rename `users.game_location_game_id` to `users.current_game_id`. + +Revision ID: c122fc5b8ec6 +Revises: 679c935495d1 +Create Date: 2025-09-16 11:10:56.703137 + +""" + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "c122fc5b8ec6" +down_revision: str | None = "679c935495d1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.alter_column( + "users", "game_location_game_id", new_column_name="current_game_id", + ) + op.execute(""" + ALTER INDEX ix_users_game_location_game_id + RENAME TO ix_users_current_game_id + """) + op.execute(""" + ALTER TABLE users RENAME CONSTRAINT users_game_location_game_id_fkey + TO users_current_game_id_fkey + """) + + +def downgrade() -> None: + op.alter_column( + "users", "current_game_id", new_column_name="game_location_game_id", + ) + op.execute(""" + ALTER INDEX ix_users_current_game_id + RENAME TO ix_users_game_location_game_id + """) + op.execute(""" + ALTER TABLE users RENAME CONSTRAINT users_current_game_id_fkey + TO users_game_location_game_id_fkey + """) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/user.py b/src/ttt/infrastructure/sqlalchemy/tables/user.py index 9234257..b016da0 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/user.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/user.py @@ -13,7 +13,6 @@ AdminRightViaOtherAdmin, ) from ttt.entities.core.user.emoji import UserEmoji -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import User, UserAtomic from ttt.entities.text.emoji import Emoji from ttt.entities.tools.assertion import not_none @@ -82,7 +81,7 @@ class TableUser(Base[User]): index=True, ) rating: Mapped[float] - game_location_game_id: Mapped[UUID | None] = mapped_column( + current_game_id: Mapped[UUID | None] = mapped_column( ForeignKey("games.id", deferrable=True, initially="DEFERRED"), index=True, ) @@ -110,14 +109,6 @@ class TableUser(Base[User]): ) def __entity__(self) -> User: - if self.game_location_game_id is not None: - location = UserGameLocation( - self.id, - self.game_location_game_id, - ) - else: - location = None - if self.admin_right is not None: admin_right = self.admin_right.entity( self.admin_right_via_other_admin_admin_id, @@ -131,17 +122,12 @@ def __entity__(self) -> User: emojis=[it.entity() for it in self.emojis], selected_emoji_id=self.selected_emoji_id, rating=self.rating, - game_location=location, + current_game_id=self.current_game_id, admin_right=admin_right, ) @classmethod def of(cls, it: User) -> "TableUser": - if it.game_location is None: - game_location_game_id = None - else: - game_location_game_id = it.game_location.game_id - match it.admin_right: case None: admin_right = None @@ -158,7 +144,7 @@ def of(cls, it: User) -> "TableUser": account_stars=it.account.stars, selected_emoji_id=it.selected_emoji_id, rating=it.rating, - game_location_game_id=game_location_game_id, + current_game_id=it.current_game_id, admin_right=admin_right, admin_right_via_other_admin_admin_id=( admin_right_via_other_admin_admin_id diff --git a/src/ttt/presentation/adapters/game_views.py b/src/ttt/presentation/adapters/game_views.py index 89bb8cd..eb379bd 100644 --- a/src/ttt/presentation/adapters/game_views.py +++ b/src/ttt/presentation/adapters/game_views.py @@ -1,5 +1,4 @@ from asyncio import gather -from collections.abc import Sequence from dataclasses import dataclass from aiogram import Bot @@ -11,7 +10,6 @@ from ttt.entities.core.game.game import ( Game, ) -from ttt.entities.core.user.location import UserGameLocation from ttt.infrastructure.sqlalchemy.tables.game import TableGame from ttt.infrastructure.sqlalchemy.tables.user import TableUser from ttt.presentation.aiogram.game.messages import completed_game_sticker @@ -36,7 +34,7 @@ class AiogramGameViews(GameViews): async def current_game_view_with_user_id(self, user_id: int, /) -> None: join_condition = ( (TableUser.id == user_id) - & (TableUser.game_location_game_id == TableGame.id) + & (TableUser.current_game_id == TableGame.id) ) stmt = select(TableGame).join(TableUser, join_condition) table_game = await self._session.scalar(stmt) @@ -47,33 +45,23 @@ async def current_game_view_with_user_id(self, user_id: int, /) -> None: game = table_game.entity() self._result_buffer.result = ActiveGameView.of(game, user_id) - async def game_view_with_locations( - self, - user_locations: Sequence[UserGameLocation], - game: Game, - /, - ) -> None: + async def game_view(self, game: Game, /) -> None: match game.result: case None: await gather(*( - self._active_game_view(location, game) - for location in user_locations + self._active_game_view(user.id, game) + for user in game.users() )) case _: await gather(*( - self._completed_game_view(location, game) - for location in user_locations + self._completed_game_view(user.id, game) + for user in game.users() )) - async def started_game_view_with_locations( - self, - user_locations: Sequence[UserGameLocation], - game: Game, - /, - ) -> None: + async def started_game_view(self, game: Game, /) -> None: await gather(*( - self._started_game_view(location, game) - for location in user_locations + self._started_game_view(user.id, game) + for user in game.users() )) async def no_game_view(self, user_id: int, /) -> None: @@ -145,50 +133,34 @@ async def already_filled_cell_error( MainDialogState.game, data, StartMode.RESET_STACK, ) - async def _started_game_view( - self, - user_location: UserGameLocation, - game: Game, - /, - ) -> None: - dialog_manager = self._dialog_manager_for_user(user_location.user_id) - + async def _started_game_view(self, user_id: int, game: Game, /) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) await dialog_manager.start( MainDialogState.game, - ActiveGameView.of(game, user_location.user_id).window_data(), + ActiveGameView.of(game, user_id).window_data(), StartMode.RESET_STACK, ) - async def _active_game_view( - self, location: UserGameLocation, game: Game, - ) -> None: - dialog_manager = self._dialog_manager_for_user(location.user_id) + async def _active_game_view(self, user_id: int, game: Game) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) - view = ActiveGameView.of(game, location.user_id) + view = ActiveGameView.of(game, user_id) data = view.window_data() await dialog_manager.start( MainDialogState.game, data, StartMode.RESET_STACK, ) - async def _completed_game_view( - self, - location: UserGameLocation, - game: Game, - ) -> None: - dialog_manager = self._dialog_manager_for_user(location.user_id) + async def _completed_game_view(self, user_id: int, game: Game) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) - view = CompletedGameView.of(game, location.user_id) + view = CompletedGameView.of(game, user_id) data = view.window_data() - await gather( - completed_game_sticker( - self._bot, location.user_id, game, location.user_id, - ), - dialog_manager.start( - MainDialogState.game, - data, - StartMode.RESET_STACK, - ShowMode.DELETE_AND_SEND, - ), + await completed_game_sticker(self._bot, user_id, game, user_id) + await dialog_manager.start( + MainDialogState.game, + data, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, ) diff --git a/src/ttt/presentation/adapters/user_views.py b/src/ttt/presentation/adapters/user_views.py index 338f2db..aad31d5 100644 --- a/src/ttt/presentation/adapters/user_views.py +++ b/src/ttt/presentation/adapters/user_views.py @@ -18,7 +18,6 @@ EmojiSelectionUserViews, ) from ttt.entities.core.stars import Stars -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import User, is_user_in_game, user_stars from ttt.entities.tools.assertion import not_none from ttt.infrastructure.sqlalchemy.stmts import ( @@ -129,7 +128,7 @@ async def user_menu_view(self, user_id: int, /) -> None: ) stmt = ( select( - TableUser.game_location_game_id, + TableUser.current_game_id, TableUser.account_stars, TableUser.rating, has_user_emojis_stmt, @@ -142,13 +141,6 @@ async def user_menu_view(self, user_id: int, /) -> None: if row is None: raise ValueError - game_location_game_id = row.game_location_game_id - - if game_location_game_id is None: - game_location = None - else: - game_location = UserGameLocation(user_id, game_location_game_id) - incoming_invitations_to_game_stmt = ( select(func.count(1)) .where( @@ -177,7 +169,7 @@ async def user_menu_view(self, user_id: int, /) -> None: amout_of_incoming_invitations_to_game = "many" view = MainMenuView( - is_user_in_game=is_user_in_game(game_location), + is_user_in_game=is_user_in_game(row.current_game_id), has_user_emojis=row.has_user_emojis, stars=row.account_stars, rating=row.rating, From a0d726d20549136fec1528909e9cd47502f4b81a Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:36:11 +0700 Subject: [PATCH 10/45] ref(`Game`): remove `number_of_unfilled_cells` --- src/ttt/entities/core/game/game.py | 18 +----------------- .../infrastructure/sqlalchemy/tables/game.py | 2 -- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/src/ttt/entities/core/game/game.py b/src/ttt/entities/core/game/game.py index 0d1de58..1c96b8c 100644 --- a/src/ttt/entities/core/game/game.py +++ b/src/ttt/entities/core/game/game.py @@ -83,10 +83,6 @@ class OnlyAiGameError(Exception): ... class NotAiCurrentMoveError(Exception): ... -def number_of_unfilled_cells(board: Matrix[Cell]) -> int: - return sum(int(not cell.is_filled()) for cell in chain.from_iterable(board)) - - @dataclass class Game: """ @@ -104,7 +100,6 @@ class Game: player2: Player player2_emoji: Emoji board: Board - number_of_unfilled_cells: int result: GameResult | None state: GameState @@ -127,12 +122,6 @@ def __post_init__(self) -> None: ) assert_(is_cell_order_ok, else_=InvalidCellOrderError) - board = self.board - assert_( - number_of_unfilled_cells(board) == self.number_of_unfilled_cells, - else_=InvalidNumberOfUnfilledCellsError, - ) - def is_against_ai(self) -> bool: return isinstance(self.player1, Ai) or isinstance(self.player2, Ai) @@ -215,7 +204,6 @@ def make_user_move( # noqa: C901 raise NoCellError from error cell.fill_as_user(user_id, tracking) - self.number_of_unfilled_cells -= 1 tracking.register_mutated(self) if self._is_player_winner(current_player, cell.board_position): @@ -339,7 +327,6 @@ def make_ai_move( tracking, ) - self.number_of_unfilled_cells -= 1 tracking.register_mutated(self) if self._is_player_winner(current_player, cell_position): @@ -392,7 +379,6 @@ def _make_random_ai_move( ) -> AiMove: cell = choice(self._free_cells(), random=free_cell_random) cell.fill_as_ai(current_player.id, tracking) - self.number_of_unfilled_cells -= 1 tracking.register_mutated(self) if self._is_player_winner(current_player, cell.board_position): @@ -419,7 +405,7 @@ def _can_continue(self) -> bool: return not self._is_board_filled() def _is_board_filled(self) -> bool: - return self.number_of_unfilled_cells <= 0 + return all(cell.is_filled() for cell in chain.from_iterable(self.board)) def _is_player_winner(self, player: Player, cell_position: Vector) -> bool: cell_x, cell_y = cell_position @@ -545,7 +531,6 @@ def start_game( # noqa: PLR0913, PLR0917 player2, player2_emoji, board, - number_of_unfilled_cells(board), None, GameState.wait_player1, ) @@ -622,7 +607,6 @@ def start_game_with_ai( # noqa: PLR0913, PLR0917 player2, player2_emoji, board, - number_of_unfilled_cells(board), None, GameState.wait_player1, ) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/game.py b/src/ttt/infrastructure/sqlalchemy/tables/game.py index 8f720bd..8181052 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/game.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/game.py @@ -15,7 +15,6 @@ Game, GameAtomic, GameState, - number_of_unfilled_cells, ) from ttt.entities.core.game.game_result import ( CancelledGameResult, @@ -284,7 +283,6 @@ def __entity__(self) -> Game: self._player2(), Emoji(self.player2_emoji_str), board, - number_of_unfilled_cells(board), self._result(), self.state.entity(), ) From f372f37e051a4e932bfbdf29cf7089cd4615feee Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Tue, 16 Sep 2025 19:58:15 +0700 Subject: [PATCH 11/45] fix(`tests`): up-to-date --- .../test_entities/test_core/conftest.py | 5 ++--- .../test_entities/test_core/test_game.py | 18 ++++++------------ .../test_entities/test_core/test_user.py | 2 +- .../test_adapters/test_game_dao.py | 4 +--- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/tests/test_ttt/test_entities/test_core/conftest.py b/tests/test_ttt/test_entities/test_core/conftest.py index 59b8ef3..f5897ce 100644 --- a/tests/test_ttt/test_entities/test_core/conftest.py +++ b/tests/test_ttt/test_entities/test_core/conftest.py @@ -3,7 +3,6 @@ from pytest import fixture from ttt.entities.core.user.account import Account -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import User from ttt.entities.math.random import Random from ttt.entities.text.emoji import Emoji @@ -28,7 +27,7 @@ def user1() -> User: emojis=[], selected_emoji_id=None, rating=1000., - game_location=UserGameLocation(1, UUID(int=0)), + current_game_id=UUID(int=0), admin_right=None, ) @@ -41,7 +40,7 @@ def user2() -> User: emojis=[], rating=1000., selected_emoji_id=None, - game_location=UserGameLocation(2, UUID(int=0)), + current_game_id=UUID(int=0), admin_right=None, ) diff --git a/tests/test_ttt/test_entities/test_core/test_game.py b/tests/test_ttt/test_entities/test_core/test_game.py index d4091f8..0eb7e91 100644 --- a/tests/test_ttt/test_entities/test_core/test_game.py +++ b/tests/test_ttt/test_entities/test_core/test_game.py @@ -120,7 +120,6 @@ def game( user2, emoji2, standard_board, - 9, None, GameState.wait_player1, ) @@ -162,7 +161,6 @@ def test_not_standard_board( user2, emoji2, not_standard_board, - 9, None, GameState.wait_player1, ) @@ -182,7 +180,6 @@ def test_one_user( user1, emoji2, standard_board, - 9, None, GameState.wait_player1, ) @@ -202,7 +199,6 @@ def test_one_emoji( user2, emoji1, standard_board, - 9, None, GameState.wait_player1, ) @@ -223,7 +219,6 @@ def test_game_with_invalid_cell_order( user2, emoji2, board_with_invalid_cell_order, - 9, None, GameState.wait_player1, ) @@ -280,7 +275,6 @@ def test_make_move_with_completed_game( # noqa: PLR0913, PLR0917 user2, emoji2, standard_board, - 9, DecidedGameResult( win=UserWin(user_id=1, new_stars=20, rating_vector=20.), loss=UserLoss(user_id=2, rating_vector=-20.), @@ -428,7 +422,7 @@ def test_winning_game( # noqa: PLR0913, PLR0917 emojis=[], rating=1020.0, selected_emoji_id=None, - game_location=None, + current_game_id=None, admin_right=None, ) @@ -439,7 +433,7 @@ def test_winning_game( # noqa: PLR0913, PLR0917 emojis=[], rating=981.1500225556907, selected_emoji_id=None, - game_location=None, + current_game_id=None, admin_right=None, ) @@ -515,7 +509,7 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 emojis=[], rating=1020.0, selected_emoji_id=None, - game_location=None, + current_game_id=None, admin_right=None, ) @@ -526,7 +520,7 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 emojis=[], rating=1020.0, selected_emoji_id=None, - game_location=None, + current_game_id=None, admin_right=None, ) @@ -602,7 +596,7 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 emojis=[], rating=1020.0, selected_emoji_id=None, - game_location=None, + current_game_id=None, admin_right=None, ) @@ -613,7 +607,7 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 emojis=[], rating=981.1500225556907, selected_emoji_id=None, - game_location=None, + current_game_id=None, admin_right=None, ) diff --git a/tests/test_ttt/test_entities/test_core/test_user.py b/tests/test_ttt/test_entities/test_core/test_user.py index 365c44c..eed7ba3 100644 --- a/tests/test_ttt/test_entities/test_core/test_user.py +++ b/tests/test_ttt/test_entities/test_core/test_user.py @@ -16,7 +16,7 @@ def test_create_user(tracking: Tracking, object_: str) -> None: emojis=[], rating=1000., selected_emoji_id=None, - game_location=None, + current_game_id=None, admin_right=None, ) diff --git a/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py index 42fa771..05064f3 100644 --- a/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py +++ b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py @@ -8,7 +8,6 @@ from ttt.entities.core.game.cell import Cell from ttt.entities.core.game.game import Game, GameState from ttt.entities.core.user.account import Account -from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import User from ttt.entities.elo.rating import GamesPlayed from ttt.entities.math.matrix import Matrix @@ -31,7 +30,7 @@ def player1() -> User: emojis=[], selected_emoji_id=None, rating=1000., - game_location=UserGameLocation(1, UUID(int=0)), + current_game_id=UUID(int=0), admin_right=None, ) @@ -69,7 +68,6 @@ def game(player1: User, player2: Ai) -> Game: Cell(UUID(int=0), UUID(int=0), (2, 2), None, None), ], ]), - number_of_unfilled_cells=9, result=None, state=GameState.wait_player1, ) From 160397c0bcf914081e483b98b904bbb0b4b74e09 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Wed, 17 Sep 2025 16:01:31 +0700 Subject: [PATCH 12/45] ref(`entities`): rename `MatchmakingQueue` to `Matchmaking` --- .../__init__.py | 0 .../common/__init__.py | 0 .../common/matchmaking_log.py} | 4 +- .../common/matchmaking_views.py} | 2 +- .../matchmaking/common/shared_matchmaking.py | 9 +++ .../game/__init__.py | 0 .../game/wait_game.py | 36 ++++++------ .../common/shared_matchmaking_queue.py | 9 --- src/ttt/entities/atomic.py | 6 +- .../__init__.py | 0 .../matchmaking.py} | 10 ++-- .../user_waiting.py | 0 ...making_queue_log.py => matchmaking_log.py} | 10 ++-- .../adapters/shared_matchmaking.py | 31 ++++++++++ .../adapters/shared_matchmaking_queue.py | 31 ---------- ...rename_matchmaking_queue_user_waitings_.py | 56 +++++++++++++++++++ .../sqlalchemy/tables/__init__.py | 4 +- .../sqlalchemy/tables/atomic.py | 16 +++--- .../{matchmaking_queue.py => matchmaking.py} | 22 ++++---- src/ttt/main/common/di.py | 28 +++++----- src/ttt/main/tg_bot/di.py | 16 +++--- ...ng_queue_views.py => matchmaking_views.py} | 6 +- .../main_dialog/game_start_window.py | 2 +- 23 files changed, 177 insertions(+), 121 deletions(-) rename src/ttt/application/{matchmaking_queue => matchmaking}/__init__.py (100%) rename src/ttt/application/{matchmaking_queue => matchmaking}/common/__init__.py (100%) rename src/ttt/application/{matchmaking_queue/common/matchmaking_queue_log.py => matchmaking/common/matchmaking_log.py} (78%) rename src/ttt/application/{matchmaking_queue/common/matchmaking_queue_views.py => matchmaking/common/matchmaking_views.py} (85%) create mode 100644 src/ttt/application/matchmaking/common/shared_matchmaking.py rename src/ttt/application/{matchmaking_queue => matchmaking}/game/__init__.py (100%) rename src/ttt/application/{matchmaking_queue => matchmaking}/game/wait_game.py (71%) delete mode 100644 src/ttt/application/matchmaking_queue/common/shared_matchmaking_queue.py rename src/ttt/entities/core/{matchmaking_queue => matchmaking}/__init__.py (100%) rename src/ttt/entities/core/{matchmaking_queue/matchmaking_queue.py => matchmaking/matchmaking.py} (90%) rename src/ttt/entities/core/{matchmaking_queue => matchmaking}/user_waiting.py (100%) rename src/ttt/infrastructure/adapters/{matchmaking_queue_log.py => matchmaking_log.py} (70%) create mode 100644 src/ttt/infrastructure/adapters/shared_matchmaking.py delete mode 100644 src/ttt/infrastructure/adapters/shared_matchmaking_queue.py create mode 100644 src/ttt/infrastructure/alembic/versions/051831b12f05_rename_matchmaking_queue_user_waitings_.py rename src/ttt/infrastructure/sqlalchemy/tables/{matchmaking_queue.py => matchmaking.py} (70%) rename src/ttt/presentation/adapters/{matchmaking_queue_views.py => matchmaking_views.py} (84%) diff --git a/src/ttt/application/matchmaking_queue/__init__.py b/src/ttt/application/matchmaking/__init__.py similarity index 100% rename from src/ttt/application/matchmaking_queue/__init__.py rename to src/ttt/application/matchmaking/__init__.py diff --git a/src/ttt/application/matchmaking_queue/common/__init__.py b/src/ttt/application/matchmaking/common/__init__.py similarity index 100% rename from src/ttt/application/matchmaking_queue/common/__init__.py rename to src/ttt/application/matchmaking/common/__init__.py diff --git a/src/ttt/application/matchmaking_queue/common/matchmaking_queue_log.py b/src/ttt/application/matchmaking/common/matchmaking_log.py similarity index 78% rename from src/ttt/application/matchmaking_queue/common/matchmaking_queue_log.py rename to src/ttt/application/matchmaking/common/matchmaking_log.py index d1c76c1..dcafc4e 100644 --- a/src/ttt/application/matchmaking_queue/common/matchmaking_queue_log.py +++ b/src/ttt/application/matchmaking/common/matchmaking_log.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -class CommonMatchmakingQueueLog(ABC): +class CommonMatchmakingLog(ABC): @abstractmethod async def waiting_for_game_start( self, @@ -17,6 +17,6 @@ async def double_waiting_for_game_start( ) -> None: ... @abstractmethod - async def user_already_in_game_to_add_to_matchmaking_queue( + async def user_already_in_game_to_wait_game_in_matchmaking( self, user_id: int, /, ) -> None: ... diff --git a/src/ttt/application/matchmaking_queue/common/matchmaking_queue_views.py b/src/ttt/application/matchmaking/common/matchmaking_views.py similarity index 85% rename from src/ttt/application/matchmaking_queue/common/matchmaking_queue_views.py rename to src/ttt/application/matchmaking/common/matchmaking_views.py index b525bde..f3aa770 100644 --- a/src/ttt/application/matchmaking_queue/common/matchmaking_queue_views.py +++ b/src/ttt/application/matchmaking/common/matchmaking_views.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -class CommonMatchmakingQueueViews(ABC): +class CommonMatchmakingViews(ABC): @abstractmethod async def waiting_for_game_view(self, user_id: int, /) -> None: ... diff --git a/src/ttt/application/matchmaking/common/shared_matchmaking.py b/src/ttt/application/matchmaking/common/shared_matchmaking.py new file mode 100644 index 0000000..63aaf5b --- /dev/null +++ b/src/ttt/application/matchmaking/common/shared_matchmaking.py @@ -0,0 +1,9 @@ +from abc import ABC +from collections.abc import Awaitable + +from ttt.entities.core.matchmaking.matchmaking import ( + Matchmaking, +) + + +class SharedMatchmaking(ABC, Awaitable[Matchmaking]): ... diff --git a/src/ttt/application/matchmaking_queue/game/__init__.py b/src/ttt/application/matchmaking/game/__init__.py similarity index 100% rename from src/ttt/application/matchmaking_queue/game/__init__.py rename to src/ttt/application/matchmaking/game/__init__.py diff --git a/src/ttt/application/matchmaking_queue/game/wait_game.py b/src/ttt/application/matchmaking/game/wait_game.py similarity index 71% rename from src/ttt/application/matchmaking_queue/game/wait_game.py rename to src/ttt/application/matchmaking/game/wait_game.py index 19db28a..057224b 100644 --- a/src/ttt/application/matchmaking_queue/game/wait_game.py +++ b/src/ttt/application/matchmaking/game/wait_game.py @@ -8,18 +8,18 @@ from ttt.application.game.game.ports.game_log import GameLog from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games -from ttt.application.matchmaking_queue.common.matchmaking_queue_log import ( - CommonMatchmakingQueueLog, +from ttt.application.matchmaking.common.matchmaking_log import ( + CommonMatchmakingLog, ) -from ttt.application.matchmaking_queue.common.matchmaking_queue_views import ( - CommonMatchmakingQueueViews, +from ttt.application.matchmaking.common.matchmaking_views import ( + CommonMatchmakingViews, ) -from ttt.application.matchmaking_queue.common.shared_matchmaking_queue import ( - SharedMatchmakingQueue, +from ttt.application.matchmaking.common.shared_matchmaking import ( + SharedMatchmaking, ) from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( +from ttt.entities.core.matchmaking.matchmaking import ( UserAlreadyWaitingForGameError, ) from ttt.entities.core.user.user import UserAlreadyInGameError @@ -38,9 +38,9 @@ class WaitGame: games: Games game_views: GameViews game_log: GameLog - shared_matchmaking_queue: SharedMatchmakingQueue - matchmaking_queue_views: CommonMatchmakingQueueViews - matchmaking_queue_log: CommonMatchmakingQueueLog + shared_matchmaking: SharedMatchmaking + matchmaking_views: CommonMatchmakingViews + matchmaking_log: CommonMatchmakingLog async def __call__(self, user_id: int) -> None: async with self.transaction: @@ -50,7 +50,7 @@ async def __call__(self, user_id: int) -> None: await self.user_views.user_is_not_registered_view(user_id) return - matchmaking_queue = await self.shared_matchmaking_queue + matchmaking = await self.shared_matchmaking user_waiting_id = await self.uuids.random_uuid() game_id = await self.uuids.random_uuid() cell_id_matrix = await self.uuids.random_uuid_matrix((3, 3)) @@ -60,7 +60,7 @@ async def __call__(self, user_id: int) -> None: try: tracking = Tracking() - game = matchmaking_queue.add_user( + game = matchmaking.wait_game( user, user_waiting_id, cell_id_matrix, @@ -71,24 +71,24 @@ async def __call__(self, user_id: int) -> None: tracking, ) except UserAlreadyWaitingForGameError: - await self.matchmaking_queue_log.double_waiting_for_game_start( + await self.matchmaking_log.double_waiting_for_game_start( user_id, ) - await self.matchmaking_queue_views.double_waiting_for_game_view( + await self.matchmaking_views.double_waiting_for_game_view( user_id, ) except UserAlreadyInGameError: await ( - self.matchmaking_queue_log - .user_already_in_game_to_add_to_matchmaking_queue(user_id) + self.matchmaking_log + .user_already_in_game_to_wait_game_in_matchmaking(user_id) ) await self.game_views.user_already_in_game_view(user_id) else: if game is None: - await self.matchmaking_queue_log.waiting_for_game_start( + await self.matchmaking_log.waiting_for_game_start( user_id, ) - await self.matchmaking_queue_views.waiting_for_game_view( + await self.matchmaking_views.waiting_for_game_view( user_id, ) await self.map_(tracking) diff --git a/src/ttt/application/matchmaking_queue/common/shared_matchmaking_queue.py b/src/ttt/application/matchmaking_queue/common/shared_matchmaking_queue.py deleted file mode 100644 index cf60682..0000000 --- a/src/ttt/application/matchmaking_queue/common/shared_matchmaking_queue.py +++ /dev/null @@ -1,9 +0,0 @@ -from abc import ABC -from collections.abc import Awaitable - -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - MatchmakingQueue, -) - - -class SharedMatchmakingQueue(ABC, Awaitable[MatchmakingQueue]): ... diff --git a/src/ttt/entities/atomic.py b/src/ttt/entities/atomic.py index c3d9207..41e297a 100644 --- a/src/ttt/entities/atomic.py +++ b/src/ttt/entities/atomic.py @@ -2,8 +2,8 @@ from ttt.entities.core.invitation_to_game.invitation_to_game import ( InvitationToGameAtomic, ) -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - MatchmakingQueueAtomic, +from ttt.entities.core.matchmaking.matchmaking import ( + MatchmakingAtomic, ) from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchaseAtomic from ttt.entities.core.user.user import UserAtomic @@ -14,7 +14,7 @@ GameAtomic | UserAtomic | StarsPurchaseAtomic - | MatchmakingQueueAtomic + | MatchmakingAtomic | InvitationToGameAtomic | PaymentAtomic ) diff --git a/src/ttt/entities/core/matchmaking_queue/__init__.py b/src/ttt/entities/core/matchmaking/__init__.py similarity index 100% rename from src/ttt/entities/core/matchmaking_queue/__init__.py rename to src/ttt/entities/core/matchmaking/__init__.py diff --git a/src/ttt/entities/core/matchmaking_queue/matchmaking_queue.py b/src/ttt/entities/core/matchmaking/matchmaking.py similarity index 90% rename from src/ttt/entities/core/matchmaking_queue/matchmaking_queue.py rename to src/ttt/entities/core/matchmaking/matchmaking.py index f928d52..0c8591e 100644 --- a/src/ttt/entities/core/matchmaking_queue/matchmaking_queue.py +++ b/src/ttt/entities/core/matchmaking/matchmaking.py @@ -4,7 +4,7 @@ from uuid import UUID from ttt.entities.core.game.game import Game, start_game -from ttt.entities.core.matchmaking_queue.user_waiting import UserWaiting +from ttt.entities.core.matchmaking.user_waiting import UserWaiting from ttt.entities.core.user.rank import are_ranks_adjacent from ttt.entities.core.user.user import User, UserAlreadyInGameError from ttt.entities.math.matrix import Matrix @@ -17,14 +17,14 @@ class UserAlreadyWaitingForGameError(Exception): ... @dataclass -class MatchmakingQueue: +class Matchmaking: user_waitings: list[UserWaiting] def __contains__(self, user: User) -> bool: waiting_user_ids = (waiting.user.id for waiting in self.user_waitings) return user.id in waiting_user_ids - def add_user( # noqa: PLR0913, PLR0917 + def wait_game( # noqa: PLR0913, PLR0917 self, user: User, user_waiting_id: UUID, @@ -36,7 +36,7 @@ def add_user( # noqa: PLR0913, PLR0917 tracking: Tracking, ) -> Game | None: """ - :raises ttt.entities.core.matchmaking_queue.matchmaking_queue.UserAlreadyWaitingForGameError: + :raises ttt.entities.core.matchmaking.matchmaking.UserAlreadyWaitingForGameError: :raises ttt.entities.core.game.game.UserAlreadyInGameError: :raises ttt.entities.core.game.game.SameRandomEmojiError: :raises ttt.entities.core.game.board.InvalidCellIDMatrixError: @@ -85,4 +85,4 @@ def _is_game_allowed( return rank1 == rank2 or are_ranks_adjacent(rank1, rank2) -MatchmakingQueueAtomic = MatchmakingQueue | UserWaiting +MatchmakingAtomic = Matchmaking | UserWaiting diff --git a/src/ttt/entities/core/matchmaking_queue/user_waiting.py b/src/ttt/entities/core/matchmaking/user_waiting.py similarity index 100% rename from src/ttt/entities/core/matchmaking_queue/user_waiting.py rename to src/ttt/entities/core/matchmaking/user_waiting.py diff --git a/src/ttt/infrastructure/adapters/matchmaking_queue_log.py b/src/ttt/infrastructure/adapters/matchmaking_log.py similarity index 70% rename from src/ttt/infrastructure/adapters/matchmaking_queue_log.py rename to src/ttt/infrastructure/adapters/matchmaking_log.py index 221183f..e4f0cd2 100644 --- a/src/ttt/infrastructure/adapters/matchmaking_queue_log.py +++ b/src/ttt/infrastructure/adapters/matchmaking_log.py @@ -2,13 +2,13 @@ from structlog.types import FilteringBoundLogger -from ttt.application.matchmaking_queue.common.matchmaking_queue_log import ( - CommonMatchmakingQueueLog, +from ttt.application.matchmaking.common.matchmaking_log import ( + CommonMatchmakingLog, ) @dataclass(frozen=True, unsafe_hash=False) -class StructlogCommonMatchmakingQueueLog(CommonMatchmakingQueueLog): +class StructlogCommonMatchmakingLog(CommonMatchmakingLog): _logger: FilteringBoundLogger async def waiting_for_game_start( @@ -31,12 +31,12 @@ async def double_waiting_for_game_start( user_id=user_id, ) - async def user_already_in_game_to_add_to_matchmaking_queue( + async def user_already_in_game_to_wait_game_in_matchmaking( self, user_id: int, /, ) -> None: await self._logger.ainfo( - "user_already_in_game_to_add_to_matchmaking_queue", + "user_already_in_game_to_wait_game_in_matchmaking", user_id=user_id, ) diff --git a/src/ttt/infrastructure/adapters/shared_matchmaking.py b/src/ttt/infrastructure/adapters/shared_matchmaking.py new file mode 100644 index 0000000..4e1c029 --- /dev/null +++ b/src/ttt/infrastructure/adapters/shared_matchmaking.py @@ -0,0 +1,31 @@ +from collections.abc import Generator +from dataclasses import dataclass +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.application.matchmaking.common.shared_matchmaking import ( + SharedMatchmaking, +) +from ttt.entities.core.matchmaking.matchmaking import ( + Matchmaking, +) +from ttt.infrastructure.sqlalchemy.tables.matchmaking import ( + TableUserWaiting, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class InPostgresSharedMatchmaking(SharedMatchmaking): + _session: AsyncSession + + def __await__(self) -> Generator[Any, Any, Matchmaking]: + return self._matchmaking().__await__() + + async def _matchmaking(self) -> Matchmaking: + stmt = select(TableUserWaiting).with_for_update() + result = await self._session.scalars(stmt) + table_waitings = result.all() + + return Matchmaking([it.entity() for it in table_waitings]) diff --git a/src/ttt/infrastructure/adapters/shared_matchmaking_queue.py b/src/ttt/infrastructure/adapters/shared_matchmaking_queue.py deleted file mode 100644 index f91045a..0000000 --- a/src/ttt/infrastructure/adapters/shared_matchmaking_queue.py +++ /dev/null @@ -1,31 +0,0 @@ -from collections.abc import Generator -from dataclasses import dataclass -from typing import Any - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from ttt.application.matchmaking_queue.common.shared_matchmaking_queue import ( - SharedMatchmakingQueue, -) -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - MatchmakingQueue, -) -from ttt.infrastructure.sqlalchemy.tables.matchmaking_queue import ( - TableUserWaiting, -) - - -@dataclass(frozen=True, unsafe_hash=False) -class InPostgresSharedMatchmakingQueue(SharedMatchmakingQueue): - _session: AsyncSession - - def __await__(self) -> Generator[Any, Any, MatchmakingQueue]: - return self._matchmaking_queue().__await__() - - async def _matchmaking_queue(self) -> MatchmakingQueue: - stmt = select(TableUserWaiting).with_for_update() - result = await self._session.scalars(stmt) - table_waitings = result.all() - - return MatchmakingQueue([it.entity() for it in table_waitings]) diff --git a/src/ttt/infrastructure/alembic/versions/051831b12f05_rename_matchmaking_queue_user_waitings_.py b/src/ttt/infrastructure/alembic/versions/051831b12f05_rename_matchmaking_queue_user_waitings_.py new file mode 100644 index 0000000..5f4665e --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/051831b12f05_rename_matchmaking_queue_user_waitings_.py @@ -0,0 +1,56 @@ +""" +rename `matchmaking_queue_user_waitings` to `matchmaking_user_waitings`. + +Revision ID: 051831b12f05 +Revises: c122fc5b8ec6 +Create Date: 2025-09-17 08:31:45.586302 + +""" + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "051831b12f05" +down_revision: str | None = "c122fc5b8ec6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.rename_table( + "matchmaking_queue_user_waitings", "matchmaking_user_waitings", + ) + op.execute(""" + ALTER INDEX ix_matchmaking_queue_user_waitings_user_id + RENAME TO ix_matchmaking_user_waitings_user_id + """) + op.execute(""" + ALTER INDEX matchmaking_queue_user_waitings_pkey + RENAME TO matchmaking_user_waitings_pkey + """) + op.execute(""" + ALTER TABLE matchmaking_user_waitings + RENAME CONSTRAINT matchmaking_queue_user_waitings_user_id_fkey + TO matchmaking_user_waitings_user_id_fkey + """) + + +def downgrade() -> None: + op.rename_table( + "matchmaking_user_waitings", "matchmaking_queue_user_waitings", + ) + op.execute(""" + ALTER INDEX ix_matchmaking_user_waitings_user_id + RENAME TO ix_matchmaking_queue_user_waitings_user_id + """) + op.execute(""" + ALTER INDEX matchmaking_user_waitings_pkey + RENAME TO matchmaking_queue_user_waitings_pkey + """) + op.execute(""" + ALTER TABLE matchmaking_queue_user_waitings + RENAME CONSTRAINT matchmaking_user_waitings_user_id_fkey + TO matchmaking_queue_user_waitings_user_id_fkey + """) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/__init__.py b/src/ttt/infrastructure/sqlalchemy/tables/__init__.py index 288d38a..525d193 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/__init__.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/__init__.py @@ -3,8 +3,8 @@ from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( TableInvitationToGame, ) -from ttt.infrastructure.sqlalchemy.tables.matchmaking_queue import ( - TableMatchmakingQueue, +from ttt.infrastructure.sqlalchemy.tables.matchmaking import ( + TableMatchmaking, ) from ttt.infrastructure.sqlalchemy.tables.payment import TablePayment from ttt.infrastructure.sqlalchemy.tables.stars_purchase import ( diff --git a/src/ttt/infrastructure/sqlalchemy/tables/atomic.py b/src/ttt/infrastructure/sqlalchemy/tables/atomic.py index a12e4a4..f219fc2 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/atomic.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/atomic.py @@ -7,8 +7,8 @@ from ttt.entities.core.invitation_to_game.invitation_to_game import ( InvitationToGameAtomic, ) -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - MatchmakingQueueAtomic, +from ttt.entities.core.matchmaking.matchmaking import ( + MatchmakingAtomic, ) from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchaseAtomic from ttt.entities.core.user.user import UserAtomic @@ -23,9 +23,9 @@ TableInvitationToGameAtomic, table_invitation_to_game_atomic, ) -from ttt.infrastructure.sqlalchemy.tables.matchmaking_queue import ( - TableMatchmakingQueueAtomic, - table_matchmaking_queue_atomic, +from ttt.infrastructure.sqlalchemy.tables.matchmaking import ( + TableMatchmakingAtomic, + table_matchmaking_atomic, ) from ttt.infrastructure.sqlalchemy.tables.payment import ( TablePaymentAtomic, @@ -45,7 +45,7 @@ TableUserAtomic | TableStarsPurchaseAtomic | TableGameAtomic - | TableMatchmakingQueueAtomic + | TableMatchmakingAtomic | TableInvitationToGameAtomic | TablePaymentAtomic ) @@ -61,8 +61,8 @@ def mapped_table_atomic(entity: Atomic) -> TableAtomic: # noqa: RET503 if isinstance(entity, PaymentAtomic): return table_payment_atomic(entity) - if isinstance(entity, MatchmakingQueueAtomic): - return table_matchmaking_queue_atomic(entity) + if isinstance(entity, MatchmakingAtomic): + return table_matchmaking_atomic(entity) if isinstance(entity, InvitationToGameAtomic): return table_invitation_to_game_atomic(entity) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/matchmaking_queue.py b/src/ttt/infrastructure/sqlalchemy/tables/matchmaking.py similarity index 70% rename from src/ttt/infrastructure/sqlalchemy/tables/matchmaking_queue.py rename to src/ttt/infrastructure/sqlalchemy/tables/matchmaking.py index 598eb6c..55a7a92 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/matchmaking_queue.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/matchmaking.py @@ -4,17 +4,17 @@ from sqlalchemy import ForeignKey from sqlalchemy.orm import Mapped, mapped_column, relationship -from ttt.entities.core.matchmaking_queue.matchmaking_queue import ( - MatchmakingQueue, - MatchmakingQueueAtomic, +from ttt.entities.core.matchmaking.matchmaking import ( + Matchmaking, + MatchmakingAtomic, ) -from ttt.entities.core.matchmaking_queue.user_waiting import UserWaiting +from ttt.entities.core.matchmaking.user_waiting import UserWaiting from ttt.infrastructure.sqlalchemy.tables.common import Base from ttt.infrastructure.sqlalchemy.tables.user import TableUser class TableUserWaiting(Base[UserWaiting]): - __tablename__ = "matchmaking_queue_user_waitings" + __tablename__ = "matchmaking_user_waitings" id: Mapped[UUID] = mapped_column(primary_key=True) start_datetime: Mapped[datetime] @@ -45,15 +45,15 @@ def of(cls, it: UserWaiting) -> "TableUserWaiting": ) -type TableMatchmakingQueue = None -type TableMatchmakingQueueAtomic = TableMatchmakingQueue | TableUserWaiting +type TableMatchmaking = None +type TableMatchmakingAtomic = TableMatchmaking | TableUserWaiting -def table_matchmaking_queue_atomic( - entity: MatchmakingQueueAtomic, -) -> TableMatchmakingQueueAtomic: +def table_matchmaking_atomic( + entity: MatchmakingAtomic, +) -> TableMatchmakingAtomic: match entity: - case MatchmakingQueue(): + case Matchmaking(): return None case UserWaiting(): diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 2262454..f72202e 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -30,11 +30,11 @@ from ttt.application.invitation_to_game.game.ports.invitations_to_game import ( InvitationsToGame, ) -from ttt.application.matchmaking_queue.common.matchmaking_queue_log import ( - CommonMatchmakingQueueLog, +from ttt.application.matchmaking.common.matchmaking_log import ( + CommonMatchmakingLog, ) -from ttt.application.matchmaking_queue.common.shared_matchmaking_queue import ( - SharedMatchmakingQueue, +from ttt.application.matchmaking.common.shared_matchmaking import ( + SharedMatchmaking, ) from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 PaidStarsPurchasePaymentInbox, @@ -72,8 +72,8 @@ InPostgresInvitationsToGame, ) from ttt.infrastructure.adapters.map import MapToPostgres -from ttt.infrastructure.adapters.matchmaking_queue_log import ( - StructlogCommonMatchmakingQueueLog, +from ttt.infrastructure.adapters.matchmaking_log import ( + StructlogCommonMatchmakingLog, ) from ttt.infrastructure.adapters.original_admin_token import ( TokenAsOriginalAdminToken, @@ -82,8 +82,8 @@ InNatsPaidStarsPurchasePaymentInbox, ) from ttt.infrastructure.adapters.randoms import MersenneTwisterRandoms -from ttt.infrastructure.adapters.shared_matchmaking_queue import ( - InPostgresSharedMatchmakingQueue, +from ttt.infrastructure.adapters.shared_matchmaking import ( + InPostgresSharedMatchmaking, ) from ttt.infrastructure.adapters.stars_purchase_log import ( StructlogStarsPurchaseLog, @@ -246,9 +246,9 @@ def provide_logger( scope=Scope.REQUEST, ) - provide_shared_matchmaking_queue = provide( - InPostgresSharedMatchmakingQueue, - provides=SharedMatchmakingQueue, + provide_shared_matchmaking = provide( + InPostgresSharedMatchmaking, + provides=SharedMatchmaking, scope=Scope.REQUEST, ) @@ -328,9 +328,9 @@ def provide_randoms(self) -> Randoms: scope=Scope.REQUEST, ) - provide_common_matchmaking_queue_log = provide( - StructlogCommonMatchmakingQueueLog, - provides=CommonMatchmakingQueueLog, + provide_common_matchmaking_log = provide( + StructlogCommonMatchmakingLog, + provides=CommonMatchmakingLog, scope=Scope.REQUEST, ) diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index 3efbde6..c570313 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -58,10 +58,10 @@ from ttt.application.invitation_to_game.game.view_outcoming_invitations_to_game import ( # noqa: E501 ViewOutcomingInvitationsToGame, ) -from ttt.application.matchmaking_queue.common.matchmaking_queue_views import ( - CommonMatchmakingQueueViews, +from ttt.application.matchmaking.common.matchmaking_views import ( + CommonMatchmakingViews, ) -from ttt.application.matchmaking_queue.game.wait_game import WaitGame +from ttt.application.matchmaking.game.wait_game import WaitGame from ttt.application.stars_purchase.complete_stars_purchase_payment import ( CompleteStarsPurchasePayment, ) @@ -125,8 +125,8 @@ from ttt.presentation.adapters.invitation_to_game_views import ( AiogramInvitationToGameViews, ) -from ttt.presentation.adapters.matchmaking_queue_views import ( - AiogramCommonMatchmakingQueueViews, +from ttt.presentation.adapters.matchmaking_views import ( + AiogramCommonMatchmakingViews, ) from ttt.presentation.adapters.stars_purchase_payment_gateway import ( AiogramPaymentGateway, @@ -229,9 +229,9 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: provides=EmojiPurchaseUserViews, scope=Scope.REQUEST, ) - provide_common_matchmaking_queue_views = provide( - AiogramCommonMatchmakingQueueViews, - provides=CommonMatchmakingQueueViews, + provide_common_matchmaking_views = provide( + AiogramCommonMatchmakingViews, + provides=CommonMatchmakingViews, scope=Scope.REQUEST, ) provide_change_other_user_account_views = provide( diff --git a/src/ttt/presentation/adapters/matchmaking_queue_views.py b/src/ttt/presentation/adapters/matchmaking_views.py similarity index 84% rename from src/ttt/presentation/adapters/matchmaking_queue_views.py rename to src/ttt/presentation/adapters/matchmaking_views.py index f211250..9163ccb 100644 --- a/src/ttt/presentation/adapters/matchmaking_queue_views.py +++ b/src/ttt/presentation/adapters/matchmaking_views.py @@ -2,8 +2,8 @@ from aiogram_dialog import StartMode -from ttt.application.matchmaking_queue.common.matchmaking_queue_views import ( - CommonMatchmakingQueueViews, +from ttt.application.matchmaking.common.matchmaking_views import ( + CommonMatchmakingViews, ) from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( DialogManagerForUser, @@ -12,7 +12,7 @@ @dataclass(frozen=True, unsafe_hash=False) -class AiogramCommonMatchmakingQueueViews(CommonMatchmakingQueueViews): +class AiogramCommonMatchmakingViews(CommonMatchmakingViews): _dialog_manager_for_user: DialogManagerForUser async def waiting_for_game_view(self, user_id: int, /) -> None: diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py index 405b4b6..5eb1b34 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py @@ -10,7 +10,7 @@ from dishka.integrations.aiogram_dialog import inject from magic_filter import F -from ttt.application.matchmaking_queue.game.wait_game import WaitGame +from ttt.application.matchmaking.game.wait_game import WaitGame from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, From db55176c7bdaa123259f77c9aec8e388f4ae7ce2 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Thu, 18 Sep 2025 01:02:23 +0700 Subject: [PATCH 13/45] ref(`entities`): merge `matchmaking_queue` and `user` (#58) --- deploy/dev/docker-compose.yaml | 4 + pyproject.toml | 1 + .../application/game/game/ports/game_log.py | 7 - .../matchmaking/common/matchmaking_log.py | 22 --- .../matchmaking/common/matchmaking_views.py | 9 -- .../matchmaking/common/shared_matchmaking.py | 9 -- .../application/matchmaking/game/wait_game.py | 100 ------------ .../application/user/common/ports/users.py | 5 + .../{matchmaking => user/game}/__init__.py | 0 src/ttt/application/user/game/matchmake.py | 69 ++++++++ .../common => user/game/ports}/__init__.py | 0 .../application/user/game/ports/user_log.py | 35 ++++ .../application/user/game/ports/user_views.py | 28 ++++ .../user/game/wait_for_matchmaking.py | 54 +++++++ src/ttt/entities/atomic.py | 4 - src/ttt/entities/core/matchmaking/__init__.py | 0 .../entities/core/matchmaking/matchmaking.py | 88 ----------- .../entities/core/matchmaking/user_waiting.py | 12 -- src/ttt/entities/core/user/matchmaking.py | 76 +++++++++ .../entities/core/user/matchmaking_waiting.py | 7 + src/ttt/entities/core/user/user.py | 53 ++++++- src/ttt/entities/tools/combinations.py | 28 ++++ src/ttt/infrastructure/adapters/game_log.py | 10 -- .../adapters/matchmaking_log.py | 42 ----- src/ttt/infrastructure/adapters/user_log.py | 55 +++++++ src/ttt/infrastructure/adapters/users.py | 15 ++ ...ef_merge_matchmaking_user_waitings_and_.py | 91 +++++++++++ .../infrastructure/pydantic_settings/envs.py | 4 + .../sqlalchemy/tables/__init__.py | 3 - .../sqlalchemy/tables/atomic.py | 12 +- .../sqlalchemy/tables/matchmaking.py | 60 ------- .../infrastructure/sqlalchemy/tables/user.py | 29 ++++ src/ttt/main/common/di.py | 47 +++--- src/ttt/main/tg_bot/di.py | 27 ++-- src/ttt/main/tg_bot/start_aiogram.py | 11 ++ .../adapters/matchmaking_views.py | 34 ---- src/ttt/presentation/adapters/user_views.py | 65 ++++++++ .../main_dialog/game_start_window.py | 6 +- src/ttt/presentation/tasks/matchmake_tasks.py | 30 ++++ .../test_entities/test_core/conftest.py | 2 + .../test_entities/test_core/test_game.py | 6 + .../test_entities/test_core/test_user.py | 1 + .../test_entities/test_tools}/__init__.py | 0 .../test_tools/test_combinations.py | 149 ++++++++++++++++++ .../test_adapters/test_game_dao.py | 1 + 45 files changed, 853 insertions(+), 458 deletions(-) delete mode 100644 src/ttt/application/matchmaking/common/matchmaking_log.py delete mode 100644 src/ttt/application/matchmaking/common/matchmaking_views.py delete mode 100644 src/ttt/application/matchmaking/common/shared_matchmaking.py delete mode 100644 src/ttt/application/matchmaking/game/wait_game.py rename src/ttt/application/{matchmaking => user/game}/__init__.py (100%) create mode 100644 src/ttt/application/user/game/matchmake.py rename src/ttt/application/{matchmaking/common => user/game/ports}/__init__.py (100%) create mode 100644 src/ttt/application/user/game/ports/user_log.py create mode 100644 src/ttt/application/user/game/ports/user_views.py create mode 100644 src/ttt/application/user/game/wait_for_matchmaking.py delete mode 100644 src/ttt/entities/core/matchmaking/__init__.py delete mode 100644 src/ttt/entities/core/matchmaking/matchmaking.py delete mode 100644 src/ttt/entities/core/matchmaking/user_waiting.py create mode 100644 src/ttt/entities/core/user/matchmaking.py create mode 100644 src/ttt/entities/core/user/matchmaking_waiting.py create mode 100644 src/ttt/entities/tools/combinations.py delete mode 100644 src/ttt/infrastructure/adapters/matchmaking_log.py create mode 100644 src/ttt/infrastructure/alembic/versions/ba0f7132baef_merge_matchmaking_user_waitings_and_.py delete mode 100644 src/ttt/infrastructure/sqlalchemy/tables/matchmaking.py delete mode 100644 src/ttt/presentation/adapters/matchmaking_views.py create mode 100644 src/ttt/presentation/tasks/matchmake_tasks.py rename {src/ttt/application/matchmaking/game => tests/test_ttt/test_entities/test_tools}/__init__.py (100%) create mode 100644 tests/test_ttt/test_entities/test_tools/test_combinations.py diff --git a/deploy/dev/docker-compose.yaml b/deploy/dev/docker-compose.yaml index b7c39e4..eda80d3 100644 --- a/deploy/dev/docker-compose.yaml +++ b/deploy/dev/docker-compose.yaml @@ -35,6 +35,10 @@ services: TTT_NATS_URL: nats://nats:4222 TTT_GEMINI_URL: https://my-openai-gemini-sigma-sandy.vercel.app + + TTT_MATCHMAKING_MAX_WORKERS: 4 + TTT_MATCHMAKING_WORKER_MAX_USERS: 100 + TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5 secrets: - secrets command: ttt-dev diff --git a/pyproject.toml b/pyproject.toml index 6d18112..1a53610 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ ignore = [ "EM101", "N807", "FURB118", + "DOC402", ] [tool.ruff.lint.per-file-ignores] diff --git a/src/ttt/application/game/game/ports/game_log.py b/src/ttt/application/game/game/ports/game_log.py index 6cf067f..8f15964 100644 --- a/src/ttt/application/game/game/ports/game_log.py +++ b/src/ttt/application/game/game/ports/game_log.py @@ -6,13 +6,6 @@ class GameLog(ABC): - @abstractmethod - async def game_against_user_started( - self, - game: Game, - /, - ) -> None: ... - @abstractmethod async def game_against_ai_started( self, diff --git a/src/ttt/application/matchmaking/common/matchmaking_log.py b/src/ttt/application/matchmaking/common/matchmaking_log.py deleted file mode 100644 index dcafc4e..0000000 --- a/src/ttt/application/matchmaking/common/matchmaking_log.py +++ /dev/null @@ -1,22 +0,0 @@ -from abc import ABC, abstractmethod - - -class CommonMatchmakingLog(ABC): - @abstractmethod - async def waiting_for_game_start( - self, - user_id: int, - /, - ) -> None: ... - - @abstractmethod - async def double_waiting_for_game_start( - self, - user_id: int, - /, - ) -> None: ... - - @abstractmethod - async def user_already_in_game_to_wait_game_in_matchmaking( - self, user_id: int, /, - ) -> None: ... diff --git a/src/ttt/application/matchmaking/common/matchmaking_views.py b/src/ttt/application/matchmaking/common/matchmaking_views.py deleted file mode 100644 index f3aa770..0000000 --- a/src/ttt/application/matchmaking/common/matchmaking_views.py +++ /dev/null @@ -1,9 +0,0 @@ -from abc import ABC, abstractmethod - - -class CommonMatchmakingViews(ABC): - @abstractmethod - async def waiting_for_game_view(self, user_id: int, /) -> None: ... - - @abstractmethod - async def double_waiting_for_game_view(self, user_id: int, /) -> None: ... diff --git a/src/ttt/application/matchmaking/common/shared_matchmaking.py b/src/ttt/application/matchmaking/common/shared_matchmaking.py deleted file mode 100644 index 63aaf5b..0000000 --- a/src/ttt/application/matchmaking/common/shared_matchmaking.py +++ /dev/null @@ -1,9 +0,0 @@ -from abc import ABC -from collections.abc import Awaitable - -from ttt.entities.core.matchmaking.matchmaking import ( - Matchmaking, -) - - -class SharedMatchmaking(ABC, Awaitable[Matchmaking]): ... diff --git a/src/ttt/application/matchmaking/game/wait_game.py b/src/ttt/application/matchmaking/game/wait_game.py deleted file mode 100644 index 057224b..0000000 --- a/src/ttt/application/matchmaking/game/wait_game.py +++ /dev/null @@ -1,100 +0,0 @@ -from dataclasses import dataclass - -from ttt.application.common.ports.clock import Clock -from ttt.application.common.ports.emojis import Emojis -from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction -from ttt.application.common.ports.uuids import UUIDs -from ttt.application.game.game.ports.game_log import GameLog -from ttt.application.game.game.ports.game_views import GameViews -from ttt.application.game.game.ports.games import Games -from ttt.application.matchmaking.common.matchmaking_log import ( - CommonMatchmakingLog, -) -from ttt.application.matchmaking.common.matchmaking_views import ( - CommonMatchmakingViews, -) -from ttt.application.matchmaking.common.shared_matchmaking import ( - SharedMatchmaking, -) -from ttt.application.user.common.ports.user_views import CommonUserViews -from ttt.application.user.common.ports.users import Users -from ttt.entities.core.matchmaking.matchmaking import ( - UserAlreadyWaitingForGameError, -) -from ttt.entities.core.user.user import UserAlreadyInGameError -from ttt.entities.tools.tracking import Tracking - - -@dataclass(frozen=True, unsafe_hash=False) -class WaitGame: - map_: Map - uuids: UUIDs - emojis: Emojis - transaction: Transaction - clock: Clock - users: Users - user_views: CommonUserViews - games: Games - game_views: GameViews - game_log: GameLog - shared_matchmaking: SharedMatchmaking - matchmaking_views: CommonMatchmakingViews - matchmaking_log: CommonMatchmakingLog - - async def __call__(self, user_id: int) -> None: - async with self.transaction: - user = await self.users.user_with_id(user_id) - - if user is None: - await self.user_views.user_is_not_registered_view(user_id) - return - - matchmaking = await self.shared_matchmaking - user_waiting_id = await self.uuids.random_uuid() - game_id = await self.uuids.random_uuid() - cell_id_matrix = await self.uuids.random_uuid_matrix((3, 3)) - user1_emoji = await self.emojis.random_emoji() - user2_emoji = await self.emojis.random_emoji() - current_datetime = await self.clock.current_datetime() - - try: - tracking = Tracking() - game = matchmaking.wait_game( - user, - user_waiting_id, - cell_id_matrix, - game_id, - user1_emoji, - user2_emoji, - current_datetime, - tracking, - ) - except UserAlreadyWaitingForGameError: - await self.matchmaking_log.double_waiting_for_game_start( - user_id, - ) - await self.matchmaking_views.double_waiting_for_game_view( - user_id, - ) - except UserAlreadyInGameError: - await ( - self.matchmaking_log - .user_already_in_game_to_wait_game_in_matchmaking(user_id) - ) - await self.game_views.user_already_in_game_view(user_id) - else: - if game is None: - await self.matchmaking_log.waiting_for_game_start( - user_id, - ) - await self.matchmaking_views.waiting_for_game_view( - user_id, - ) - await self.map_(tracking) - return - - await self.game_log.game_against_user_started(game) - await self.map_(tracking) - - await self.game_views.started_game_view(game) diff --git a/src/ttt/application/user/common/ports/users.py b/src/ttt/application/user/common/ports/users.py index 35d470b..9c1a972 100644 --- a/src/ttt/application/user/common/ports/users.py +++ b/src/ttt/application/user/common/ports/users.py @@ -38,3 +38,8 @@ async def users_with_ids( ids: Sequence[int], /, ) -> tuple[User | None, ...]: ... + + @abstractmethod + async def some_users_waiting_for_matchmaking_to_matchmake( + self, + ) -> list[User]: ... diff --git a/src/ttt/application/matchmaking/__init__.py b/src/ttt/application/user/game/__init__.py similarity index 100% rename from src/ttt/application/matchmaking/__init__.py rename to src/ttt/application/user/game/__init__.py diff --git a/src/ttt/application/user/game/matchmake.py b/src/ttt/application/user/game/matchmake.py new file mode 100644 index 0000000..7d5f7ae --- /dev/null +++ b/src/ttt/application/user/game/matchmake.py @@ -0,0 +1,69 @@ +from asyncio import gather +from dataclasses import dataclass + +from ttt.application.common.ports.emojis import Emojis +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.uuids import UUIDs +from ttt.application.user.common.ports.users import Users +from ttt.application.user.game.ports.user_log import GameUserLog +from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.entities.core.game.game import Game +from ttt.entities.core.user.matchmaking import MatchmakingInput, matchmaking +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class Matchmake: + map_: Map + transaction: Transaction + users: Users + log: GameUserLog + views: GameUserViews + uuids: UUIDs + emojis: Emojis + + async def __call__(self) -> None: + async with self.transaction: + tracking = Tracking() + games = await self._result(tracking) + + await self.log.games_were_matched(games) + await gather( + self.views.matched_games_view(games), + self.map_(tracking), + ) + + async def _result(self, tracking: Tracking) -> list[Game]: + users, input_ = await gather( + self.users.some_users_waiting_for_matchmaking_to_matchmake(), + self._matchmaking_input(), + ) + + games = list[Game]() + matchmaking_ = matchmaking(users, input_, tracking) + + try: + while True: + games.append(matchmaking_.send(await self._matchmaking_input())) + except StopIteration: + return games + + async def _matchmaking_input(self) -> MatchmakingInput: + ( + cell_id_matrix, + game_id, + player1_random_emoji, + player2_random_emoji, + ) = await gather( + self.uuids.random_uuid_matrix((3, 3)), + self.uuids.random_uuid(), + self.emojis.random_emoji(), + self.emojis.random_emoji(), + ) + return MatchmakingInput( + cell_id_matrix, + game_id, + player1_random_emoji, + player2_random_emoji, + ) diff --git a/src/ttt/application/matchmaking/common/__init__.py b/src/ttt/application/user/game/ports/__init__.py similarity index 100% rename from src/ttt/application/matchmaking/common/__init__.py rename to src/ttt/application/user/game/ports/__init__.py diff --git a/src/ttt/application/user/game/ports/user_log.py b/src/ttt/application/user/game/ports/user_log.py new file mode 100644 index 0000000..da59d6e --- /dev/null +++ b/src/ttt/application/user/game/ports/user_log.py @@ -0,0 +1,35 @@ +from abc import ABC, abstractmethod + +from ttt.entities.core.game.game import Game +from ttt.entities.core.user.user import User + + +class GameUserLog(ABC): + @abstractmethod + async def user_is_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_is_not_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_is_already_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_is_in_game_to_wait_for_matchmaking( + self, user: User, /, + ) -> None: ... + + @abstractmethod + async def games_were_matched(self, games: list[Game], /) -> None: ... diff --git a/src/ttt/application/user/game/ports/user_views.py b/src/ttt/application/user/game/ports/user_views.py new file mode 100644 index 0000000..237ad0e --- /dev/null +++ b/src/ttt/application/user/game/ports/user_views.py @@ -0,0 +1,28 @@ +from abc import ABC, abstractmethod + +from ttt.entities.core.game.game import Game +from ttt.entities.core.user.user import User + + +class GameUserViews(ABC): + @abstractmethod + async def user_is_waiting_for_matchmaking_view( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_is_already_waiting_for_matchmaking_view( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_is_in_game_to_wait_for_matchmaking_view( + self, user: User, /, + ) -> None: ... + + @abstractmethod + async def matched_games_view(self, games: list[Game], /) -> None: ... diff --git a/src/ttt/application/user/game/wait_for_matchmaking.py b/src/ttt/application/user/game/wait_for_matchmaking.py new file mode 100644 index 0000000..b1d8e91 --- /dev/null +++ b/src/ttt/application/user/game/wait_for_matchmaking.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.clock import Clock +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import Transaction +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users +from ttt.application.user.game.ports.user_log import GameUserLog +from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.entities.core.user.user import ( + UserAlreadyWaitingForMatchmakingError, + UserIsInGameError, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class WaitForMatchmaking: + map_: Map + transaction: Transaction + clock: Clock + users: Users + user_views: CommonUserViews + views: GameUserViews + log: GameUserLog + + async def __call__(self, user_id: int) -> None: + async with self.transaction: + user = await self.users.user_with_id(user_id) + + if user is None: + await self.user_views.user_is_not_registered_view(user_id) + return + + current_datetime = await self.clock.current_datetime() + + try: + tracking = Tracking() + user.wait_for_matchmaking(current_datetime, tracking) + except UserAlreadyWaitingForMatchmakingError: + await self.log.user_is_already_waiting_for_matchmaking(user) + await self.views.user_is_already_waiting_for_matchmaking_view( + user, + ) + except UserIsInGameError: + await self.log.user_is_in_game_to_wait_for_matchmaking(user) + await self.views.user_is_in_game_to_wait_for_matchmaking_view( + user, + ) + else: + await self.log.user_is_waiting_for_matchmaking(user) + await self.map_(tracking) + + await self.views.user_is_waiting_for_matchmaking_view(user) diff --git a/src/ttt/entities/atomic.py b/src/ttt/entities/atomic.py index 41e297a..abe044a 100644 --- a/src/ttt/entities/atomic.py +++ b/src/ttt/entities/atomic.py @@ -2,9 +2,6 @@ from ttt.entities.core.invitation_to_game.invitation_to_game import ( InvitationToGameAtomic, ) -from ttt.entities.core.matchmaking.matchmaking import ( - MatchmakingAtomic, -) from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchaseAtomic from ttt.entities.core.user.user import UserAtomic from ttt.entities.finance.payment.payment import PaymentAtomic @@ -14,7 +11,6 @@ GameAtomic | UserAtomic | StarsPurchaseAtomic - | MatchmakingAtomic | InvitationToGameAtomic | PaymentAtomic ) diff --git a/src/ttt/entities/core/matchmaking/__init__.py b/src/ttt/entities/core/matchmaking/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ttt/entities/core/matchmaking/matchmaking.py b/src/ttt/entities/core/matchmaking/matchmaking.py deleted file mode 100644 index 0c8591e..0000000 --- a/src/ttt/entities/core/matchmaking/matchmaking.py +++ /dev/null @@ -1,88 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from itertools import combinations -from uuid import UUID - -from ttt.entities.core.game.game import Game, start_game -from ttt.entities.core.matchmaking.user_waiting import UserWaiting -from ttt.entities.core.user.rank import are_ranks_adjacent -from ttt.entities.core.user.user import User, UserAlreadyInGameError -from ttt.entities.math.matrix import Matrix -from ttt.entities.text.emoji import Emoji -from ttt.entities.tools.assertion import assert_ -from ttt.entities.tools.tracking import Tracking - - -class UserAlreadyWaitingForGameError(Exception): ... - - -@dataclass -class Matchmaking: - user_waitings: list[UserWaiting] - - def __contains__(self, user: User) -> bool: - waiting_user_ids = (waiting.user.id for waiting in self.user_waitings) - return user.id in waiting_user_ids - - def wait_game( # noqa: PLR0913, PLR0917 - self, - user: User, - user_waiting_id: UUID, - cell_id_matrix: Matrix[UUID], - game_id: UUID, - player1_random_emoji: Emoji, - player2_random_emoji: Emoji, - current_datetime: datetime, - tracking: Tracking, - ) -> Game | None: - """ - :raises ttt.entities.core.matchmaking.matchmaking.UserAlreadyWaitingForGameError: - :raises ttt.entities.core.game.game.UserAlreadyInGameError: - :raises ttt.entities.core.game.game.SameRandomEmojiError: - :raises ttt.entities.core.game.board.InvalidCellIDMatrixError: - """ # noqa: E501 - - assert_(user not in self, else_=UserAlreadyWaitingForGameError) - assert_(not user.is_in_game(), else_=UserAlreadyInGameError) - - user_waiting = UserWaiting( - id_=user_waiting_id, - start_datetime=current_datetime, - user=user, - ) - tracking.register_new(user_waiting) - self.user_waitings.append(user_waiting) - - for user_waiting1, user_waiting2 in combinations(self.user_waitings, 2): - if self._is_game_allowed(user_waiting1, user_waiting2): - game = start_game( - cell_id_matrix, - game_id, - user_waiting1.user, - player1_random_emoji, - user_waiting2.user, - player2_random_emoji, - tracking, - ) - - self.user_waitings.remove(user_waiting1) - self.user_waitings.remove(user_waiting2) - tracking.register_unused(user_waiting1) - tracking.register_unused(user_waiting2) - - return game - - return None - - def _is_game_allowed( - self, - user_waiting1: UserWaiting, - user_waiting2: UserWaiting, - ) -> bool: - rank1 = user_waiting1.user.rank() - rank2 = user_waiting2.user.rank() - - return rank1 == rank2 or are_ranks_adjacent(rank1, rank2) - - -MatchmakingAtomic = Matchmaking | UserWaiting diff --git a/src/ttt/entities/core/matchmaking/user_waiting.py b/src/ttt/entities/core/matchmaking/user_waiting.py deleted file mode 100644 index 19bc224..0000000 --- a/src/ttt/entities/core/matchmaking/user_waiting.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from uuid import UUID - -from ttt.entities.core.user.user import User - - -@dataclass -class UserWaiting: - id_: UUID - start_datetime: datetime - user: User diff --git a/src/ttt/entities/core/user/matchmaking.py b/src/ttt/entities/core/user/matchmaking.py new file mode 100644 index 0000000..84d308d --- /dev/null +++ b/src/ttt/entities/core/user/matchmaking.py @@ -0,0 +1,76 @@ +from collections.abc import Generator +from dataclasses import dataclass +from uuid import UUID + +from ttt.entities.core.game.game import Game, start_game +from ttt.entities.core.user.rank import are_ranks_adjacent +from ttt.entities.core.user.user import User +from ttt.entities.math.matrix import Matrix +from ttt.entities.text.emoji import Emoji +from ttt.entities.tools.combinations import Combinations +from ttt.entities.tools.tracking import Tracking + + +@dataclass +class MatchmakingInput: + cell_id_matrix: Matrix[UUID] + game_id: UUID + player1_random_emoji: Emoji + player2_random_emoji: Emoji + + +@dataclass +class UsersAreNotWaitingForMatchmakingError(Exception): + users: tuple[User, ...] + + +def matchmaking( + users: list[User], + input_: MatchmakingInput, + tracking: Tracking, +) -> Generator[Game, MatchmakingInput]: + """ + :raises ttt.entities.core.user.matchmaking.UsersAreNotWaitingForMatchmakingError + :raises ttt.entities.core.game.game.SameRandomEmojiError: + :raises ttt.entities.core.game.board.InvalidCellIDMatrixError: + """ # noqa: E501 + + users_not_waiting_for_matchmaking = tuple( + user + for user in users + if not user.is_waiting_for_matchmaking() + ) + if users_not_waiting_for_matchmaking: + raise UsersAreNotWaitingForMatchmakingError( + users_not_waiting_for_matchmaking, + ) + + for user in list(users): + if user.is_in_game(): + user.dont_wait_for_matchmaking(tracking) + users.remove(user) + + combinations = Combinations(users) + for user1, user2 in combinations: + if _is_game_allowed(user1, user2): + user1.dont_wait_for_matchmaking(tracking) + user2.dont_wait_for_matchmaking(tracking) + combinations.cut() + + game = start_game( + input_.cell_id_matrix, + input_.game_id, + user1, + input_.player1_random_emoji, + user2, + input_.player2_random_emoji, + tracking, + ) + input_ = yield game + + +def _is_game_allowed(user1: User, user2: User) -> bool: + rank1 = user1.rank() + rank2 = user2.rank() + + return rank1 == rank2 or are_ranks_adjacent(rank1, rank2) diff --git a/src/ttt/entities/core/user/matchmaking_waiting.py b/src/ttt/entities/core/user/matchmaking_waiting.py new file mode 100644 index 0000000..065e3bc --- /dev/null +++ b/src/ttt/entities/core/user/matchmaking_waiting.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class MatchmakingWaiting: + start_datetime: datetime diff --git a/src/ttt/entities/core/user/user.py b/src/ttt/entities/core/user/user.py index ee78695..ea757b7 100644 --- a/src/ttt/entities/core/user/user.py +++ b/src/ttt/entities/core/user/user.py @@ -13,6 +13,7 @@ from ttt.entities.core.user.draw import UserDraw from ttt.entities.core.user.emoji import UserEmoji from ttt.entities.core.user.loss import UserLoss +from ttt.entities.core.user.matchmaking_waiting import MatchmakingWaiting from ttt.entities.core.user.rank import Rank, rank_for_rating from ttt.entities.core.user.win import UserWin from ttt.entities.elo.rating import ( @@ -74,11 +75,21 @@ class NotAuthorizedAsAdminViaAdminTokenError(Exception): ... class UserAlredyAdminToAuthorizeAsAdminError(Exception): ... -@dataclass +class UserAlreadyWaitingForMatchmakingError(Exception): ... + + +class UserIsNotWaitingForMatchmakingError(Exception): ... + + +class UserIsInGameError(Exception): ... + + +@dataclass # noqa: PLR0904 class User: id: int account: Account emojis: list[UserEmoji] + matchmaking_waiting: MatchmakingWaiting | None selected_emoji_id: UUID | None rating: EloRating admin_right: AdminRight | None @@ -416,6 +427,45 @@ def select_emoji(self, emoji: Emoji, tracking: Tracking) -> None: tracking.register_mutated(self) + def is_waiting_for_matchmaking(self) -> bool: + return self.matchmaking_waiting is not None + + def wait_for_matchmaking( + self, + curreint_datetime: datetime, + tracking: Tracking, + ) -> "MatchmakingWaiting": + """ + :raises ttt.entities.core.user.user.UserAlreadyWaitingForMatchmakingError: + :raises ttt.entities.core.user.user.UserIsInGameError: + """ # noqa: E501 + + assert_( + not self.is_waiting_for_matchmaking(), + else_=UserAlreadyWaitingForMatchmakingError, + ) + assert_(not self.is_in_game(), else_=UserIsInGameError) + + waiting = MatchmakingWaiting(start_datetime=curreint_datetime) + + self.matchmaking_waiting = waiting + tracking.register_mutated(self) + + return waiting + + def dont_wait_for_matchmaking(self, tracking: Tracking) -> None: + """ + :raises ttt.entities.core.user.user.UserIsNotWaitingForMatchmakingError: + """ + + assert_( + self.is_waiting_for_matchmaking(), + else_=UserIsNotWaitingForMatchmakingError, + ) + + tracking.register_unused(self.matchmaking_waiting) + self.matchmaking_waiting = None + UserAtomic = User | UserEmoji @@ -429,6 +479,7 @@ def register_user(user_id: int, tracking: Tracking) -> User: rating=initial_elo_rating, current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) tracking.register_new(user) diff --git a/src/ttt/entities/tools/combinations.py b/src/ttt/entities/tools/combinations.py new file mode 100644 index 0000000..92b0299 --- /dev/null +++ b/src/ttt/entities/tools/combinations.py @@ -0,0 +1,28 @@ +from collections.abc import Iterator +from dataclasses import dataclass, field + + +@dataclass +class Combinations[V](Iterator[tuple[V, V]]): + _values: list[V] + + _index1: int = field(init=False, default=0) + _index2: int = field(init=False, default=0) + + def __next__(self) -> tuple[V, V]: + self._index2 += 1 + + if self._index2 >= len(self._values): + self._index1 += 1 + self._index2 = self._index1 + 1 + + if self._index1 >= len(self._values) - 1: + raise StopIteration + + return self._values[self._index1], self._values[self._index2] + + def cut(self) -> None: + del self._values[self._index2] + del self._values[self._index1] + + self._index2 = self._index1 diff --git a/src/ttt/infrastructure/adapters/game_log.py b/src/ttt/infrastructure/adapters/game_log.py index 190a1d8..377b998 100644 --- a/src/ttt/infrastructure/adapters/game_log.py +++ b/src/ttt/infrastructure/adapters/game_log.py @@ -12,16 +12,6 @@ class StructlogGameLog(GameLog): _logger: FilteringBoundLogger - async def game_against_user_started( - self, - game: Game, - /, - ) -> None: - await self._logger.ainfo( - "game_against_user_started", - game_id=game.id.hex, - ) - async def game_against_ai_started( self, game: Game, diff --git a/src/ttt/infrastructure/adapters/matchmaking_log.py b/src/ttt/infrastructure/adapters/matchmaking_log.py deleted file mode 100644 index e4f0cd2..0000000 --- a/src/ttt/infrastructure/adapters/matchmaking_log.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass - -from structlog.types import FilteringBoundLogger - -from ttt.application.matchmaking.common.matchmaking_log import ( - CommonMatchmakingLog, -) - - -@dataclass(frozen=True, unsafe_hash=False) -class StructlogCommonMatchmakingLog(CommonMatchmakingLog): - _logger: FilteringBoundLogger - - async def waiting_for_game_start( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "waiting_for_game_start", - user_id=user_id, - ) - - async def double_waiting_for_game_start( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "double_waiting_for_game_start", - user_id=user_id, - ) - - async def user_already_in_game_to_wait_game_in_matchmaking( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "user_already_in_game_to_wait_game_in_matchmaking", - user_id=user_id, - ) diff --git a/src/ttt/infrastructure/adapters/user_log.py b/src/ttt/infrastructure/adapters/user_log.py index a98f9ed..fff9afc 100644 --- a/src/ttt/infrastructure/adapters/user_log.py +++ b/src/ttt/infrastructure/adapters/user_log.py @@ -1,3 +1,4 @@ +from asyncio import gather from dataclasses import dataclass from structlog.types import FilteringBoundLogger @@ -12,6 +13,8 @@ from ttt.application.user.emoji_selection.ports.user_log import ( EmojiSelectionUserLog, ) +from ttt.application.user.game.ports.user_log import GameUserLog +from ttt.entities.core.game.game import Game from ttt.entities.core.stars import Stars from ttt.entities.core.user.user import User from ttt.entities.text.emoji import Emoji @@ -339,3 +342,55 @@ async def negative_account_on_set_other_user_account( other_user_id=other_user_id, other_user_account_stars=other_user_account_stars, ) + + +@dataclass(frozen=True, unsafe_hash=False) +class StructlogGameUserLog(GameUserLog): + _logger: FilteringBoundLogger + + async def user_is_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: + await self._logger.ainfo( + "user_is_waiting_for_matchmaking", + user_id=user.id, + ) + + async def user_is_not_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: + await self._logger.ainfo( + "user_is_not_waiting_for_matchmaking", + user_id=user.id, + ) + + async def user_is_already_waiting_for_matchmaking( + self, + user: User, + /, + ) -> None: + await self._logger.ainfo( + "user_is_already_waiting_for_matchmaking", + user_id=user.id, + ) + + async def user_is_in_game_to_wait_for_matchmaking( + self, user: User, /, + ) -> None: + await self._logger.ainfo( + "user_is_in_game_to_wait_for_matchmaking", + user_id=user.id, + ) + + async def games_were_matched(self, games: list[Game], /) -> None: + await gather(*map(self._game_was_matched, games)) + + async def _game_was_matched(self, game: Game) -> None: + await self._logger.ainfo( + "game_was_matched", + game_id=game.id.hex, + ) diff --git a/src/ttt/infrastructure/adapters/users.py b/src/ttt/infrastructure/adapters/users.py index d001a07..02eab7e 100644 --- a/src/ttt/infrastructure/adapters/users.py +++ b/src/ttt/infrastructure/adapters/users.py @@ -13,6 +13,7 @@ @dataclass(frozen=True, unsafe_hash=False) class InPostgresUsers(Users): _session: AsyncSession + _users_to_matchmake_limit: int async def contains_user_with_id( self, @@ -54,3 +55,17 @@ async def user_with_id(self, id_: int, /) -> User | None: table_user = await self._session.scalar(stmt) return None if table_user is None else table_user.entity() + + async def some_users_waiting_for_matchmaking_to_matchmake( + self, + ) -> list[User]: + stmt = ( + select(TableUser) + .where(TableUser.has_matchmaking_waiting) + .limit(self._users_to_matchmake_limit) + .with_for_update(skip_locked=True) + ) + result = await self._session.scalars(stmt) + table_users = result.all() + + return [table_user.entity() for table_user in table_users] diff --git a/src/ttt/infrastructure/alembic/versions/ba0f7132baef_merge_matchmaking_user_waitings_and_.py b/src/ttt/infrastructure/alembic/versions/ba0f7132baef_merge_matchmaking_user_waitings_and_.py new file mode 100644 index 0000000..51b1779 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/ba0f7132baef_merge_matchmaking_user_waitings_and_.py @@ -0,0 +1,91 @@ +""" +merge `matchmaking_user_waitings` and `users`. + +Revision ID: ba0f7132baef +Revises: 051831b12f05 +Create Date: 2025-09-17 17:37:58.210228 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "ba0f7132baef" +down_revision: str | None = "051831b12f05" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_matchmaking_user_waitings_user_id"), + table_name="matchmaking_user_waitings", + ) + op.drop_table("matchmaking_user_waitings") + op.add_column( + "users", + sa.Column( + "has_matchmaking_waiting", + sa.Boolean(), + server_default="false", + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column( + "matchmaking_waiting_start_datetime", sa.DateTime(), nullable=True, + ), + ) + op.create_index( + "ix_users_has_matchmaking_waiting", + "users", + ["has_matchmaking_waiting"], + unique=False, + postgresql_where=sa.text("has_matchmaking_waiting IS true"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "ix_users_has_matchmaking_waiting", + table_name="users", + postgresql_where=sa.text("has_matchmaking_waiting IS true"), + ) + op.drop_column("users", "matchmaking_waiting_start_datetime") + op.drop_column("users", "has_matchmaking_waiting") + op.create_table( + "matchmaking_user_waitings", + sa.Column("id", sa.UUID(), autoincrement=False, nullable=False), + sa.Column( + "start_datetime", + postgresql.TIMESTAMP(), + autoincrement=False, + nullable=False, + ), + sa.Column("user_id", sa.BIGINT(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("matchmaking_user_waitings_user_id_fkey"), + initially="DEFERRED", + deferrable=True, + ), + sa.PrimaryKeyConstraint( + "id", name=op.f("matchmaking_user_waitings_pkey"), + ), + ) + op.create_index( + op.f("ix_matchmaking_user_waitings_user_id"), + "matchmaking_user_waitings", + ["user_id"], + unique=False, + ) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/pydantic_settings/envs.py b/src/ttt/infrastructure/pydantic_settings/envs.py index a0fc325..27b1939 100644 --- a/src/ttt/infrastructure/pydantic_settings/envs.py +++ b/src/ttt/infrastructure/pydantic_settings/envs.py @@ -23,6 +23,10 @@ class Envs(BaseSettings): gemini_url: str + matchmaking_max_workers: int + matchmaking_worker_max_users: int + matchmaking_worker_creation_interval_seconds: float + @classmethod def settings_customise_sources( cls, diff --git a/src/ttt/infrastructure/sqlalchemy/tables/__init__.py b/src/ttt/infrastructure/sqlalchemy/tables/__init__.py index 525d193..c27260e 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/__init__.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/__init__.py @@ -3,9 +3,6 @@ from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( TableInvitationToGame, ) -from ttt.infrastructure.sqlalchemy.tables.matchmaking import ( - TableMatchmaking, -) from ttt.infrastructure.sqlalchemy.tables.payment import TablePayment from ttt.infrastructure.sqlalchemy.tables.stars_purchase import ( TableStarsPurchase, diff --git a/src/ttt/infrastructure/sqlalchemy/tables/atomic.py b/src/ttt/infrastructure/sqlalchemy/tables/atomic.py index f219fc2..d84a747 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/atomic.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/atomic.py @@ -7,9 +7,6 @@ from ttt.entities.core.invitation_to_game.invitation_to_game import ( InvitationToGameAtomic, ) -from ttt.entities.core.matchmaking.matchmaking import ( - MatchmakingAtomic, -) from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchaseAtomic from ttt.entities.core.user.user import UserAtomic from ttt.entities.finance.payment.payment import ( @@ -23,10 +20,6 @@ TableInvitationToGameAtomic, table_invitation_to_game_atomic, ) -from ttt.infrastructure.sqlalchemy.tables.matchmaking import ( - TableMatchmakingAtomic, - table_matchmaking_atomic, -) from ttt.infrastructure.sqlalchemy.tables.payment import ( TablePaymentAtomic, table_payment_atomic, @@ -45,9 +38,9 @@ TableUserAtomic | TableStarsPurchaseAtomic | TableGameAtomic - | TableMatchmakingAtomic | TableInvitationToGameAtomic | TablePaymentAtomic + | None ) @@ -61,9 +54,6 @@ def mapped_table_atomic(entity: Atomic) -> TableAtomic: # noqa: RET503 if isinstance(entity, PaymentAtomic): return table_payment_atomic(entity) - if isinstance(entity, MatchmakingAtomic): - return table_matchmaking_atomic(entity) - if isinstance(entity, InvitationToGameAtomic): return table_invitation_to_game_atomic(entity) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/matchmaking.py b/src/ttt/infrastructure/sqlalchemy/tables/matchmaking.py deleted file mode 100644 index 55a7a92..0000000 --- a/src/ttt/infrastructure/sqlalchemy/tables/matchmaking.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import datetime -from uuid import UUID - -from sqlalchemy import ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from ttt.entities.core.matchmaking.matchmaking import ( - Matchmaking, - MatchmakingAtomic, -) -from ttt.entities.core.matchmaking.user_waiting import UserWaiting -from ttt.infrastructure.sqlalchemy.tables.common import Base -from ttt.infrastructure.sqlalchemy.tables.user import TableUser - - -class TableUserWaiting(Base[UserWaiting]): - __tablename__ = "matchmaking_user_waitings" - - id: Mapped[UUID] = mapped_column(primary_key=True) - start_datetime: Mapped[datetime] - user_id: Mapped[int] = mapped_column( - ForeignKey( - "users.id", - deferrable=True, - initially="DEFERRED", - ), - index=True, - ) - - user: Mapped[TableUser] = relationship(lazy="selectin") - - def __entity__(self) -> UserWaiting: - return UserWaiting( - id_=self.id, - user=self.user.entity(), - start_datetime=self.start_datetime, - ) - - @classmethod - def of(cls, it: UserWaiting) -> "TableUserWaiting": - return TableUserWaiting( - id=it.id_, - user_id=it.user.id, - start_datetime=it.start_datetime, - ) - - -type TableMatchmaking = None -type TableMatchmakingAtomic = TableMatchmaking | TableUserWaiting - - -def table_matchmaking_atomic( - entity: MatchmakingAtomic, -) -> TableMatchmakingAtomic: - match entity: - case Matchmaking(): - return None - - case UserWaiting(): - return TableUserWaiting.of(entity) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/user.py b/src/ttt/infrastructure/sqlalchemy/tables/user.py index b016da0..b3cadc3 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/user.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/user.py @@ -13,6 +13,7 @@ AdminRightViaOtherAdmin, ) from ttt.entities.core.user.emoji import UserEmoji +from ttt.entities.core.user.matchmaking_waiting import MatchmakingWaiting from ttt.entities.core.user.user import User, UserAtomic from ttt.entities.text.emoji import Emoji from ttt.entities.tools.assertion import not_none @@ -89,6 +90,10 @@ class TableUser(Base[User]): admin_right_via_other_admin_admin_id: Mapped[int | None] = mapped_column( ForeignKey("users.id", deferrable=True, initially="DEFERRED"), ) + has_matchmaking_waiting: Mapped[bool] = mapped_column( + server_default="false", + ) + matchmaking_waiting_start_datetime: Mapped[datetime | None] emojis: Mapped[list[TableUserEmoji]] = relationship( lazy="selectin", @@ -106,6 +111,11 @@ class TableUser(Base[User]): admin_right, postgresql_where=(admin_right.is_not(None)), ), + Index( + "ix_users_has_matchmaking_waiting", + has_matchmaking_waiting, + postgresql_where=(has_matchmaking_waiting.is_(True)), + ), ) def __entity__(self) -> User: @@ -116,6 +126,13 @@ def __entity__(self) -> User: else: admin_right = None + if self.has_matchmaking_waiting: + matchmaking_waiting = MatchmakingWaiting( + start_datetime=not_none(self.matchmaking_waiting_start_datetime), + ) + else: + matchmaking_waiting = None + return User( id=self.id, account=Account(self.account_stars), @@ -124,6 +141,7 @@ def __entity__(self) -> User: rating=self.rating, current_game_id=self.current_game_id, admin_right=admin_right, + matchmaking_waiting=matchmaking_waiting, ) @classmethod @@ -139,6 +157,15 @@ def of(cls, it: User) -> "TableUser": admin_right = TableAdminRight.via_other_admin admin_right_via_other_admin_admin_id = admin_id + if it.matchmaking_waiting is not None: + has_matchmaking_waiting = True + matchmaking_waiting_start_datetime = ( + it.matchmaking_waiting.start_datetime + ) + else: + has_matchmaking_waiting = False + matchmaking_waiting_start_datetime = None + return TableUser( id=it.id, account_stars=it.account.stars, @@ -149,6 +176,8 @@ def of(cls, it: User) -> "TableUser": admin_right_via_other_admin_admin_id=( admin_right_via_other_admin_admin_id ), + matchmaking_waiting_start_datetime=matchmaking_waiting_start_datetime, + has_matchmaking_waiting=has_matchmaking_waiting, ) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index f72202e..9e1fe53 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -30,12 +30,6 @@ from ttt.application.invitation_to_game.game.ports.invitations_to_game import ( InvitationsToGame, ) -from ttt.application.matchmaking.common.matchmaking_log import ( - CommonMatchmakingLog, -) -from ttt.application.matchmaking.common.shared_matchmaking import ( - SharedMatchmaking, -) from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 PaidStarsPurchasePaymentInbox, ) @@ -57,6 +51,7 @@ from ttt.application.user.emoji_selection.ports.user_log import ( EmojiSelectionUserLog, ) +from ttt.application.user.game.ports.user_log import GameUserLog from ttt.infrastructure.adapters.clock import NotMonotonicUtcClock from ttt.infrastructure.adapters.game_ai_gateway import GeminiGameAiGateway from ttt.infrastructure.adapters.game_dao import PostgresGameDao @@ -72,9 +67,6 @@ InPostgresInvitationsToGame, ) from ttt.infrastructure.adapters.map import MapToPostgres -from ttt.infrastructure.adapters.matchmaking_log import ( - StructlogCommonMatchmakingLog, -) from ttt.infrastructure.adapters.original_admin_token import ( TokenAsOriginalAdminToken, ) @@ -82,9 +74,6 @@ InNatsPaidStarsPurchasePaymentInbox, ) from ttt.infrastructure.adapters.randoms import MersenneTwisterRandoms -from ttt.infrastructure.adapters.shared_matchmaking import ( - InPostgresSharedMatchmaking, -) from ttt.infrastructure.adapters.stars_purchase_log import ( StructlogStarsPurchaseLog, ) @@ -95,6 +84,7 @@ StructlogCommonUserLog, StructlogEmojiPurchaseUserLog, StructlogEmojiSelectionUserLog, + StructlogGameUserLog, ) from ttt.infrastructure.adapters.users import InPostgresUsers from ttt.infrastructure.adapters.uuids import UUIDv4s @@ -234,11 +224,16 @@ def provide_logger( scope=Scope.REQUEST, ) - provide_users = provide( - InPostgresUsers, - provides=Users, - scope=Scope.REQUEST, - ) + @provide(scope=Scope.REQUEST) + def provide_users( + self, + session: AsyncSession, + envs: Envs, + ) -> Users: + return InPostgresUsers( + session, + _users_to_matchmake_limit=envs.matchmaking_worker_max_users, + ) provide_stars_purchases = provide( PostgresStarsPurchases, @@ -246,12 +241,6 @@ def provide_logger( scope=Scope.REQUEST, ) - provide_shared_matchmaking = provide( - InPostgresSharedMatchmaking, - provides=SharedMatchmaking, - scope=Scope.REQUEST, - ) - provide_invitations_to_game = provide( InPostgresInvitationsToGame, provides=InvitationsToGame, @@ -310,6 +299,12 @@ def provide_randoms(self) -> Randoms: scope=Scope.REQUEST, ) + provide_game_user_log = provide( + StructlogGameUserLog, + provides=GameUserLog, + scope=Scope.REQUEST, + ) + provide_emoji_purchase_user_log = provide( StructlogEmojiPurchaseUserLog, provides=EmojiPurchaseUserLog, @@ -328,12 +323,6 @@ def provide_randoms(self) -> Randoms: scope=Scope.REQUEST, ) - provide_common_matchmaking_log = provide( - StructlogCommonMatchmakingLog, - provides=CommonMatchmakingLog, - scope=Scope.REQUEST, - ) - provide_change_other_user_account_log = provide( StructlogChangeOtherUserAccountLog, provides=ChangeOtherUserAccountLog, diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index c570313..9b1e523 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -58,10 +58,6 @@ from ttt.application.invitation_to_game.game.view_outcoming_invitations_to_game import ( # noqa: E501 ViewOutcomingInvitationsToGame, ) -from ttt.application.matchmaking.common.matchmaking_views import ( - CommonMatchmakingViews, -) -from ttt.application.matchmaking.game.wait_game import WaitGame from ttt.application.stars_purchase.complete_stars_purchase_payment import ( CompleteStarsPurchasePayment, ) @@ -109,6 +105,9 @@ EmojiSelectionUserViews, ) from ttt.application.user.emoji_selection.select_emoji import SelectEmoji +from ttt.application.user.game.matchmake import Matchmake +from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.application.user.game.wait_for_matchmaking import WaitForMatchmaking from ttt.application.user.register_user import RegisterUser from ttt.application.user.relinquish_admin_right import RelinquishAdminRight from ttt.application.user.view_admin_menu import ViewAdminMenu @@ -125,9 +124,6 @@ from ttt.presentation.adapters.invitation_to_game_views import ( AiogramInvitationToGameViews, ) -from ttt.presentation.adapters.matchmaking_views import ( - AiogramCommonMatchmakingViews, -) from ttt.presentation.adapters.stars_purchase_payment_gateway import ( AiogramPaymentGateway, ) @@ -139,6 +135,7 @@ AiogramCommonUserViews, AiogramEmojiPurchaseUserViews, AiogramEmojiSelectionUserViews, + AiogramGameUserViews, ) from ttt.presentation.aiogram.common.bots import ttt_bot from ttt.presentation.aiogram.common.routes.all import common_routers @@ -214,6 +211,11 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: provides=CommonUserViews, scope=Scope.REQUEST, ) + provide_game_user_views = provide( + AiogramGameUserViews, + provides=GameUserViews, + scope=Scope.REQUEST, + ) provide_stars_purchase_user_views = provide( AiogramStarsPurchaseViews, provides=StarsPurchaseViews, @@ -229,11 +231,6 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: provides=EmojiPurchaseUserViews, scope=Scope.REQUEST, ) - provide_common_matchmaking_views = provide( - AiogramCommonMatchmakingViews, - provides=CommonMatchmakingViews, - scope=Scope.REQUEST, - ) provide_change_other_user_account_views = provide( AiogramChangeOtherUserAccountViews, provides=ChangeOtherUserAccountViews, @@ -377,6 +374,10 @@ class ApplicationProvider(Provider): provide_view_user_account_to_change = provide( ViewUserAccountToChange, scope=Scope.REQUEST, ) + provide_matchmake = provide(Matchmake, scope=Scope.REQUEST) + provide_wait_for_matchmaking = provide( + WaitForMatchmaking, scope=Scope.REQUEST, + ) provide_start_stars_purchase = provide( StartStarsPurchase, @@ -403,8 +404,6 @@ class ApplicationProvider(Provider): provide_make_move_in_game = provide(MakeMoveInGame, scope=Scope.REQUEST) provide_view_game = provide(ViewGame, scope=Scope.REQUEST) - provide_wait_game = provide(WaitGame, scope=Scope.REQUEST) - provide_accept_invitation_to_game = provide( AcceptInvitationToGame, scope=Scope.REQUEST, ) diff --git a/src/ttt/main/tg_bot/start_aiogram.py b/src/ttt/main/tg_bot/start_aiogram.py index 7dead24..b199078 100644 --- a/src/ttt/main/tg_bot/start_aiogram.py +++ b/src/ttt/main/tg_bot/start_aiogram.py @@ -9,14 +9,17 @@ ContainerMiddleware, ) +from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.presentation.tasks.auto_cancel_invitation_to_game_task import ( auto_cancel_invitation_to_game_task, ) +from ttt.presentation.tasks.matchmake_tasks import matchmake_tasks from ttt.presentation.unkillable_tasks import UnkillableTasks async def start_aiogram(container: AsyncContainer) -> None: dp = await container.get(Dispatcher) + envs = await container.get(Envs) middleware = ContainerMiddleware(container) @@ -27,6 +30,14 @@ async def start_aiogram(container: AsyncContainer) -> None: async with container(context) as request: tasks = await request.get(UnkillableTasks) tasks.add(partial(auto_cancel_invitation_to_game_task, container)) + tasks.add(partial( + matchmake_tasks, + container, + max_workers=envs.matchmaking_max_workers, + worker_creation_interval_seconds=( + envs.matchmaking_worker_creation_interval_seconds + ), + )) logging.basicConfig(level=logging.INFO) diff --git a/src/ttt/presentation/adapters/matchmaking_views.py b/src/ttt/presentation/adapters/matchmaking_views.py deleted file mode 100644 index 9163ccb..0000000 --- a/src/ttt/presentation/adapters/matchmaking_views.py +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass - -from aiogram_dialog import StartMode - -from ttt.application.matchmaking.common.matchmaking_views import ( - CommonMatchmakingViews, -) -from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( - DialogManagerForUser, -) -from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState - - -@dataclass(frozen=True, unsafe_hash=False) -class AiogramCommonMatchmakingViews(CommonMatchmakingViews): - _dialog_manager_for_user: DialogManagerForUser - - async def waiting_for_game_view(self, user_id: int, /) -> None: - dialog_manager = self._dialog_manager_for_user(user_id) - - await dialog_manager.start( - MainDialogState.game_mode_to_start_game, - {"hint": "⚔️ Поиск игры начат"}, - StartMode.RESET_STACK, - ) - - async def double_waiting_for_game_view(self, user_id: int, /) -> None: - dialog_manager = self._dialog_manager_for_user(user_id) - - await dialog_manager.start( - MainDialogState.game_mode_to_start_game, - {"hint": "⚔️ Поиск игры начат"}, - StartMode.RESET_STACK, - ) diff --git a/src/ttt/presentation/adapters/user_views.py b/src/ttt/presentation/adapters/user_views.py index aad31d5..3fafd74 100644 --- a/src/ttt/presentation/adapters/user_views.py +++ b/src/ttt/presentation/adapters/user_views.py @@ -1,3 +1,4 @@ +from asyncio import gather from collections import OrderedDict from dataclasses import dataclass from typing import cast @@ -17,6 +18,8 @@ from ttt.application.user.emoji_selection.ports.user_views import ( EmojiSelectionUserViews, ) +from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.entities.core.game.game import Game from ttt.entities.core.stars import Stars from ttt.entities.core.user.user import User, is_user_in_game, user_stars from ttt.entities.tools.assertion import not_none @@ -58,6 +61,9 @@ from ttt.presentation.aiogram_dialog.main_dialog.emojis_window import ( EmojiMenuView, ) +from ttt.presentation.aiogram_dialog.main_dialog.game_window import ( + ActiveGameView, +) from ttt.presentation.aiogram_dialog.main_dialog.main_window import ( AmoutOfIncomingInvitationsToGame, MainMenuView, @@ -618,3 +624,62 @@ async def _account_view( StartMode.RESET_STACK, ShowMode.DELETE_AND_SEND, ) + + +@dataclass(frozen=True, unsafe_hash=False) +class AiogramGameUserViews(GameUserViews): + _dialog_manager_for_user: DialogManagerForUser + _result_buffer: ResultBuffer + + async def user_is_waiting_for_matchmaking_view( + self, + user: User, + /, + ) -> None: + dialog_manager = self._dialog_manager_for_user(user.id) + await dialog_manager.start( + MainDialogState.game_mode_to_start_game, + {"hint": "⚔️ Подбор начат"}, + StartMode.RESET_STACK, + ) + + async def user_is_already_waiting_for_matchmaking_view( + self, + user: User, + /, + ) -> None: + dialog_manager = self._dialog_manager_for_user(user.id) + await dialog_manager.start( + MainDialogState.game_mode_to_start_game, + {"hint": "⚔️ Подбор начат"}, + StartMode.RESET_STACK, + ) + + async def user_is_in_game_to_wait_for_matchmaking_view( + self, user: User, /, + ) -> None: + dialog_manager = self._dialog_manager_for_user(user.id) + await dialog_manager.start( + MainDialogState.game_mode_to_start_game, + {"hint": "⚔️ Вы уже в игре"}, + StartMode.RESET_STACK, + ) + + async def matched_games_view(self, games: list[Game], /) -> None: + await gather(*map(self._started_game_view, games)) + + async def _started_game_view(self, game: Game, /) -> None: + await gather(*( + self._started_game_view_for_user(user.id, game) + for user in game.users() + )) + + async def _started_game_view_for_user( + self, user_id: int, game: Game, /, + ) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) + await dialog_manager.start( + MainDialogState.game, + ActiveGameView.of(game, user_id).window_data(), + StartMode.RESET_STACK, + ) diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py index 5eb1b34..97f6e19 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py @@ -10,7 +10,7 @@ from dishka.integrations.aiogram_dialog import inject from magic_filter import F -from ttt.application.matchmaking.game.wait_game import WaitGame +from ttt.application.user.game.wait_for_matchmaking import WaitForMatchmaking from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, @@ -23,9 +23,9 @@ async def on_matchmaking_clicked( callback: CallbackQuery, _: Button, __: DialogManager, - wait_game: FromDishka[WaitGame], + wait_for_matchmaking: FromDishka[WaitForMatchmaking], ) -> None: - await wait_game(callback.from_user.id) + await wait_for_matchmaking(callback.from_user.id) game_start_window = Window( diff --git a/src/ttt/presentation/tasks/matchmake_tasks.py b/src/ttt/presentation/tasks/matchmake_tasks.py new file mode 100644 index 0000000..92c1b48 --- /dev/null +++ b/src/ttt/presentation/tasks/matchmake_tasks.py @@ -0,0 +1,30 @@ +from asyncio import Semaphore, TaskGroup, sleep + +from aiogram.types import TelegramObject +from dishka import AsyncContainer +from dishka.integrations.aiogram import AiogramMiddlewareData + +from ttt.application.user.game.matchmake import Matchmake + + +async def matchmake_tasks( + diska_container: AsyncContainer, + max_workers: int, + worker_creation_interval_seconds: float, +) -> None: + semaphore = Semaphore(max_workers) + + async with TaskGroup() as tasks: + while True: + await sleep(worker_creation_interval_seconds) + if not semaphore.locked(): + tasks.create_task(_matchmake_task(diska_container, semaphore)) + + +async def _matchmake_task( + diska_container: AsyncContainer, semaphore: Semaphore, +) -> None: + context = {TelegramObject: None, AiogramMiddlewareData: None} + async with semaphore, diska_container(context) as request: + matchmake = await request.get(Matchmake) + await matchmake() diff --git a/tests/test_ttt/test_entities/test_core/conftest.py b/tests/test_ttt/test_entities/test_core/conftest.py index f5897ce..0d1f5a3 100644 --- a/tests/test_ttt/test_entities/test_core/conftest.py +++ b/tests/test_ttt/test_entities/test_core/conftest.py @@ -29,6 +29,7 @@ def user1() -> User: rating=1000., current_game_id=UUID(int=0), admin_right=None, + matchmaking_waiting=None, ) @@ -42,6 +43,7 @@ def user2() -> User: selected_emoji_id=None, current_game_id=UUID(int=0), admin_right=None, + matchmaking_waiting=None, ) diff --git a/tests/test_ttt/test_entities/test_core/test_game.py b/tests/test_ttt/test_entities/test_core/test_game.py index 0eb7e91..3a30d1c 100644 --- a/tests/test_ttt/test_entities/test_core/test_game.py +++ b/tests/test_ttt/test_entities/test_core/test_game.py @@ -424,6 +424,7 @@ def test_winning_game( # noqa: PLR0913, PLR0917 selected_emoji_id=None, current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "user2": @@ -435,6 +436,7 @@ def test_winning_game( # noqa: PLR0913, PLR0917 selected_emoji_id=None, current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "extra_move": @@ -511,6 +513,7 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 selected_emoji_id=None, current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "user2": @@ -522,6 +525,7 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 selected_emoji_id=None, current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "extra_move": @@ -598,6 +602,7 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 selected_emoji_id=None, current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "user2": @@ -609,6 +614,7 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 selected_emoji_id=None, current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "extra_move": diff --git a/tests/test_ttt/test_entities/test_core/test_user.py b/tests/test_ttt/test_entities/test_core/test_user.py index eed7ba3..51099d1 100644 --- a/tests/test_ttt/test_entities/test_core/test_user.py +++ b/tests/test_ttt/test_entities/test_core/test_user.py @@ -18,6 +18,7 @@ def test_create_user(tracking: Tracking, object_: str) -> None: selected_emoji_id=None, current_game_id=None, admin_right=None, + matchmaking_waiting=None, ) if object_ == "tracking": diff --git a/src/ttt/application/matchmaking/game/__init__.py b/tests/test_ttt/test_entities/test_tools/__init__.py similarity index 100% rename from src/ttt/application/matchmaking/game/__init__.py rename to tests/test_ttt/test_entities/test_tools/__init__.py diff --git a/tests/test_ttt/test_entities/test_tools/test_combinations.py b/tests/test_ttt/test_entities/test_tools/test_combinations.py new file mode 100644 index 0000000..1e39331 --- /dev/null +++ b/tests/test_ttt/test_entities/test_tools/test_combinations.py @@ -0,0 +1,149 @@ +from pytest import mark + +from ttt.entities.tools.combinations import Combinations + + +@mark.parametrize( + ("combinations", "expected_result"), + [ + (Combinations([]), []), + (Combinations([1]), []), + (Combinations([1, 2]), [(1, 2)]), + (Combinations([1, 2, 3]), [(1, 2), (1, 3), (2, 3)]), + ( + Combinations([1, 2, 3, 4]), + [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), (2, 6), + (3, 4), (3, 5), (3, 6), + (4, 5), (4, 6), + (5, 6), + ], + ), + ], +) +def test_without_cut( + combinations: Combinations[int], expected_result: list[tuple[int, int]], +) -> None: + assert list(combinations) == expected_result + + +@mark.parametrize( + ("combinations", "value_to_cut", "expected_result"), + [ + (Combinations([]), (0, 0), []), + (Combinations([1]), (0, 0), []), + (Combinations([1, 2]), (1, 2), [(1, 2)]), + (Combinations([1, 2, 3]), (1, 3), [(1, 2), (1, 3)]), + (Combinations([1, 2, 3, 4]), (1, 2), [(1, 2), (3, 4)]), + ( + Combinations([1, 2, 3, 4]), + (1, 3), + [(1, 2), (1, 3), (2, 4)], + ), + ( + Combinations([1, 2, 3, 4]), + (2, 3), + [(1, 2), (1, 3), (1, 4), (2, 3)], + ), + ( + Combinations([1, 2, 3, 4]), + (2, 4), + [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4)], + ), + ( + Combinations([1, 2, 3, 4]), + (3, 4), + [(1, 2), (1, 3), (1, 4), (2, 3), (2, 4), (3, 4)], + ), + ( + Combinations([1, 2, 3, 4, 5]), + (2, 4), + [(1, 2), (1, 3), (1, 4), (1, 5), (2, 3), (2, 4), (3, 5)], + ), + ( + Combinations([1, 2, 3, 4, 5]), + (2, 3), + [(1, 2), (1, 3), (1, 4), (1, 5), (2, 3), (4, 5)], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (1, 6), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), + (3, 4), (3, 5), + (4, 5), + ], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (5, 6), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), (2, 6), + (3, 4), (3, 5), (3, 6), + (4, 5), (4, 6), + (5, 6), + ], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (4, 6), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), (2, 6), + (3, 4), (3, 5), (3, 6), + (4, 5), (4, 6), + ], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (4, 5), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), (2, 6), + (3, 4), (3, 5), (3, 6), + (4, 5), + ], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (2, 5), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), + (3, 4), (3, 6), + (4, 6), + ], + ), + ( + Combinations([1, 2, 3, 4, 5, 6]), + (3, 6), + [ + (1, 2), (1, 3), (1, 4), (1, 5), (1, 6), + (2, 3), (2, 4), (2, 5), (2, 6), + (3, 4), (3, 5), (3, 6), + (4, 5), + ], + ), + ], +) +def test_with_cut( + combinations: Combinations[int], + value_to_cut: tuple[int, int], + expected_result: list[tuple[int, int]], +) -> None: + result = [] + + for value in combinations: + result.append(value) + + if value == value_to_cut: + combinations.cut() + + assert result == expected_result diff --git a/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py index 05064f3..1cd729d 100644 --- a/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py +++ b/tests/test_ttt/test_infrastructure/test_adapters/test_game_dao.py @@ -32,6 +32,7 @@ def player1() -> User: rating=1000., current_game_id=UUID(int=0), admin_right=None, + matchmaking_waiting=None, ) From afcf57bfce8c251aa7e2ce6d6c812a9616cddb8e Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:40:14 +0700 Subject: [PATCH 14/45] chore(`structlog`): use `rich` instead of `better-exceptions` --- pyproject.toml | 2 +- src/ttt/infrastructure/structlog/logger.py | 7 ++- uv.lock | 50 ++++++++++++++++------ 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1a53610..2f18ede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dev = [ "pytest-cov==6.1.1", "pytest-asyncio==1.0.0", "dirty-equals==0.9.0", - "better-exceptions==0.3.3", + "rich==14.1.0", ] [project.urls] diff --git a/src/ttt/infrastructure/structlog/logger.py b/src/ttt/infrastructure/structlog/logger.py index 21a6474..fe0305f 100644 --- a/src/ttt/infrastructure/structlog/logger.py +++ b/src/ttt/infrastructure/structlog/logger.py @@ -20,6 +20,11 @@ class DevLoggerFactory(LoggerFactory): adds_request_id: bool = field(kw_only=True) def __call__(self) -> FilteringBoundLogger: + renderer = structlog.dev.ConsoleRenderer( + exception_formatter=( + structlog.dev.RichTracebackFormatter(show_locals=False) + ), + ) return cast( FilteringBoundLogger, structlog.wrap_logger( @@ -28,7 +33,7 @@ def __call__(self) -> FilteringBoundLogger: structlog.processors.add_log_level, structlog.processors.TimeStamper(fmt="iso"), *([AddRequestId()] if self.adds_request_id else []), - structlog.dev.ConsoleRenderer(), + renderer, ], ), ) diff --git a/uv.lock b/uv.lock index ab4b6fe..e59404d 100644 --- a/uv.lock +++ b/uv.lock @@ -154,18 +154,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, ] -[[package]] -name = "better-exceptions" -version = "0.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/d8/30b745b965765c08ee132fd590fca46c31296e8f1a606de0c53cc6b5a68f/better_exceptions-0.3.3.tar.gz", hash = "sha256:e4e6bc18444d5f04e6e894b10381e5e921d3d544240418162c7db57e9eb3453b", size = 30156, upload-time = "2021-01-29T16:48:54.914Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/50/abf6850135f1e95d321a525d0a36e05255a039b3fc118b7d88413e8a8207/better_exceptions-0.3.3-py3-none-any.whl", hash = "sha256:9c70b1c61d5a179b84cd2c9d62c3324b667d74286207343645ed4306fdaad976", size = 11857, upload-time = "2021-01-29T16:48:53.642Z" }, -] - [[package]] name = "cachetools" version = "5.5.2" @@ -481,6 +469,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/83/de0a49e7de540513f53ab5d2e105321dedeb08a8f5850f0208decf4390ec/Mako-1.3.9-py3-none-any.whl", hash = "sha256:95920acccb578427a9aa38e37a186b1e43156c87260d7ba18ca63aa4c7cbd3a1", size = 78456, upload-time = "2025-02-04T15:05:51.115Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markupsafe" version = "3.0.2" @@ -509,6 +509,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "multidict" version = "6.4.4" @@ -926,6 +935,19 @@ hiredis = [ { name = "hiredis" }, ] +[[package]] +name = "rich" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, +] + [[package]] name = "ruff" version = "0.13.0" @@ -1053,12 +1075,12 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "better-exceptions" }, { name = "dirty-equals" }, { name = "mypy", extra = ["faster-cache"] }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "rich" }, { name = "ruff" }, ] @@ -1083,12 +1105,12 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "better-exceptions", specifier = "==0.3.3" }, { name = "dirty-equals", specifier = "==0.9.0" }, { name = "mypy", extras = ["faster-cache"], specifier = "==1.18.1" }, { name = "pytest", specifier = "==8.4.0" }, { name = "pytest-asyncio", specifier = "==1.0.0" }, { name = "pytest-cov", specifier = "==6.1.1" }, + { name = "rich", specifier = "==14.1.0" }, { name = "ruff", specifier = "==0.13.0" }, ] From d56d86cf1d72c8287d20699b0c77b9df6128f908 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:03:37 +0700 Subject: [PATCH 15/45] fix(`Matchmake`): fix `entities` io (#58) --- src/ttt/application/user/game/matchmake.py | 9 ++++++--- src/ttt/entities/core/user/user.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ttt/application/user/game/matchmake.py b/src/ttt/application/user/game/matchmake.py index 7d5f7ae..7a9af08 100644 --- a/src/ttt/application/user/game/matchmake.py +++ b/src/ttt/application/user/game/matchmake.py @@ -1,4 +1,5 @@ from asyncio import gather +from contextlib import suppress from dataclasses import dataclass from ttt.application.common.ports.emojis import Emojis @@ -43,11 +44,13 @@ async def _result(self, tracking: Tracking) -> list[Game]: games = list[Game]() matchmaking_ = matchmaking(users, input_, tracking) - try: + with suppress(StopIteration): + games.append(next(matchmaking_)) + while True: games.append(matchmaking_.send(await self._matchmaking_input())) - except StopIteration: - return games + + return games async def _matchmaking_input(self) -> MatchmakingInput: ( diff --git a/src/ttt/entities/core/user/user.py b/src/ttt/entities/core/user/user.py index ea757b7..3766a88 100644 --- a/src/ttt/entities/core/user/user.py +++ b/src/ttt/entities/core/user/user.py @@ -463,8 +463,8 @@ def dont_wait_for_matchmaking(self, tracking: Tracking) -> None: else_=UserIsNotWaitingForMatchmakingError, ) - tracking.register_unused(self.matchmaking_waiting) self.matchmaking_waiting = None + tracking.register_mutated(self) UserAtomic = User | UserEmoji From 7c4e1d7acc6cf17e13062f8e407e0c624f47ff82 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Thu, 18 Sep 2025 16:06:04 +0700 Subject: [PATCH 16/45] ref(`adapters`): remove unused `shared_matchmaking` (#58) --- .../adapters/shared_matchmaking.py | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 src/ttt/infrastructure/adapters/shared_matchmaking.py diff --git a/src/ttt/infrastructure/adapters/shared_matchmaking.py b/src/ttt/infrastructure/adapters/shared_matchmaking.py deleted file mode 100644 index 4e1c029..0000000 --- a/src/ttt/infrastructure/adapters/shared_matchmaking.py +++ /dev/null @@ -1,31 +0,0 @@ -from collections.abc import Generator -from dataclasses import dataclass -from typing import Any - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from ttt.application.matchmaking.common.shared_matchmaking import ( - SharedMatchmaking, -) -from ttt.entities.core.matchmaking.matchmaking import ( - Matchmaking, -) -from ttt.infrastructure.sqlalchemy.tables.matchmaking import ( - TableUserWaiting, -) - - -@dataclass(frozen=True, unsafe_hash=False) -class InPostgresSharedMatchmaking(SharedMatchmaking): - _session: AsyncSession - - def __await__(self) -> Generator[Any, Any, Matchmaking]: - return self._matchmaking().__await__() - - async def _matchmaking(self) -> Matchmaking: - stmt = select(TableUserWaiting).with_for_update() - result = await self._session.scalars(stmt) - table_waitings = result.all() - - return Matchmaking([it.entity() for it in table_waitings]) From 10d23127bc92a45d7385ffa353230c965200e0b3 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:54:51 +0700 Subject: [PATCH 17/45] feat: add `matchmaking` waiting cancellation (#45) --- .../user/game/dont_wait_for_matchmaking.py | 45 ++++++++++++++++ .../application/user/game/ports/user_log.py | 5 ++ .../application/user/game/ports/user_views.py | 13 +++++ .../application/user/game/view_matchmaking.py | 14 +++++ src/ttt/infrastructure/adapters/user_log.py | 8 +++ src/ttt/main/tg_bot/di.py | 8 +++ src/ttt/presentation/adapters/user_views.py | 42 ++++++++++----- .../main_dialog/game_start_window.py | 53 +++++++++++++++++-- 8 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 src/ttt/application/user/game/dont_wait_for_matchmaking.py create mode 100644 src/ttt/application/user/game/view_matchmaking.py diff --git a/src/ttt/application/user/game/dont_wait_for_matchmaking.py b/src/ttt/application/user/game/dont_wait_for_matchmaking.py new file mode 100644 index 0000000..194a24d --- /dev/null +++ b/src/ttt/application/user/game/dont_wait_for_matchmaking.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import Transaction +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users +from ttt.application.user.game.ports.user_log import GameUserLog +from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.entities.core.user.user import UserIsNotWaitingForMatchmakingError +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class DontWaitForMatchmaking: + map_: Map + transaction: Transaction + users: Users + user_views: CommonUserViews + views: GameUserViews + log: GameUserLog + + async def __call__(self, user_id: int) -> None: + async with self.transaction: + user = await self.users.user_with_id(user_id) + + if user is None: + await self.user_views.user_is_not_registered_view(user_id) + return + + try: + tracking = Tracking() + user.dont_wait_for_matchmaking(tracking) + except UserIsNotWaitingForMatchmakingError: + await self.log.user_is_not_waiting_for_matchmaking_to_dont_wait( + user, + ) + await ( + self.views + .user_is_not_waiting_for_matchmaking_to_dont_wait_view(user) + ) + else: + await self.log.user_is_not_waiting_for_matchmaking(user) + await self.map_(tracking) + + await self.views.user_is_not_waiting_for_matchmaking_view(user) diff --git a/src/ttt/application/user/game/ports/user_log.py b/src/ttt/application/user/game/ports/user_log.py index da59d6e..5fefeb0 100644 --- a/src/ttt/application/user/game/ports/user_log.py +++ b/src/ttt/application/user/game/ports/user_log.py @@ -33,3 +33,8 @@ async def user_is_in_game_to_wait_for_matchmaking( @abstractmethod async def games_were_matched(self, games: list[Game], /) -> None: ... + + @abstractmethod + async def user_is_not_waiting_for_matchmaking_to_dont_wait( + self, user: User, /, + ) -> None: ... diff --git a/src/ttt/application/user/game/ports/user_views.py b/src/ttt/application/user/game/ports/user_views.py index 237ad0e..fc1b912 100644 --- a/src/ttt/application/user/game/ports/user_views.py +++ b/src/ttt/application/user/game/ports/user_views.py @@ -26,3 +26,16 @@ async def user_is_in_game_to_wait_for_matchmaking_view( @abstractmethod async def matched_games_view(self, games: list[Game], /) -> None: ... + + @abstractmethod + async def user_is_not_waiting_for_matchmaking_to_dont_wait_view( + self, user: User, /, + ) -> None: ... + + @abstractmethod + async def user_is_not_waiting_for_matchmaking_view( + self, user: User, /, + ) -> None: ... + + @abstractmethod + async def matchmaking_view(self, user_id: int, /) -> None: ... diff --git a/src/ttt/application/user/game/view_matchmaking.py b/src/ttt/application/user/game/view_matchmaking.py new file mode 100644 index 0000000..b4bb7d3 --- /dev/null +++ b/src/ttt/application/user/game/view_matchmaking.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.transaction import Transaction +from ttt.application.user.game.ports.user_views import GameUserViews + + +@dataclass(frozen=True, unsafe_hash=False) +class ViewMatchmaking: + transaction: Transaction + views: GameUserViews + + async def __call__(self, user_id: int) -> None: + async with self.transaction: + await self.views.matchmaking_view(user_id) diff --git a/src/ttt/infrastructure/adapters/user_log.py b/src/ttt/infrastructure/adapters/user_log.py index fff9afc..7312037 100644 --- a/src/ttt/infrastructure/adapters/user_log.py +++ b/src/ttt/infrastructure/adapters/user_log.py @@ -394,3 +394,11 @@ async def _game_was_matched(self, game: Game) -> None: "game_was_matched", game_id=game.id.hex, ) + + async def user_is_not_waiting_for_matchmaking_to_dont_wait( + self, user: User, /, + ) -> None: + await self._logger.ainfo( + "user_is_not_waiting_for_matchmaking_to_dont_wait", + user_id=user.id, + ) diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index 9b1e523..8ad5f85 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -105,8 +105,12 @@ EmojiSelectionUserViews, ) from ttt.application.user.emoji_selection.select_emoji import SelectEmoji +from ttt.application.user.game.dont_wait_for_matchmaking import ( + DontWaitForMatchmaking, +) from ttt.application.user.game.matchmake import Matchmake from ttt.application.user.game.ports.user_views import GameUserViews +from ttt.application.user.game.view_matchmaking import ViewMatchmaking from ttt.application.user.game.wait_for_matchmaking import WaitForMatchmaking from ttt.application.user.register_user import RegisterUser from ttt.application.user.relinquish_admin_right import RelinquishAdminRight @@ -378,6 +382,10 @@ class ApplicationProvider(Provider): provide_wait_for_matchmaking = provide( WaitForMatchmaking, scope=Scope.REQUEST, ) + provide_dont_wait_for_matchmaking = provide( + DontWaitForMatchmaking, scope=Scope.REQUEST, + ) + provide_view_matchmaking = provide(ViewMatchmaking, scope=Scope.REQUEST) provide_start_stars_purchase = provide( StartStarsPurchase, diff --git a/src/ttt/presentation/adapters/user_views.py b/src/ttt/presentation/adapters/user_views.py index 3fafd74..24d500c 100644 --- a/src/ttt/presentation/adapters/user_views.py +++ b/src/ttt/presentation/adapters/user_views.py @@ -61,6 +61,9 @@ from ttt.presentation.aiogram_dialog.main_dialog.emojis_window import ( EmojiMenuView, ) +from ttt.presentation.aiogram_dialog.main_dialog.game_start_window import ( + GameStartView, +) from ttt.presentation.aiogram_dialog.main_dialog.game_window import ( ActiveGameView, ) @@ -630,30 +633,21 @@ async def _account_view( class AiogramGameUserViews(GameUserViews): _dialog_manager_for_user: DialogManagerForUser _result_buffer: ResultBuffer + _session: AsyncSession async def user_is_waiting_for_matchmaking_view( self, user: User, /, ) -> None: - dialog_manager = self._dialog_manager_for_user(user.id) - await dialog_manager.start( - MainDialogState.game_mode_to_start_game, - {"hint": "⚔️ Подбор начат"}, - StartMode.RESET_STACK, - ) + ... async def user_is_already_waiting_for_matchmaking_view( self, user: User, /, ) -> None: - dialog_manager = self._dialog_manager_for_user(user.id) - await dialog_manager.start( - MainDialogState.game_mode_to_start_game, - {"hint": "⚔️ Подбор начат"}, - StartMode.RESET_STACK, - ) + ... async def user_is_in_game_to_wait_for_matchmaking_view( self, user: User, /, @@ -683,3 +677,27 @@ async def _started_game_view_for_user( ActiveGameView.of(game, user_id).window_data(), StartMode.RESET_STACK, ) + + async def user_is_not_waiting_for_matchmaking_to_dont_wait_view( + self, user: User, /, + ) -> None: + ... + + async def user_is_not_waiting_for_matchmaking_view( + self, user: User, /, + ) -> None: + ... + + async def matchmaking_view(self, user_id: int, /) -> None: + stmt = ( + select(TableUser.has_matchmaking_waiting) + .where(TableUser.id == user_id) + ) + has_matchmaking_waiting = await self._session.scalar(stmt) + + if has_matchmaking_waiting is None: + self._result_buffer.result = None + else: + self._result_buffer.result = GameStartView( + is_user_waiting_for_matchmaking=has_matchmaking_waiting, + ) diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py index 97f6e19..2bac329 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py @@ -1,4 +1,8 @@ +from dataclasses import dataclass +from typing import Any + from aiogram.types import CallbackQuery +from aiogram.types.user import User from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.kbd import ( Button, @@ -10,16 +14,27 @@ from dishka.integrations.aiogram_dialog import inject from magic_filter import F +from ttt.application.user.game.dont_wait_for_matchmaking import ( + DontWaitForMatchmaking, +) +from ttt.application.user.game.view_matchmaking import ViewMatchmaking from ttt.application.user.game.wait_for_matchmaking import WaitForMatchmaking +from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, ) from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState +from ttt.presentation.result_buffer import ResultBuffer + + +@dataclass(frozen=True) +class GameStartView(EncodableToWindowData): + is_user_waiting_for_matchmaking: bool @inject -async def on_matchmaking_clicked( +async def on_wait_for_matchmaking_clicked( callback: CallbackQuery, _: Button, __: DialogManager, @@ -28,14 +43,45 @@ async def on_matchmaking_clicked( await wait_for_matchmaking(callback.from_user.id) +@inject +async def on_dont_wait_for_matchmaking_clicked( + callback: CallbackQuery, + _: Button, + __: DialogManager, + dont_wait_for_matchmaking: FromDishka[DontWaitForMatchmaking], +) -> None: + await dont_wait_for_matchmaking(callback.from_user.id) + + +@inject +async def getter( + *, + event_from_user: User, + view_matchmaking: FromDishka[ViewMatchmaking], + result_buffer: FromDishka[ResultBuffer], + **_: Any, # noqa: ANN401 +) -> dict[str, Any]: + await view_matchmaking(event_from_user.id) + view = result_buffer(GameStartView) + + return view.window_data() + + game_start_window = Window( Const("⚔️ Выберите режим", when=~F["start_data"]["hint"]), hint(key="hint"), Row( Button( Const("🗡 Подбор игр"), - id="matchmaking", - on_click=on_matchmaking_clicked, + id="wait_for_matchmaking", + on_click=on_wait_for_matchmaking_clicked, + when=~F["main"]["is_user_waiting_for_matchmaking"], + ), + Button( + Const("🗡❌ Отменить"), + id="dont_wait_for_matchmaking", + on_click=on_dont_wait_for_matchmaking_clicked, + when=F["main"]["is_user_waiting_for_matchmaking"], ), SwitchTo( Const("🤖 Играть с ИИ"), @@ -54,4 +100,5 @@ async def on_matchmaking_clicked( OneTimekey("hint"), state=MainDialogState.game_mode_to_start_game, + getter=getter, ) From 6a76fa2091f78fb43e7ee7bfe088bde8dcc64a52 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Fri, 19 Sep 2025 17:50:08 +0700 Subject: [PATCH 18/45] ref: make infinite interactors not infinite (#61) --- deploy/dev/docker-compose.yaml | 2 + deploy/prod/docker-compose.yaml | 6 ++ .../complete_stars_purchase_payment.py | 2 +- .../paid_stars_purchase_payment_inbox.py | 4 +- .../ports/stars_purchase_payment_gateway.py | 7 -- ...start_stars_purchase_payment_completion.py | 18 ++-- .../paid_stars_purchase_payment_inbox.py | 4 +- src/ttt/infrastructure/background_tasks.py | 38 --------- src/ttt/infrastructure/buffer.py | 29 ------- src/ttt/infrastructure/nats/messages.py | 27 +++--- .../infrastructure/pydantic_settings/envs.py | 2 + src/ttt/infrastructure/structlog/logger.py | 83 ++++++++----------- src/ttt/main/common/di.py | 22 +---- src/ttt/main/tg_bot/di.py | 79 +++++++++++++----- src/ttt/main/tg_bot/start_aiogram.py | 50 ----------- src/ttt/main/tg_bot/start_tg_bot.py | 35 ++++++++ src/ttt/main/tg_bot_dev/__main__.py | 17 ++-- src/ttt/main/tg_bot_dev/di.py | 18 ++++ src/ttt/main/tg_bot_prod/__main__.py | 14 ++-- src/ttt/main/tg_bot_prod/di.py | 18 ++++ .../stars_purchase_payment_gateway.py | 10 --- .../aiogram/user/routes/handle_payment.py | 28 +++---- .../auto_cancel_invitation_to_game_task.py | 17 ---- .../auto_cancel_invitations_to_game_task.py | 21 +++++ .../complete_stars_purchase_payment_task.py | 17 ++++ src/ttt/presentation/tasks/matchmake_tasks.py | 48 ++++++----- src/ttt/presentation/tasks/task.py | 11 +++ .../presentation/tasks/unkillable_tasks.py | 16 ++++ ...able_tasks.py => unkillable_task_group.py} | 2 +- 29 files changed, 324 insertions(+), 321 deletions(-) delete mode 100644 src/ttt/infrastructure/background_tasks.py delete mode 100644 src/ttt/infrastructure/buffer.py delete mode 100644 src/ttt/main/tg_bot/start_aiogram.py create mode 100644 src/ttt/main/tg_bot/start_tg_bot.py create mode 100644 src/ttt/main/tg_bot_dev/di.py create mode 100644 src/ttt/main/tg_bot_prod/di.py delete mode 100644 src/ttt/presentation/tasks/auto_cancel_invitation_to_game_task.py create mode 100644 src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py create mode 100644 src/ttt/presentation/tasks/complete_stars_purchase_payment_task.py create mode 100644 src/ttt/presentation/tasks/task.py create mode 100644 src/ttt/presentation/tasks/unkillable_tasks.py rename src/ttt/presentation/{unkillable_tasks.py => unkillable_task_group.py} (95%) diff --git a/deploy/dev/docker-compose.yaml b/deploy/dev/docker-compose.yaml index eda80d3..baa7861 100644 --- a/deploy/dev/docker-compose.yaml +++ b/deploy/dev/docker-compose.yaml @@ -39,6 +39,8 @@ services: TTT_MATCHMAKING_MAX_WORKERS: 4 TTT_MATCHMAKING_WORKER_MAX_USERS: 100 TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5 + + TTT_AUTO_CANCEL_INVITATIONS_TO_GAME_INTERVAL_SECONDS: 1 secrets: - secrets command: ttt-dev diff --git a/deploy/prod/docker-compose.yaml b/deploy/prod/docker-compose.yaml index eb6f70a..7398e9c 100644 --- a/deploy/prod/docker-compose.yaml +++ b/deploy/prod/docker-compose.yaml @@ -35,6 +35,12 @@ services: TTT_NATS_URL: nats://${NATS_TOKEN}@nats:4222 TTT_GEMINI_URL: ${GEMINI_URL} + + TTT_MATCHMAKING_MAX_WORKERS: 4 + TTT_MATCHMAKING_WORKER_MAX_USERS: 100 + TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5 + + TTT_AUTO_CANCEL_INVITATIONS_TO_GAME_INTERVAL_SECONDS: 1 secrets: - secrets networks: diff --git a/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py b/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py index 6e3de3c..58b72f2 100644 --- a/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py +++ b/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py @@ -32,7 +32,7 @@ class CompleteStarsPurchasePayment: stars_purchases: StarsPurchases async def __call__(self) -> None: - async for paid_payment in self.inbox.stream(): + async for paid_payment in self.inbox: current_datetime = await self.clock.current_datetime() async with self.transaction: diff --git a/src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py b/src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py index 845107c..f0e7c9f 100644 --- a/src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py +++ b/src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from collections.abc import AsyncIterable +from collections.abc import AsyncIterator from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment @@ -9,4 +9,4 @@ class PaidStarsPurchasePaymentInbox(ABC): async def push(self, payment: PaidStarsPurchasePayment) -> None: ... @abstractmethod - def stream(self) -> AsyncIterable[PaidStarsPurchasePayment]: ... + def __aiter__(self) -> AsyncIterator[PaidStarsPurchasePayment]: ... diff --git a/src/ttt/application/stars_purchase/ports/stars_purchase_payment_gateway.py b/src/ttt/application/stars_purchase/ports/stars_purchase_payment_gateway.py index 55e93ac..013e457 100644 --- a/src/ttt/application/stars_purchase/ports/stars_purchase_payment_gateway.py +++ b/src/ttt/application/stars_purchase/ports/stars_purchase_payment_gateway.py @@ -1,8 +1,6 @@ from abc import ABC, abstractmethod -from collections.abc import AsyncIterable from uuid import UUID -from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase @@ -18,8 +16,3 @@ async def stop_payment_due_to_dublicate(self, payment_id: UUID) -> None: ... @abstractmethod async def stop_payment_due_to_error(self, payment_id: UUID) -> None: ... - - @abstractmethod - def paid_payment_stream( - self, - ) -> AsyncIterable[PaidStarsPurchasePayment]: ... diff --git a/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py index d6bdd87..a159520 100644 --- a/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py @@ -1,5 +1,6 @@ from dataclasses import dataclass +from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 PaidStarsPurchasePaymentInbox, ) @@ -21,12 +22,11 @@ class StartStarsPurchasePaymentCompletion: views: StarsPurchaseViews log: StarsPurchaseLog - async def __call__(self) -> None: - async for paid_payment in self.payment_gateway.paid_payment_stream(): - await self.inbox.push(paid_payment) - await self.views.stars_purchase_will_be_completed_view( - paid_payment.user_id, - ) - await self.log.stars_purchase_payment_completion_started( - paid_payment, - ) + async def __call__(self, paid_payment: PaidStarsPurchasePayment) -> None: + await self.inbox.push(paid_payment) + await self.log.stars_purchase_payment_completion_started( + paid_payment, + ) + await self.views.stars_purchase_will_be_completed_view( + paid_payment.user_id, + ) diff --git a/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py b/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py index 369995b..3d26168 100644 --- a/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py +++ b/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py @@ -1,4 +1,4 @@ -from collections.abc import AsyncIterable +from collections.abc import AsyncIterator from dataclasses import dataclass from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment @@ -17,6 +17,6 @@ class InNatsPaidStarsPurchasePaymentInbox(PaidStarsPurchasePaymentInbox): async def push(self, payment: PaidStarsPurchasePayment) -> None: await self._inbox.push(payment) - async def stream(self) -> AsyncIterable[PaidStarsPurchasePayment]: + async def __aiter__(self) -> AsyncIterator[PaidStarsPurchasePayment]: async for payment in self._inbox: yield payment diff --git a/src/ttt/infrastructure/background_tasks.py b/src/ttt/infrastructure/background_tasks.py deleted file mode 100644 index 7b06e80..0000000 --- a/src/ttt/infrastructure/background_tasks.py +++ /dev/null @@ -1,38 +0,0 @@ -import asyncio -from collections.abc import Coroutine -from dataclasses import dataclass, field -from types import TracebackType -from typing import Any, Self - - -@dataclass(frozen=True, unsafe_hash=False) -class BackgroundTasks: - _loop: asyncio.AbstractEventLoop = field( - default_factory=asyncio.get_running_loop, - ) - _tasks: set[asyncio.Task[Any]] = field(init=False, default_factory=set) - - async def __aenter__(self) -> Self: - return self - - async def __aexit__( - self, - error_type: type[BaseException] | None, - error: BaseException | None, - traceback: TracebackType | None, - ) -> None: - for task in self._tasks: - task.cancel() - - errors = await asyncio.gather(*self._tasks, return_exceptions=True) - errors = [error for error in errors if isinstance(error, Exception)] - - if errors: - raise ExceptionGroup("unhandled errors", errors) # noqa: TRY003 - - def create_task[T](self, coro: Coroutine[Any, Any, T]) -> asyncio.Task[T]: - task = self._loop.create_task(coro) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) - - return task diff --git a/src/ttt/infrastructure/buffer.py b/src/ttt/infrastructure/buffer.py deleted file mode 100644 index b565324..0000000 --- a/src/ttt/infrastructure/buffer.py +++ /dev/null @@ -1,29 +0,0 @@ -from asyncio import Event -from collections import deque -from collections.abc import AsyncIterator -from dataclasses import dataclass, field - - -@dataclass(frozen=True, unsafe_hash=False) -class Buffer[ValueT]: - _values: deque[ValueT] = field(default_factory=deque) - _has_values: Event = field(default_factory=Event, init=False) - - def __post_init__(self) -> None: - if self._values: - self._has_values.set() - - def __len__(self) -> int: - return len(self._values) - - def add(self, value: ValueT) -> None: - self._values.append(value) - self._has_values.set() - - async def stream(self) -> AsyncIterator[ValueT]: - while True: - await self._has_values.wait() - yield self._values.popleft() - - if not self._values: - self._has_values.clear() diff --git a/src/ttt/infrastructure/nats/messages.py b/src/ttt/infrastructure/nats/messages.py index 410b4ec..917556d 100644 --- a/src/ttt/infrastructure/nats/messages.py +++ b/src/ttt/infrastructure/nats/messages.py @@ -7,21 +7,20 @@ async def at_least_once_messages( subscription: JetStreamContext.PullSubscription, - batch: int = 1, + max_len: int = 1, timeout: float | None = 5, # noqa: ASYNC109 heartbeat: float | None = None, ) -> AsyncIterator[Msg]: - while True: - try: - messages = await subscription.fetch(batch, timeout, heartbeat) - except NatsTimeoutError: - continue + try: + messages = await subscription.fetch(max_len, timeout, heartbeat) + except NatsTimeoutError: + return - for message in messages: - try: - yield message - except BaseException as error: - await message.nak() - raise error from error - else: - await message.ack() + for message in messages: + try: + yield message + except BaseException as error: + await message.nak() + raise error from error + else: + await message.ack() diff --git a/src/ttt/infrastructure/pydantic_settings/envs.py b/src/ttt/infrastructure/pydantic_settings/envs.py index 27b1939..662cd7a 100644 --- a/src/ttt/infrastructure/pydantic_settings/envs.py +++ b/src/ttt/infrastructure/pydantic_settings/envs.py @@ -27,6 +27,8 @@ class Envs(BaseSettings): matchmaking_worker_max_users: int matchmaking_worker_creation_interval_seconds: float + auto_cancel_invitations_to_game_interval_seconds: float + @classmethod def settings_customise_sources( cls, diff --git a/src/ttt/infrastructure/structlog/logger.py b/src/ttt/infrastructure/structlog/logger.py index fe0305f..87fc39f 100644 --- a/src/ttt/infrastructure/structlog/logger.py +++ b/src/ttt/infrastructure/structlog/logger.py @@ -1,6 +1,4 @@ import logging -from abc import ABC, abstractmethod -from dataclasses import dataclass, field from typing import cast import structlog @@ -10,53 +8,40 @@ from ttt.infrastructure.structlog.processors import AddRequestId -class LoggerFactory(ABC): - @abstractmethod - def __call__(self) -> FilteringBoundLogger: ... - - -@dataclass(frozen=True) -class DevLoggerFactory(LoggerFactory): - adds_request_id: bool = field(kw_only=True) - - def __call__(self) -> FilteringBoundLogger: - renderer = structlog.dev.ConsoleRenderer( - exception_formatter=( - structlog.dev.RichTracebackFormatter(show_locals=False) - ), - ) - return cast( - FilteringBoundLogger, - structlog.wrap_logger( - structlog.PrintLogger(), - processors=[ - structlog.processors.add_log_level, - structlog.processors.TimeStamper(fmt="iso"), - *([AddRequestId()] if self.adds_request_id else []), - renderer, - ], - ), - ) - - -@dataclass(frozen=True) -class ProdLoggerFactory(LoggerFactory): - adds_request_id: bool = field(kw_only=True) - - def __call__(self) -> FilteringBoundLogger: - return cast( - FilteringBoundLogger, - structlog.wrap_logger( - structlog.PrintLogger(), - processors=[ - structlog.processors.add_log_level, - structlog.processors.TimeStamper(fmt="iso", utc=True), - *([AddRequestId()] if self.adds_request_id else []), - SentryProcessor(event_level=logging.WARNING), - structlog.processors.KeyValueRenderer(), - ], - ), - ) +def dev_logger(*, with_request_id: bool) -> FilteringBoundLogger: + renderer = structlog.dev.ConsoleRenderer( + exception_formatter=( + structlog.dev.RichTracebackFormatter(show_locals=False) + ), + ) + return cast( + FilteringBoundLogger, + structlog.wrap_logger( + structlog.PrintLogger(), + processors=[ + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + *([AddRequestId()] if with_request_id else []), + renderer, + ], + ), + ) + + +def prod_logger(*, with_request_id: bool) -> FilteringBoundLogger: + return cast( + FilteringBoundLogger, + structlog.wrap_logger( + structlog.PrintLogger(), + processors=[ + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso", utc=True), + *([AddRequestId()] if with_request_id else []), + SentryProcessor(event_level=logging.WARNING), + structlog.processors.KeyValueRenderer(), + ], + ), + ) async def unexpected_error_log( diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 9e1fe53..fa35454 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -1,6 +1,6 @@ from collections.abc import AsyncIterator -from dishka import Provider, Scope, from_context, provide +from dishka import Provider, Scope, provide from nats import connect as connect_to_nats from nats.aio.client import Client as Nats from nats.js import JetStreamContext @@ -10,7 +10,6 @@ AsyncSession, create_async_engine, ) -from structlog.types import FilteringBoundLogger from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map @@ -88,22 +87,15 @@ ) from ttt.infrastructure.adapters.users import InPostgresUsers from ttt.infrastructure.adapters.uuids import UUIDv4s -from ttt.infrastructure.background_tasks import BackgroundTasks from ttt.infrastructure.nats.paid_stars_purchase_payment_inbox import ( InNatsPaidStarsPurchasePaymentInbox as OriginalInNatsPaidStarsPurchasePaymentInbox, # noqa: E501 ) from ttt.infrastructure.openai.gemini import Gemini, gemini from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets -from ttt.infrastructure.structlog.logger import LoggerFactory class InfrastructureProvider(Provider): - provide_logger_factory = from_context( - provides=LoggerFactory, - scope=Scope.APP, - ) - provide_envs = provide(source=Envs.load, scope=Scope.APP) provide_secrets = provide(source=Secrets.load, scope=Scope.APP) @@ -113,11 +105,6 @@ def provide_original_admin_token( ) -> OriginalAdminToken: return TokenAsOriginalAdminToken(secrets.admin_token) - @provide(scope=Scope.APP) - async def provide_background_tasks(self) -> AsyncIterator[BackgroundTasks]: - async with BackgroundTasks() as tasks: - yield tasks - @provide(scope=Scope.APP) async def provide_postgres_engine(self, envs: Envs) -> AsyncEngine: return create_async_engine( @@ -199,13 +186,6 @@ async def provide_original_in_nats_paid_stars_purchase_payment_inbox( def provide_gemini(self, secrets: Secrets, envs: Envs) -> Gemini: return gemini(secrets.gemini_api_key, envs.gemini_url) - @provide(scope=Scope.REQUEST) - def provide_logger( - self, - logger_factory: LoggerFactory, - ) -> FilteringBoundLogger: - return logger_factory() - provide_in_nats_paid_stars_purchase_payment_inbox = provide( InNatsPaidStarsPurchasePaymentInbox, provides=PaidStarsPurchasePaymentInbox, diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index 8ad5f85..c84473a 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -1,6 +1,6 @@ from collections.abc import AsyncIterator from dataclasses import dataclass -from typing import cast +from typing import Annotated, cast from aiogram import Bot, Dispatcher from aiogram.fsm.context import FSMContext @@ -16,6 +16,7 @@ from aiogram_dialog.manager.bg_manager import BgManagerFactoryImpl from aiogram_dialog.manager.manager import ManagerImpl from dishka import ( + FromComponent, Provider, Scope, provide, @@ -61,7 +62,6 @@ from ttt.application.stars_purchase.complete_stars_purchase_payment import ( CompleteStarsPurchasePayment, ) -from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 StarsPurchasePaymentGateway, ) @@ -119,7 +119,7 @@ from ttt.application.user.view_other_user import ViewOtherUser from ttt.application.user.view_user import ViewUser from ttt.application.user.view_user_emojis import ViewUserEmojis -from ttt.infrastructure.buffer import Buffer +from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets from ttt.presentation.adapters.emojis import PictographsAsEmojis from ttt.presentation.adapters.game_views import ( @@ -150,7 +150,15 @@ ) from ttt.presentation.aiogram_dialog.main_dialog import main_dialog from ttt.presentation.result_buffer import ResultBuffer -from ttt.presentation.unkillable_tasks import UnkillableTasks +from ttt.presentation.tasks.auto_cancel_invitations_to_game_task import ( + AutoCancelInvitationsToGameTask, +) +from ttt.presentation.tasks.complete_stars_purchase_payment_task import ( + CompleteStarsPurchasePaymentTask, +) +from ttt.presentation.tasks.matchmake_tasks import MatchmakeTasks +from ttt.presentation.tasks.unkillable_tasks import UnkillableTasks +from ttt.presentation.unkillable_task_group import UnkillableTaskGroup @dataclass @@ -251,26 +259,57 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: def provide_result_buffer(self) -> ResultBuffer: return ResultBuffer() - @provide(scope=Scope.REQUEST) - async def unkillable_tasks( + @provide(scope=Scope.APP) + def provide_auto_cancel_invitations_to_game_task( + self, envs: Envs, + ) -> AutoCancelInvitationsToGameTask: + return AutoCancelInvitationsToGameTask( + _interval_seconds=( + envs.auto_cancel_invitations_to_game_interval_seconds + ), + ) + + @provide(scope=Scope.APP) + def provide_complete_stars_purchase_payment_task( self, - logger: FilteringBoundLogger, - start_stars_purchase_payment_completion: ( - StartStarsPurchasePaymentCompletion - ), - complete_stars_purchase_payment: CompleteStarsPurchasePayment, - ) -> UnkillableTasks: - tasks = UnkillableTasks(logger) - tasks.add(start_stars_purchase_payment_completion) - tasks.add(complete_stars_purchase_payment) + ) -> CompleteStarsPurchasePaymentTask: + return CompleteStarsPurchasePaymentTask() - return tasks + @provide(scope=Scope.APP) + def provide_matchmake_tasks( + self, + envs: Envs, + logger: Annotated[FilteringBoundLogger, FromComponent("app")], + ) -> MatchmakeTasks: + return MatchmakeTasks( + _max_workers=envs.matchmaking_max_workers, + _worker_creation_interval_seconds=( + envs.matchmaking_worker_creation_interval_seconds + ), + _logger=logger, + ) + + @provide(scope=Scope.APP) + async def unkillable_task_group( + self, logger: Annotated[FilteringBoundLogger, FromComponent("app")], + ) -> AsyncIterator[UnkillableTaskGroup]: + async with UnkillableTaskGroup(logger) as group: + yield group @provide(scope=Scope.APP) - def provide_paid_stars_purchase_payment_buffer( + async def unkillable_tasks( self, - ) -> Buffer[PaidStarsPurchasePayment]: - return Buffer() + task_group: UnkillableTaskGroup, + auto_cancel_invitations_to_game_task: AutoCancelInvitationsToGameTask, + complete_stars_purchase_payment_task: CompleteStarsPurchasePaymentTask, + matchmake_tasks: MatchmakeTasks, + ) -> UnkillableTasks: + tasks = ( + auto_cancel_invitations_to_game_task, + complete_stars_purchase_payment_task, + matchmake_tasks, + ) + return UnkillableTasks(tasks, task_group) @provide(scope=Scope.APP) def provide_dp(self, storage: BaseStorage) -> Dispatcher: @@ -331,12 +370,10 @@ def provide_stars_purchase_payment_gateway( pre_checkout_query: PreCheckoutQuery | None, secrets: Secrets, bot: Bot, - buffer: Buffer[PaidStarsPurchasePayment], dialog_manager_for_user: DialogManagerForUser, ) -> StarsPurchasePaymentGateway: return AiogramPaymentGateway( pre_checkout_query, - buffer, bot, secrets.payments_token, dialog_manager_for_user, diff --git a/src/ttt/main/tg_bot/start_aiogram.py b/src/ttt/main/tg_bot/start_aiogram.py deleted file mode 100644 index b199078..0000000 --- a/src/ttt/main/tg_bot/start_aiogram.py +++ /dev/null @@ -1,50 +0,0 @@ -import logging -from functools import partial - -from aiogram import Bot, Dispatcher -from aiogram.types import TelegramObject -from dishka import AsyncContainer -from dishka.integrations.aiogram import ( - AiogramMiddlewareData, - ContainerMiddleware, -) - -from ttt.infrastructure.pydantic_settings.envs import Envs -from ttt.presentation.tasks.auto_cancel_invitation_to_game_task import ( - auto_cancel_invitation_to_game_task, -) -from ttt.presentation.tasks.matchmake_tasks import matchmake_tasks -from ttt.presentation.unkillable_tasks import UnkillableTasks - - -async def start_aiogram(container: AsyncContainer) -> None: - dp = await container.get(Dispatcher) - envs = await container.get(Envs) - - middleware = ContainerMiddleware(container) - - for observer in dp.observers.values(): - observer.middleware(middleware) - - context = {TelegramObject: None, AiogramMiddlewareData: None} - async with container(context) as request: - tasks = await request.get(UnkillableTasks) - tasks.add(partial(auto_cancel_invitation_to_game_task, container)) - tasks.add(partial( - matchmake_tasks, - container, - max_workers=envs.matchmaking_max_workers, - worker_creation_interval_seconds=( - envs.matchmaking_worker_creation_interval_seconds - ), - )) - - logging.basicConfig(level=logging.INFO) - - bot = await container.get(Bot) - - try: - async with tasks: - await dp.start_polling(bot) - finally: - await container.close() diff --git a/src/ttt/main/tg_bot/start_tg_bot.py b/src/ttt/main/tg_bot/start_tg_bot.py new file mode 100644 index 0000000..d17d4eb --- /dev/null +++ b/src/ttt/main/tg_bot/start_tg_bot.py @@ -0,0 +1,35 @@ +import logging +from functools import partial + +from aiogram import Bot, Dispatcher +from aiogram.types import TelegramObject +from dishka import AsyncContainer +from dishka.integrations.aiogram import ( + AiogramMiddlewareData, + ContainerMiddleware, +) + +from ttt.presentation.tasks.unkillable_tasks import UnkillableTasks + + +async def start_tg_bot(container: AsyncContainer) -> None: + dp = await container.get(Dispatcher) + middleware = ContainerMiddleware(container) + + for observer in dp.observers.values(): + observer.middleware(middleware) + + logging.basicConfig(level=logging.INFO) + + tasks = await container.get(UnkillableTasks) + next_container = partial( + container, {TelegramObject: None, AiogramMiddlewareData: None}, + ) + await tasks(next_container) + + bot = await container.get(Bot) + + try: + await dp.start_polling(bot) + finally: + await container.close() diff --git a/src/ttt/main/tg_bot_dev/__main__.py b/src/ttt/main/tg_bot_dev/__main__.py index 223015f..80cd6ef 100644 --- a/src/ttt/main/tg_bot_dev/__main__.py +++ b/src/ttt/main/tg_bot_dev/__main__.py @@ -3,16 +3,16 @@ from dishka import make_async_container from dishka.integrations.aiogram import AiogramProvider -from ttt.infrastructure.structlog.logger import ( - DevLoggerFactory, - LoggerFactory, -) from ttt.main.common.di import InfrastructureProvider from ttt.main.tg_bot.di import ( ApplicationProvider, PresentationProvider, ) -from ttt.main.tg_bot.start_aiogram import start_aiogram +from ttt.main.tg_bot.start_tg_bot import start_tg_bot +from ttt.main.tg_bot_dev.di import ( + DevTgBotAppLoggerProvider, + DevTgBotRequestLoggerProvider, +) async def amain() -> None: @@ -21,12 +21,11 @@ async def amain() -> None: ApplicationProvider(), PresentationProvider(), InfrastructureProvider(), - context={ - LoggerFactory: DevLoggerFactory(adds_request_id=True), - }, + DevTgBotRequestLoggerProvider(), + DevTgBotAppLoggerProvider(), ) - await start_aiogram(container) + await start_tg_bot(container) def main() -> None: diff --git a/src/ttt/main/tg_bot_dev/di.py b/src/ttt/main/tg_bot_dev/di.py new file mode 100644 index 0000000..42a951b --- /dev/null +++ b/src/ttt/main/tg_bot_dev/di.py @@ -0,0 +1,18 @@ +from dishka import Provider, Scope, provide +from structlog.types import FilteringBoundLogger + +from ttt.infrastructure.structlog.logger import dev_logger + + +class DevTgBotRequestLoggerProvider(Provider): + @provide(scope=Scope.REQUEST) + def provide_request_logger(self) -> FilteringBoundLogger: + return dev_logger(with_request_id=True) + + +class DevTgBotAppLoggerProvider(Provider): + component = "app" + + @provide(scope=Scope.APP) + def provide_app_logger(self) -> FilteringBoundLogger: + return dev_logger(with_request_id=False) diff --git a/src/ttt/main/tg_bot_prod/__main__.py b/src/ttt/main/tg_bot_prod/__main__.py index 76a46bf..be8ff15 100644 --- a/src/ttt/main/tg_bot_prod/__main__.py +++ b/src/ttt/main/tg_bot_prod/__main__.py @@ -6,13 +6,16 @@ from ttt import __version__ from ttt.infrastructure.pydantic_settings.secrets import Secrets -from ttt.infrastructure.structlog.logger import LoggerFactory, ProdLoggerFactory from ttt.main.common.di import InfrastructureProvider from ttt.main.tg_bot.di import ( ApplicationProvider, PresentationProvider, ) -from ttt.main.tg_bot.start_aiogram import start_aiogram +from ttt.main.tg_bot.start_tg_bot import start_tg_bot +from ttt.main.tg_bot_prod.di import ( + ProdTgBotAppLoggerProvider, + ProdTgBotRequestLoggerProvider, +) async def amain() -> None: @@ -21,15 +24,14 @@ async def amain() -> None: ApplicationProvider(), PresentationProvider(), InfrastructureProvider(), - context={ - LoggerFactory: ProdLoggerFactory(adds_request_id=True), - }, + ProdTgBotAppLoggerProvider(), + ProdTgBotRequestLoggerProvider(), ) secrets = await container.get(Secrets) sentry_sdk.init(dsn=secrets.sentry_dsn, release=__version__) - await start_aiogram(container) + await start_tg_bot(container) def main() -> None: diff --git a/src/ttt/main/tg_bot_prod/di.py b/src/ttt/main/tg_bot_prod/di.py new file mode 100644 index 0000000..540c54b --- /dev/null +++ b/src/ttt/main/tg_bot_prod/di.py @@ -0,0 +1,18 @@ +from dishka import Provider, Scope, provide +from structlog.types import FilteringBoundLogger + +from ttt.infrastructure.structlog.logger import prod_logger + + +class ProdTgBotRequestLoggerProvider(Provider): + @provide(scope=Scope.REQUEST) + def provide_request_logger(self) -> FilteringBoundLogger: + return prod_logger(with_request_id=True) + + +class ProdTgBotAppLoggerProvider(Provider): + component = "app" + + @provide(scope=Scope.APP) + def provide_app_logger(self) -> FilteringBoundLogger: + return prod_logger(with_request_id=False) diff --git a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py index ad84511..3b2b331 100644 --- a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py +++ b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py @@ -1,4 +1,3 @@ -from collections.abc import AsyncIterable from dataclasses import dataclass, field from uuid import UUID @@ -6,13 +5,11 @@ from aiogram.types import PreCheckoutQuery from aiogram_dialog import ShowMode, StartMode -from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 StarsPurchasePaymentGateway, ) from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase from ttt.entities.tools.assertion import not_none -from ttt.infrastructure.buffer import Buffer from ttt.presentation.aiogram.user.invoices import stars_invoce from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( DialogManagerForUser, @@ -23,7 +20,6 @@ @dataclass class AiogramPaymentGateway(StarsPurchasePaymentGateway): _pre_checkout_query: PreCheckoutQuery | None - _buffer: Buffer[PaidStarsPurchasePayment] _bot: Bot _payments_token: str = field(repr=False) _dialog_manager_for_user: DialogManagerForUser @@ -60,9 +56,3 @@ async def stop_payment_due_to_error(self, payment_id: UUID) -> None: ok=False, error_message=message, ) - - async def paid_payment_stream( - self, - ) -> AsyncIterable[PaidStarsPurchasePayment]: - async for payment in self._buffer.stream(): - yield payment diff --git a/src/ttt/presentation/aiogram/user/routes/handle_payment.py b/src/ttt/presentation/aiogram/user/routes/handle_payment.py index c90324c..2afed92 100644 --- a/src/ttt/presentation/aiogram/user/routes/handle_payment.py +++ b/src/ttt/presentation/aiogram/user/routes/handle_payment.py @@ -4,9 +4,11 @@ from dishka.integrations.aiogram import inject from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment +from ttt.application.stars_purchase.start_stars_purchase_payment_completion import ( # noqa: E501 + StartStarsPurchasePaymentCompletion, +) from ttt.entities.finance.payment.success import PaymentSuccess from ttt.entities.tools.assertion import not_none -from ttt.infrastructure.buffer import Buffer from ttt.presentation.aiogram.user.invoices import ( StarsPurchaseInvoicePayload, invoce_payload_adapter, @@ -22,26 +24,24 @@ async def _( message: Message, dishka_container: AsyncContainer, ) -> None: - payment = not_none(message.successful_payment) + aiogram_payment = not_none(message.successful_payment) success = PaymentSuccess( - payment.telegram_payment_charge_id, - payment.provider_payment_charge_id, + aiogram_payment.telegram_payment_charge_id, + aiogram_payment.provider_payment_charge_id, ) invoce_payload = invoce_payload_adapter.validate_json( - payment.invoice_payload, + aiogram_payment.invoice_payload, ) - parent_container = not_none(dishka_container.parent_container) match invoce_payload: case StarsPurchaseInvoicePayload(): - buffer = await parent_container.get( - Buffer[PaidStarsPurchasePayment], + start_stars_purchase_payment_completion = ( + await dishka_container.get(StartStarsPurchasePaymentCompletion) ) - buffer.add( - PaidStarsPurchasePayment( - invoce_payload.purchase_id, - invoce_payload.user_id, - success, - ), + payment = PaidStarsPurchasePayment( + invoce_payload.purchase_id, + invoce_payload.user_id, + success, ) + await start_stars_purchase_payment_completion(payment) diff --git a/src/ttt/presentation/tasks/auto_cancel_invitation_to_game_task.py b/src/ttt/presentation/tasks/auto_cancel_invitation_to_game_task.py deleted file mode 100644 index e0de920..0000000 --- a/src/ttt/presentation/tasks/auto_cancel_invitation_to_game_task.py +++ /dev/null @@ -1,17 +0,0 @@ -from asyncio import sleep - -from dishka import AsyncContainer - -from ttt.application.invitation_to_game.game.auto_cancel_invitations_to_game import ( # noqa: E501 - AutoCancelInvitationsToGame, -) - - -async def auto_cancel_invitation_to_game_task( - diska_container: AsyncContainer, -) -> None: - while True: - await sleep(1) - async with diska_container() as request: - cancel_invitations = await request.get(AutoCancelInvitationsToGame) - await cancel_invitations() diff --git a/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py b/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py new file mode 100644 index 0000000..ba5e7f5 --- /dev/null +++ b/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py @@ -0,0 +1,21 @@ +from asyncio import sleep +from dataclasses import dataclass + +from ttt.application.invitation_to_game.game.auto_cancel_invitations_to_game import ( # noqa: E501 + AutoCancelInvitationsToGame, +) +from ttt.presentation.tasks.task import NextContainer, Task + + +@dataclass(frozen=True) +class AutoCancelInvitationsToGameTask(Task): + _interval_seconds: float + + async def __call__(self, container: NextContainer) -> None: + while True: + await sleep(self._interval_seconds) + async with container() as request: + cancel_invitations = await request.get( + AutoCancelInvitationsToGame, + ) + await cancel_invitations() diff --git a/src/ttt/presentation/tasks/complete_stars_purchase_payment_task.py b/src/ttt/presentation/tasks/complete_stars_purchase_payment_task.py new file mode 100644 index 0000000..78d78b8 --- /dev/null +++ b/src/ttt/presentation/tasks/complete_stars_purchase_payment_task.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass + +from ttt.application.stars_purchase.complete_stars_purchase_payment import ( + CompleteStarsPurchasePayment, +) +from ttt.presentation.tasks.task import NextContainer, Task + + +@dataclass(frozen=True) +class CompleteStarsPurchasePaymentTask(Task): + async def __call__(self, container: NextContainer) -> None: + while True: + async with container() as request: + complete_stars_purchase_payment = await request.get( + CompleteStarsPurchasePayment, + ) + await complete_stars_purchase_payment() diff --git a/src/ttt/presentation/tasks/matchmake_tasks.py b/src/ttt/presentation/tasks/matchmake_tasks.py index 92c1b48..5d7b06d 100644 --- a/src/ttt/presentation/tasks/matchmake_tasks.py +++ b/src/ttt/presentation/tasks/matchmake_tasks.py @@ -1,30 +1,36 @@ from asyncio import Semaphore, TaskGroup, sleep +from dataclasses import dataclass, field -from aiogram.types import TelegramObject -from dishka import AsyncContainer -from dishka.integrations.aiogram import AiogramMiddlewareData +from structlog.types import FilteringBoundLogger from ttt.application.user.game.matchmake import Matchmake +from ttt.infrastructure.structlog.logger import unexpected_error_log +from ttt.presentation.tasks.task import NextContainer, Task -async def matchmake_tasks( - diska_container: AsyncContainer, - max_workers: int, - worker_creation_interval_seconds: float, -) -> None: - semaphore = Semaphore(max_workers) +@dataclass +class MatchmakeTasks(Task): + _max_workers: int + _worker_creation_interval_seconds: float + _logger: FilteringBoundLogger - async with TaskGroup() as tasks: - while True: - await sleep(worker_creation_interval_seconds) - if not semaphore.locked(): - tasks.create_task(_matchmake_task(diska_container, semaphore)) + _semaphore: Semaphore = field(init=False) + _task_group: TaskGroup = field(init=False, default_factory=TaskGroup) + def __post_init__(self) -> None: + self._semaphore = Semaphore(self._max_workers) -async def _matchmake_task( - diska_container: AsyncContainer, semaphore: Semaphore, -) -> None: - context = {TelegramObject: None, AiogramMiddlewareData: None} - async with semaphore, diska_container(context) as request: - matchmake = await request.get(Matchmake) - await matchmake() + async def __call__(self, container: NextContainer) -> None: + async with self._task_group: + while True: + await sleep(self._worker_creation_interval_seconds) + if not self._semaphore.locked(): + self._task_group.create_task(self._matchmake(container)) + + async def _matchmake(self, container: NextContainer) -> None: + try: + async with self._semaphore, container() as request: + matchmake = await request.get(Matchmake) + await matchmake() + except Exception as error: # noqa: BLE001 + await unexpected_error_log(self._logger, error) diff --git a/src/ttt/presentation/tasks/task.py b/src/ttt/presentation/tasks/task.py new file mode 100644 index 0000000..21e6830 --- /dev/null +++ b/src/ttt/presentation/tasks/task.py @@ -0,0 +1,11 @@ +from collections.abc import Callable +from typing import Any, Protocol + +from dishka.async_container import AsyncContextWrapper + + +type NextContainer = Callable[[], AsyncContextWrapper] + + +class Task(Protocol): + async def __call__(self, container: NextContainer, /) -> Any: ... # noqa: ANN401 diff --git a/src/ttt/presentation/tasks/unkillable_tasks.py b/src/ttt/presentation/tasks/unkillable_tasks.py new file mode 100644 index 0000000..33567d3 --- /dev/null +++ b/src/ttt/presentation/tasks/unkillable_tasks.py @@ -0,0 +1,16 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from functools import partial + +from ttt.presentation.tasks.task import NextContainer, Task +from ttt.presentation.unkillable_task_group import UnkillableTaskGroup + + +@dataclass(frozen=True, unsafe_hash=False) +class UnkillableTasks(Task): + _tasks: Sequence[Task] + _group: UnkillableTaskGroup + + async def __call__(self, container: NextContainer) -> None: + for task in self._tasks: + self._group.add(partial(task, container)) diff --git a/src/ttt/presentation/unkillable_tasks.py b/src/ttt/presentation/unkillable_task_group.py similarity index 95% rename from src/ttt/presentation/unkillable_tasks.py rename to src/ttt/presentation/unkillable_task_group.py index 1dfae95..90de6f4 100644 --- a/src/ttt/presentation/unkillable_tasks.py +++ b/src/ttt/presentation/unkillable_task_group.py @@ -10,7 +10,7 @@ @dataclass(frozen=True, unsafe_hash=False) -class UnkillableTasks: +class UnkillableTaskGroup: _logger: FilteringBoundLogger _loop: asyncio.AbstractEventLoop = field( init=False, From bd6a9e401da0ba1dbc12bbde49a0791fe09c2888 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Fri, 19 Sep 2025 19:41:35 +0700 Subject: [PATCH 19/45] chore(`infrastructure`): don't index `null`s --- .../7ce97d6efd2c_don_t_index_null_s.py | 90 +++++++++++++++++++ .../infrastructure/sqlalchemy/tables/game.py | 15 +++- .../infrastructure/sqlalchemy/tables/user.py | 12 ++- 3 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 src/ttt/infrastructure/alembic/versions/7ce97d6efd2c_don_t_index_null_s.py diff --git a/src/ttt/infrastructure/alembic/versions/7ce97d6efd2c_don_t_index_null_s.py b/src/ttt/infrastructure/alembic/versions/7ce97d6efd2c_don_t_index_null_s.py new file mode 100644 index 0000000..6617a1f --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/7ce97d6efd2c_don_t_index_null_s.py @@ -0,0 +1,90 @@ +""" +don't index `null`s. + +Revision ID: 7ce97d6efd2c +Revises: ba0f7132baef +Create Date: 2025-09-19 11:58:53.188572 + +""" + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "7ce97d6efd2c" +down_revision: str | None = "ba0f7132baef" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_index(op.f("ix_users_selected_emoji_id"), table_name="users") + op.create_index( + op.f("ix_users_selected_emoji_id"), + "users", + ["selected_emoji_id"], + unique=False, + postgresql_where="(selected_emoji_id IS NOT NULL)", + ) + + op.drop_index(op.f("ix_users_current_game_id"), table_name="users") + op.create_index( + op.f("ix_users_current_game_id"), + "users", + ["current_game_id"], + unique=False, + postgresql_where="(current_game_id IS NOT NULL)", + ) + + op.drop_index(op.f("ix_cells_user_filler_id"), table_name="cells") + op.create_index( + op.f("ix_cells_user_filler_id"), + "cells", + ["user_filler_id"], + unique=False, + postgresql_where="(user_filler_id IS NOT NULL)", + ) + + op.drop_index(op.f("ix_cells_ai_filler_id"), table_name="cells") + op.create_index( + op.f("ix_cells_ai_filler_id"), + "cells", + ["ai_filler_id"], + unique=False, + postgresql_where="(ai_filler_id IS NOT NULL)", + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_users_selected_emoji_id"), table_name="users") + op.create_index( + op.f("ix_users_selected_emoji_id"), + "users", + ["selected_emoji_id"], + unique=False, + ) + + op.drop_index(op.f("ix_users_current_game_id"), table_name="users") + op.create_index( + op.f("ix_users_current_game_id"), + "users", + ["current_game_id"], + unique=False, + ) + + op.drop_index(op.f("ix_cells_user_filler_id"), table_name="cells") + op.create_index( + op.f("ix_cells_user_filler_id"), + "cells", + ["user_filler_id"], + unique=False, + ) + + op.drop_index(op.f("ix_cells_ai_filler_id"), table_name="cells") + op.create_index( + op.f("ix_cells_ai_filler_id"), + "cells", + ["ai_filler_id"], + unique=False, + ) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/game.py b/src/ttt/infrastructure/sqlalchemy/tables/game.py index 8181052..8d40286 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/game.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/game.py @@ -86,11 +86,22 @@ class TableCell(Base[Cell]): user_filler_id: Mapped[int | None] = mapped_column( BigInteger(), ForeignKey("users.id", deferrable=True, initially="DEFERRED"), - index=True, ) ai_filler_id: Mapped[UUID | None] = mapped_column( ForeignKey("ais.id", deferrable=True, initially="DEFERRED"), - index=True, + ) + + __table_args__ = ( + Index( + "ix_cells_user_filler_id", + user_filler_id, + postgresql_where=(user_filler_id.is_not(None)), + ), + Index( + "ix_cells_ai_filler_id", + ai_filler_id, + postgresql_where=(ai_filler_id.is_not(None)), + ), ) def __entity__(self) -> Cell: diff --git a/src/ttt/infrastructure/sqlalchemy/tables/user.py b/src/ttt/infrastructure/sqlalchemy/tables/user.py index b3cadc3..5d46936 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/user.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/user.py @@ -79,12 +79,10 @@ class TableUser(Base[User]): account_stars: Mapped[int] = mapped_column(server_default="0") selected_emoji_id: Mapped[UUID | None] = mapped_column( ForeignKey("user_emojis.id", deferrable=True, initially="DEFERRED"), - index=True, ) rating: Mapped[float] current_game_id: Mapped[UUID | None] = mapped_column( ForeignKey("games.id", deferrable=True, initially="DEFERRED"), - index=True, ) admin_right: Mapped[TableAdminRight | None] = mapped_column(admin_right) admin_right_via_other_admin_admin_id: Mapped[int | None] = mapped_column( @@ -101,6 +99,16 @@ class TableUser(Base[User]): ) __table_args__ = ( + Index( + "ix_users_selected_emoji_id", + selected_emoji_id, + postgresql_where=(selected_emoji_id.is_not(None)), + ), + Index( + "ix_users_current_game_id", + current_game_id, + postgresql_where=(current_game_id.is_not(None)), + ), Index( "ix_users_admin_right_via_other_admin_admin_id", admin_right_via_other_admin_admin_id, From f0ab80a7c082b20b0a0e54ff3a591be302791ac4 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Sat, 20 Sep 2025 17:12:40 +0700 Subject: [PATCH 20/45] feat: add strongest user rank (#57) --- .../application/user/common/ports/users.py | 7 +++ src/ttt/application/user/game/matchmake.py | 7 ++- src/ttt/entities/core/user/matchmaking.py | 18 +++++-- src/ttt/entities/core/user/rank.py | 53 +++++++++++++++---- src/ttt/entities/core/user/user.py | 8 +-- src/ttt/infrastructure/adapters/users.py | 12 +++++ .../6642791b3bf8_index_users_rating.py | 30 +++++++++++ src/ttt/infrastructure/sqlalchemy/stmts.py | 30 ++++++++++- .../infrastructure/sqlalchemy/tables/user.py | 2 +- src/ttt/presentation/adapters/user_views.py | 24 +++++++++ .../admin_dialog/other_user_profile_window.py | 8 ++- .../aiogram_dialog/main_dialog/main_window.py | 10 ++-- .../main_dialog/profile_window.py | 10 ++-- src/ttt/presentation/texts.py | 28 +++++----- 14 files changed, 206 insertions(+), 41 deletions(-) create mode 100644 src/ttt/infrastructure/alembic/versions/6642791b3bf8_index_users_rating.py diff --git a/src/ttt/application/user/common/ports/users.py b/src/ttt/application/user/common/ports/users.py index 9c1a972..4dab335 100644 --- a/src/ttt/application/user/common/ports/users.py +++ b/src/ttt/application/user/common/ports/users.py @@ -2,7 +2,9 @@ from collections.abc import Sequence from typing import overload +from ttt.entities.core.user.rank import UsersWithMaxRating from ttt.entities.core.user.user import User +from ttt.entities.elo.rating import EloRating class Users(ABC): @@ -43,3 +45,8 @@ async def users_with_ids( async def some_users_waiting_for_matchmaking_to_matchmake( self, ) -> list[User]: ... + + @abstractmethod + async def max_rating_and_users_with_max_rating( + self, + ) -> tuple[EloRating, UsersWithMaxRating]: ... diff --git a/src/ttt/application/user/game/matchmake.py b/src/ttt/application/user/game/matchmake.py index 7a9af08..eddcc10 100644 --- a/src/ttt/application/user/game/matchmake.py +++ b/src/ttt/application/user/game/matchmake.py @@ -40,9 +40,14 @@ async def _result(self, tracking: Tracking) -> list[Game]: self.users.some_users_waiting_for_matchmaking_to_matchmake(), self._matchmaking_input(), ) + max_rating, users_with_max_rating = ( + await self.users.max_rating_and_users_with_max_rating() + ) games = list[Game]() - matchmaking_ = matchmaking(users, input_, tracking) + matchmaking_ = matchmaking( + users, input_, max_rating, users_with_max_rating, tracking, + ) with suppress(StopIteration): games.append(next(matchmaking_)) diff --git a/src/ttt/entities/core/user/matchmaking.py b/src/ttt/entities/core/user/matchmaking.py index 84d308d..42299c6 100644 --- a/src/ttt/entities/core/user/matchmaking.py +++ b/src/ttt/entities/core/user/matchmaking.py @@ -3,8 +3,9 @@ from uuid import UUID from ttt.entities.core.game.game import Game, start_game -from ttt.entities.core.user.rank import are_ranks_adjacent +from ttt.entities.core.user.rank import UsersWithMaxRating, are_ranks_adjacent from ttt.entities.core.user.user import User +from ttt.entities.elo.rating import EloRating from ttt.entities.math.matrix import Matrix from ttt.entities.text.emoji import Emoji from ttt.entities.tools.combinations import Combinations @@ -27,6 +28,8 @@ class UsersAreNotWaitingForMatchmakingError(Exception): def matchmaking( users: list[User], input_: MatchmakingInput, + max_rating: EloRating, + users_with_max_rating: UsersWithMaxRating, tracking: Tracking, ) -> Generator[Game, MatchmakingInput]: """ @@ -52,7 +55,7 @@ def matchmaking( combinations = Combinations(users) for user1, user2 in combinations: - if _is_game_allowed(user1, user2): + if _is_game_allowed(user1, user2, max_rating, users_with_max_rating): user1.dont_wait_for_matchmaking(tracking) user2.dont_wait_for_matchmaking(tracking) combinations.cut() @@ -69,8 +72,13 @@ def matchmaking( input_ = yield game -def _is_game_allowed(user1: User, user2: User) -> bool: - rank1 = user1.rank() - rank2 = user2.rank() +def _is_game_allowed( + user1: User, + user2: User, + max_rating: EloRating, + users_with_max_rating: UsersWithMaxRating, +) -> bool: + rank1 = user1.rank(max_rating, users_with_max_rating) + rank2 = user2.rank(max_rating, users_with_max_rating) return rank1 == rank2 or are_ranks_adjacent(rank1, rank2) diff --git a/src/ttt/entities/core/user/rank.py b/src/ttt/entities/core/user/rank.py index f225931..aa1f878 100644 --- a/src/ttt/entities/core/user/rank.py +++ b/src/ttt/entities/core/user/rank.py @@ -5,23 +5,46 @@ from ttt.entities.elo.rating import EloRating -type RankTier = Literal[-1, 0, 1, 2, 3, 4] +type RankTier = Literal[-1, 0, 1, 2, 3, 4, 5] @dataclass(frozen=True) -class Rank: +class IntervalRank: tier: RankTier min_rating: EloRating max_rating: EloRating +@dataclass(frozen=True) +class SpecialRank[TierT: RankTier, NameT: str]: + tier: TierT + name: NameT + + +StrongestRank = SpecialRank[Literal[5], Literal["strongest"]] +strongest_rank = StrongestRank(tier=5, name="strongest") + +special_ranks = ( + strongest_rank, +) + + +type Rank = IntervalRank | StrongestRank + + +interval_ranks = ( + IntervalRank(tier=-1, min_rating=-math.inf, max_rating=871), + IntervalRank(tier=0, min_rating=872, max_rating=1085), + IntervalRank(tier=1, min_rating=1086, max_rating=1336), + IntervalRank(tier=2, min_rating=1337, max_rating=1679), + IntervalRank(tier=3, min_rating=1680, max_rating=1999), + IntervalRank(tier=4, min_rating=2000, max_rating=math.inf), +) + + ranks = ( - Rank(tier=-1, min_rating=-math.inf, max_rating=871), - Rank(tier=0, min_rating=872, max_rating=1085), - Rank(tier=1, min_rating=1086, max_rating=1336), - Rank(tier=2, min_rating=1337, max_rating=1679), - Rank(tier=3, min_rating=1680, max_rating=1999), - Rank(tier=4, min_rating=2000, max_rating=math.inf), + *interval_ranks, + *special_ranks, ) @@ -33,8 +56,18 @@ def rank_with_tier(tier: RankTier) -> Rank: raise ValueError -def rank_for_rating(rating: EloRating) -> Rank: - for rank in ranks: +type UsersWithMaxRating = Literal["1", ">1"] + + +def rank( + rating: EloRating, + max_rating: EloRating, + users_with_max_rating: UsersWithMaxRating, +) -> Rank: + if rating == max_rating and users_with_max_rating == "1": + return strongest_rank + + for rank in interval_ranks: if rank.min_rating <= rating <= rank.max_rating: return rank diff --git a/src/ttt/entities/core/user/user.py b/src/ttt/entities/core/user/user.py index 3766a88..6aec231 100644 --- a/src/ttt/entities/core/user/user.py +++ b/src/ttt/entities/core/user/user.py @@ -14,7 +14,7 @@ from ttt.entities.core.user.emoji import UserEmoji from ttt.entities.core.user.loss import UserLoss from ttt.entities.core.user.matchmaking_waiting import MatchmakingWaiting -from ttt.entities.core.user.rank import Rank, rank_for_rating +from ttt.entities.core.user.rank import Rank, UsersWithMaxRating, rank from ttt.entities.core.user.win import UserWin from ttt.entities.elo.rating import ( EloRating, @@ -97,8 +97,10 @@ class User: emoji_cost: ClassVar[Stars] = 1000 - def rank(self) -> Rank: - return rank_for_rating(self.rating) + def rank( + self, max_rating: EloRating, users_with_max_rating: UsersWithMaxRating, + ) -> Rank: + return rank(self.rating, max_rating, users_with_max_rating) def is_admin(self) -> bool: return is_user_admin(self.admin_right) diff --git a/src/ttt/infrastructure/adapters/users.py b/src/ttt/infrastructure/adapters/users.py index 02eab7e..ef7ccf4 100644 --- a/src/ttt/infrastructure/adapters/users.py +++ b/src/ttt/infrastructure/adapters/users.py @@ -6,7 +6,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from ttt.application.user.common.ports.users import Users +from ttt.entities.core.user.rank import UsersWithMaxRating from ttt.entities.core.user.user import User +from ttt.entities.elo.rating import EloRating +from ttt.infrastructure.sqlalchemy.stmts import ( + max_rating_and_users_with_max_rating_from_postgres, +) from ttt.infrastructure.sqlalchemy.tables.user import TableUser @@ -69,3 +74,10 @@ async def some_users_waiting_for_matchmaking_to_matchmake( table_users = result.all() return [table_user.entity() for table_user in table_users] + + async def max_rating_and_users_with_max_rating( + self, + ) -> tuple[EloRating, UsersWithMaxRating]: + return await max_rating_and_users_with_max_rating_from_postgres( + self._session, + ) diff --git a/src/ttt/infrastructure/alembic/versions/6642791b3bf8_index_users_rating.py b/src/ttt/infrastructure/alembic/versions/6642791b3bf8_index_users_rating.py new file mode 100644 index 0000000..9ea34d9 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/6642791b3bf8_index_users_rating.py @@ -0,0 +1,30 @@ +""" +index `users.rating`. + +Revision ID: 6642791b3bf8 +Revises: 7ce97d6efd2c +Create Date: 2025-09-20 08:16:00.242481 + +""" + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "6642791b3bf8" +down_revision: str | None = "7ce97d6efd2c" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f("ix_users_rating"), "users", ["rating"], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_users_rating"), table_name="users") + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/sqlalchemy/stmts.py b/src/ttt/infrastructure/sqlalchemy/stmts.py index a98ae9a..681e799 100644 --- a/src/ttt/infrastructure/sqlalchemy/stmts.py +++ b/src/ttt/infrastructure/sqlalchemy/stmts.py @@ -1,9 +1,12 @@ from collections.abc import Sequence from typing import cast -from sqlalchemy import exists, select +from sqlalchemy import exists, func, select from sqlalchemy.ext.asyncio import AsyncSession +from ttt.entities.core.user.rank import UsersWithMaxRating +from ttt.entities.elo.rating import EloRating +from ttt.entities.tools.assertion import not_none from ttt.infrastructure.sqlalchemy.tables.user import TableUser, TableUserEmoji @@ -39,3 +42,28 @@ async def selected_user_emoji_str_from_postgres( async def user_exists_in_postgres(session: AsyncSession, user_id: int) -> bool: stmt = select(exists(1).where(TableUser.id == user_id)) return bool(await session.scalar(stmt)) + + +async def max_rating_and_users_with_max_rating_from_postgres( + session: AsyncSession, +) -> tuple[EloRating, UsersWithMaxRating]: + max_rating_stmt = select(func.max(TableUser.rating)) + max_rating = not_none(await session.scalar(max_rating_stmt)) + + raw_users_with_max_rating_stmt = ( + select(func.count(1)) + .select_from( + select(1) + .where(TableUser.rating == max_rating) + .limit(2) + .subquery(), + ) + ) + raw_users_with_max_rating = not_none( + await session.scalar(raw_users_with_max_rating_stmt), + ) + users_with_max_rating: UsersWithMaxRating = ( + "1" if raw_users_with_max_rating == 1 else ">1" + ) + + return max_rating, users_with_max_rating diff --git a/src/ttt/infrastructure/sqlalchemy/tables/user.py b/src/ttt/infrastructure/sqlalchemy/tables/user.py index 5d46936..4bc831c 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/user.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/user.py @@ -80,7 +80,7 @@ class TableUser(Base[User]): selected_emoji_id: Mapped[UUID | None] = mapped_column( ForeignKey("user_emojis.id", deferrable=True, initially="DEFERRED"), ) - rating: Mapped[float] + rating: Mapped[float] = mapped_column(index=True) current_game_id: Mapped[UUID | None] = mapped_column( ForeignKey("games.id", deferrable=True, initially="DEFERRED"), ) diff --git a/src/ttt/presentation/adapters/user_views.py b/src/ttt/presentation/adapters/user_views.py index 24d500c..90f669b 100644 --- a/src/ttt/presentation/adapters/user_views.py +++ b/src/ttt/presentation/adapters/user_views.py @@ -24,6 +24,7 @@ from ttt.entities.core.user.user import User, is_user_in_game, user_stars from ttt.entities.tools.assertion import not_none from ttt.infrastructure.sqlalchemy.stmts import ( + max_rating_and_users_with_max_rating_from_postgres, selected_user_emoji_str_from_postgres, user_emojis_from_postgres, ) @@ -121,12 +122,20 @@ async def view_of_user_with_id( ) defeats = not_none(await self._session.scalar(defeats_stmt)) + max_rating, users_with_max_rating = ( + await max_rating_and_users_with_max_rating_from_postgres( + self._session, + ) + ) + view = UserProfileView.of( wins, draws, defeats, user_row.account_stars, user_row.rating, + max_rating, + users_with_max_rating, ) self._result_buffer.result = view @@ -177,6 +186,11 @@ async def user_menu_view(self, user_id: int, /) -> None: else: amout_of_incoming_invitations_to_game = "many" + max_rating, users_with_max_rating = ( + await max_rating_and_users_with_max_rating_from_postgres( + self._session, + ) + ) view = MainMenuView( is_user_in_game=is_user_in_game(row.current_game_id), has_user_emojis=row.has_user_emojis, @@ -185,6 +199,8 @@ async def user_menu_view(self, user_id: int, /) -> None: amout_of_incoming_invitations_to_game=( amout_of_incoming_invitations_to_game ), + max_rating=max_rating, + users_with_max_rating=users_with_max_rating, ) self._result_buffer.result = view @@ -387,6 +403,12 @@ async def other_user_view(self, user: User, other_user_id: int, /) -> None: row.admin_right_via_other_admin_admin_id, ) + max_rating, users_with_max_rating = ( + await max_rating_and_users_with_max_rating_from_postgres( + self._session, + ) + ) + view = OtherUserProfileView.of( other_user_id, admin_right, @@ -395,6 +417,8 @@ async def other_user_view(self, user: User, other_user_id: int, /) -> None: defeats, row.account_stars, row.rating, + max_rating, + users_with_max_rating, ) manager = self._dialog_manager_for_user(user.id) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py index c525233..0903491 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py @@ -15,7 +15,7 @@ from ttt.application.user.view_other_user import ViewOtherUser from ttt.entities.core.user.admin_right import AdminRight -from ttt.entities.core.user.rank import rank_for_rating +from ttt.entities.core.user.rank import UsersWithMaxRating, rank from ttt.entities.tools.assertion import not_none from ttt.presentation.aiogram_dialog.admin_dialog.common import ( AdminDialogState, @@ -57,6 +57,8 @@ def of( # noqa: PLR0913, PLR0917 number_of_defeats: int, account_stars: int, rating: float, + max_rating: float, + users_with_max_rating: UsersWithMaxRating, ) -> "OtherUserProfileView": return OtherUserProfileView( id_=id_, @@ -67,7 +69,9 @@ def of( # noqa: PLR0913, PLR0917 number_of_defeats=number_of_defeats, account_stars=account_stars, rating_text=short_float_text(rating), - rank_text=rank_title(rank_for_rating(rating)), + rank_text=rank_title( + rank(rating, max_rating, users_with_max_rating), + ), ) diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py index 322ff3e..780ea97 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py @@ -19,7 +19,7 @@ ) from ttt.application.user.view_main_menu import ViewMainMenu from ttt.entities.core.stars import Stars -from ttt.entities.core.user.rank import rank_for_rating +from ttt.entities.core.user.rank import UsersWithMaxRating, rank from ttt.entities.elo.rating import EloRating from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.func_text import FuncText @@ -50,6 +50,8 @@ class MainMenuView(EncodableToWindowData): is_user_in_game: bool has_user_emojis: bool rating: EloRating + max_rating: EloRating + users_with_max_rating: UsersWithMaxRating stars: Stars amout_of_incoming_invitations_to_game: AmoutOfIncomingInvitationsToGame @@ -59,9 +61,11 @@ async def rank_text( # noqa: RUF029 _: DialogManager, ) -> str: rating = data["main"]["rating"] - rank = rank_for_rating(rating) + max_rating = data["main"]["max_rating"] + users_with_max_rating = data["main"]["users_with_max_rating"] + rank_ = rank(rating, max_rating, users_with_max_rating) - return f"Вы — {rank_title(rank)} {rank_progres_text(rating)}" + return f"Вы — {rank_title(rank_)} {rank_progres_text(rank_, rating)}" @inject diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py index d8d50f6..7c1ba14 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py @@ -11,7 +11,7 @@ from dishka.integrations.aiogram_dialog import inject from ttt.application.user.view_user import ViewUser -from ttt.entities.core.user.rank import rank_for_rating +from ttt.entities.core.user.rank import UsersWithMaxRating, rank from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState from ttt.presentation.result_buffer import ResultBuffer @@ -31,13 +31,15 @@ class UserProfileView(EncodableToWindowData): rank_text: str @classmethod - def of( + def of( # noqa: PLR0913, PLR0917 cls, number_of_wins: int, number_of_draws: int, number_of_defeats: int, account_stars: int, rating: float, + max_rating: float, + users_with_max_rating: UsersWithMaxRating, ) -> "UserProfileView": return UserProfileView( number_of_wins=number_of_wins, @@ -45,7 +47,9 @@ def of( number_of_defeats=number_of_defeats, account_stars=account_stars, rating_text=short_float_text(rating), - rank_text=rank_title(rank_for_rating(rating)), + rank_text=rank_title( + rank(rating, max_rating, users_with_max_rating), + ), ) diff --git a/src/ttt/presentation/texts.py b/src/ttt/presentation/texts.py index fc4d0c6..fa6bf86 100644 --- a/src/ttt/presentation/texts.py +++ b/src/ttt/presentation/texts.py @@ -1,4 +1,4 @@ -from ttt.entities.core.user.rank import Rank, rank_for_rating, rank_with_tier +from ttt.entities.core.user.rank import IntervalRank, Rank, rank_with_tier from ttt.entities.elo.rating import EloRating @@ -10,8 +10,8 @@ def copy_signed_text(text: str, original_signed: float) -> str: return f"+{text}" if original_signed >= 0 else f"{text}" -def rank_sign(rank: Rank) -> str: - match rank.tier: +def rank_sign(rank_: Rank) -> str: # noqa: PLR0911 + match rank_.tier: case -1: return "🪨" case 0: @@ -24,10 +24,12 @@ def rank_sign(rank: Rank) -> str: return "👹" case 4: return "🪬" + case 5: + return "⚪️" -def rank_name(rank: Rank) -> str: - match rank.tier: +def rank_name(rank_: Rank) -> str: # noqa: PLR0911 + match rank_.tier: case -1: return "Камень" case 0: @@ -40,18 +42,20 @@ def rank_name(rank: Rank) -> str: return "Демон" case 4: return "Око" + case 5: + return "Сильнейший" -def rank_title(rank: Rank) -> str: - return f"{rank_sign(rank)} {rank_name(rank)}" +def rank_title(rank_: Rank) -> str: + return f"{rank_sign(rank_)} {rank_name(rank_)}" -def rank_progres_text(raiting: EloRating) -> str: - rank = rank_for_rating(raiting) - - if rank.tier == 4: # noqa: PLR2004 +def rank_progres_text(rank_: Rank, raiting: EloRating) -> str: + if rank_.tier == 4 or rank_.tier == 5: # noqa: PLR1714, PLR2004 return f"({short_float_text(raiting)})" - next_rank = rank_with_tier(rank.tier + 1) # type: ignore[arg-type] + next_rank = rank_with_tier(rank_.tier + 1) # type: ignore[arg-type] + if not isinstance(next_rank, IntervalRank): + raise TypeError return f"({short_float_text(raiting)} / {next_rank.min_rating})" From 0a12c0af5a030985f6026b8d4613aeaf7534bf77 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Sun, 21 Sep 2025 20:55:10 +0700 Subject: [PATCH 21/45] fix(`application`): use `ports` for serializable transactions (#62) --- .../dto => common/errors}/__init__.py | 0 .../common/errors/serialization_error.py | 1 + src/ttt/application/common/ports/map.py | 1 + .../application/common/ports/transaction.py | 30 ++++++- src/ttt/application/game/game/cancel_game.py | 21 +++-- .../game/game/make_ai_move_in_game.py | 79 ++++++++++++++++ .../game/game/make_move_in_game.py | 49 +++++----- .../application/game/game/ports/game_log.py | 57 +++++++++++- .../application/game/game/ports/game_tasks.py | 12 +++ .../application/game/game/ports/game_views.py | 10 ++- src/ttt/application/game/game/ports/games.py | 4 + .../game/game/start_game_with_ai.py | 45 ++++------ src/ttt/application/game/game/view_game.py | 7 +- .../game/accpet_invitation_to_game.py | 13 ++- .../game/auto_cancel_invitations_to_game.py | 8 +- .../game/cancel_invitation_to_game.py | 12 ++- .../invitation_to_game/game/invite_to_game.py | 12 ++- .../game/ports/invitation_to_game_dao.py | 5 +- .../game/reject_invitation_to_game.py | 12 ++- .../game/view_incoming_invitation_to_game.py | 4 +- .../game/view_incoming_invitations_to_game.py | 4 +- .../view_one_incoming_invitation_to_game.py | 4 +- .../view_outcoming_invitations_to_game.py | 7 +- .../complete_stars_purchase_payment.py | 89 ++++++++++--------- .../application/stars_purchase/dto/common.py | 11 --- .../paid_stars_purchase_payment_inbox.py | 12 --- .../ports/stars_purchase_log.py | 10 ++- .../ports/stars_purchase_tasks.py | 14 +++ .../stars_purchase/start_stars_purchase.py | 12 ++- .../start_stars_purchase_payment.py | 22 +++-- ...start_stars_purchase_payment_completion.py | 26 +++--- .../application/user/authorize_as_admin.py | 12 ++- .../user/authorize_other_user_as_admin.py | 12 ++- .../change_other_user_account.py | 12 ++- .../set_other_user_account.py | 11 ++- .../view_user_account_to_change.py | 4 +- .../user/deauthorize_other_user_as_admin.py | 11 ++- .../user/emoji_purchase/buy_emoji.py | 13 ++- .../user/emoji_selection/select_emoji.py | 12 ++- .../user/game/dont_wait_for_matchmaking.py | 12 ++- src/ttt/application/user/game/matchmake.py | 13 ++- .../application/user/game/view_matchmaking.py | 4 +- .../user/game/wait_for_matchmaking.py | 13 ++- src/ttt/application/user/register_user.py | 8 +- .../user/relinquish_admin_right.py | 11 ++- src/ttt/application/user/view_admin_menu.py | 4 +- src/ttt/application/user/view_main_menu.py | 4 +- src/ttt/application/user/view_other_user.py | 7 +- src/ttt/application/user/view_user.py | 4 +- src/ttt/application/user/view_user_emojis.py | 4 +- src/ttt/main/common/di.py | 4 +- .../stars_purchase_payment_gateway.py | 5 ++ 52 files changed, 550 insertions(+), 223 deletions(-) rename src/ttt/application/{stars_purchase/dto => common/errors}/__init__.py (100%) create mode 100644 src/ttt/application/common/errors/serialization_error.py create mode 100644 src/ttt/application/game/game/make_ai_move_in_game.py create mode 100644 src/ttt/application/game/game/ports/game_tasks.py delete mode 100644 src/ttt/application/stars_purchase/dto/common.py delete mode 100644 src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py create mode 100644 src/ttt/application/stars_purchase/ports/stars_purchase_tasks.py diff --git a/src/ttt/application/stars_purchase/dto/__init__.py b/src/ttt/application/common/errors/__init__.py similarity index 100% rename from src/ttt/application/stars_purchase/dto/__init__.py rename to src/ttt/application/common/errors/__init__.py diff --git a/src/ttt/application/common/errors/serialization_error.py b/src/ttt/application/common/errors/serialization_error.py new file mode 100644 index 0000000..107e3d3 --- /dev/null +++ b/src/ttt/application/common/errors/serialization_error.py @@ -0,0 +1 @@ +class SerializationError(Exception): ... diff --git a/src/ttt/application/common/ports/map.py b/src/ttt/application/common/ports/map.py index 6a46f8a..87fb8d9 100644 --- a/src/ttt/application/common/ports/map.py +++ b/src/ttt/application/common/ports/map.py @@ -23,4 +23,5 @@ async def __call__( """ :raises ttt.application.common.ports.map.NotUniqueUserIdError: :raises ttt.application.common.ports.map.NotUniqueActiveInvitationToGameUserIdsError: + :raises ttt.application.common.errors.serialization_error.SerializationError: """ # noqa: E501 diff --git a/src/ttt/application/common/ports/transaction.py b/src/ttt/application/common/ports/transaction.py index 1a8f423..85fc329 100644 --- a/src/ttt/application/common/ports/transaction.py +++ b/src/ttt/application/common/ports/transaction.py @@ -1,5 +1,33 @@ +from abc import ABC, abstractmethod from contextlib import AbstractAsyncContextManager +from types import TracebackType from typing import Any -Transaction = AbstractAsyncContextManager[Any] +class SerializableTransaction(AbstractAsyncContextManager[Any], ABC): + @abstractmethod + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + + return None + + @abstractmethod + async def commit(self) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + + +class NotSerializableTransaction(AbstractAsyncContextManager[Any], ABC): + @abstractmethod + async def commit(self) -> None: ... + + +class ReadonlyTransaction(AbstractAsyncContextManager[Any], ABC): ... diff --git a/src/ttt/application/game/game/cancel_game.py b/src/ttt/application/game/game/cancel_game.py index 6e75aeb..b00fd46 100644 --- a/src/ttt/application/game/game/cancel_game.py +++ b/src/ttt/application/game/game/cancel_game.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_log import GameLog from ttt.application.game.game.ports.game_views import GameViews @@ -16,15 +16,21 @@ class CancelGame: games: Games game_views: GameViews uuids: UUIDs - transaction: Transaction + transaction: SerializableTransaction log: GameLog async def __call__(self, user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: game = await self.games.current_user_game(user_id) if game is None: - await self.game_views.no_game_view(user_id) + await self.log.no_current_game_to_cancel_game(user_id) + await self.transaction.commit() + await self.game_views.no_current_game_view(user_id) return try: @@ -32,13 +38,16 @@ async def __call__(self, user_id: int) -> None: game.cancel(user_id, tracking) except AlreadyCompletedGameError: await self.log.already_completed_game_to_cancel(game, user_id) + await self.transaction.commit() await self.game_views.game_already_complteted_view( user_id, game, ) return + else: + await self.log.game_cancelled(user_id, game) - await self.log.game_cancelled(user_id, game) + await self.map_(tracking) + await self.transaction.commit() - await self.map_(tracking) - await self.game_views.game_view(game) + await self.game_views.game_view(game) diff --git a/src/ttt/application/game/game/make_ai_move_in_game.py b/src/ttt/application/game/game/make_ai_move_in_game.py new file mode 100644 index 0000000..5974e9c --- /dev/null +++ b/src/ttt/application/game/game/make_ai_move_in_game.py @@ -0,0 +1,79 @@ +from asyncio import gather +from dataclasses import dataclass +from uuid import UUID + +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.randoms import Randoms +from ttt.application.common.ports.transaction import SerializableTransaction +from ttt.application.common.ports.uuids import UUIDs +from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway +from ttt.application.game.game.ports.game_dao import GameDao +from ttt.application.game.game.ports.game_log import GameLog +from ttt.application.game.game.ports.game_views import GameViews +from ttt.application.game.game.ports.games import Games +from ttt.application.user.common.ports.users import Users +from ttt.entities.core.game.game import ( + AlreadyCompletedGameError, + NotAiCurrentMoveError, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class MakeAiMoveInGame: + map_: Map + games: Games + game_views: GameViews + users: Users + uuids: UUIDs + randoms: Randoms + ai_gateway: GameAiGateway + transaction: SerializableTransaction + log: GameLog + dao: GameDao + + async def __call__(self, game_id: UUID, ai_id: UUID) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + + async with self.transaction: + game = await self.games.game_with_id(game_id) + + if game is None: + await self.log.no_game_to_make_ai_move(game_id) + return + + ( + free_cell_random, + ai_move_cell_number_int, + ) = await gather( + self.randoms.random(), + self.ai_gateway.next_move_cell_number_int(game, ai_id), + ) + try: + tracking = Tracking() + ai_move = game.make_ai_move( + ai_id, + ai_move_cell_number_int, + free_cell_random, + tracking, + ) + except AlreadyCompletedGameError: + await self.log.already_completed_game_to_make_ai_move( + game, ai_id, + ) + except NotAiCurrentMoveError: + await self.log.not_ai_current_move_to_make_ai_move( + game, ai_id, + ) + else: + await self.log.ai_move_maked(game, ai_move, ai_id) + + if game.is_completed(): + await self.log.game_was_completed_by_ai(ai_id, game) + + await self.map_(tracking) + await self.transaction.commit() + + await self.game_views.game_view(game) diff --git a/src/ttt/application/game/game/make_move_in_game.py b/src/ttt/application/game/game/make_move_in_game.py index 1917575..2018c14 100644 --- a/src/ttt/application/game/game/make_move_in_game.py +++ b/src/ttt/application/game/game/make_move_in_game.py @@ -3,11 +3,12 @@ from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway from ttt.application.game.game.ports.game_dao import GameDao from ttt.application.game.game.ports.game_log import GameLog +from ttt.application.game.game.ports.game_tasks import GameTasks from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games from ttt.application.user.common.ports.users import Users @@ -29,20 +30,25 @@ class MakeMoveInGame: uuids: UUIDs randoms: Randoms ai_gateway: GameAiGateway - transaction: Transaction + transaction: SerializableTransaction log: GameLog dao: GameDao + tasks: GameTasks async def __call__( self, user_id: int, cell_number_int: int, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: game = await self.games.current_user_game(user_id) if game is None: - await self.game_views.no_game_view(user_id) + await self.game_views.no_current_game_view(user_id) return ( @@ -68,6 +74,7 @@ async def __call__( user_id, cell_number_int, ) + await self.transaction.commit() await self.game_views.game_already_complteted_view( user_id, game, @@ -78,6 +85,7 @@ async def __call__( user_id, cell_number_int, ) + await self.transaction.commit() await self.game_views.not_current_user_view( user_id, game, @@ -88,6 +96,7 @@ async def __call__( user_id, cell_number_int, ) + await self.transaction.commit() await self.game_views.no_cell_view(user_id, game) except AlreadyFilledCellError: await self.log.already_filled_cell_to_make_move( @@ -95,6 +104,7 @@ async def __call__( user_id, cell_number_int, ) + await self.transaction.commit() await self.game_views.already_filled_cell_error( user_id, game, @@ -102,34 +112,15 @@ async def __call__( else: await self.log.user_move_maked(user_id, game, user_move) - if user_move.next_move_ai_id is not None: - await self.game_views.game_view(game) + if game.is_completed(): + await self.log.game_was_completed_by_user(user_id, game) - ( - free_cell_random, - ai_move_cell_number_int, - ) = await gather( - self.randoms.random(), - self.ai_gateway.next_move_cell_number_int( - game, - user_move.next_move_ai_id, - ), - ) - ai_move = game.make_ai_move( - user_move.next_move_ai_id, - ai_move_cell_number_int, - free_cell_random, - tracking, - ) + await self.map_(tracking) - await self.log.ai_move_maked( - user_id, - game, - ai_move, + if user_move.next_move_ai_id is not None: + await self.tasks.make_ai_move( + game.id, user_move.next_move_ai_id, ) - if game.is_completed(): - await self.log.game_completed(user_id, game) - - await self.map_(tracking) + await self.transaction.commit() await self.game_views.game_view(game) diff --git a/src/ttt/application/game/game/ports/game_log.py b/src/ttt/application/game/game/ports/game_log.py index 8f15964..d890408 100644 --- a/src/ttt/application/game/game/ports/game_log.py +++ b/src/ttt/application/game/game/ports/game_log.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from uuid import UUID from ttt.entities.core.game.game import Game from ttt.entities.core.game.move import AiMove, UserMove @@ -6,6 +7,34 @@ class GameLog(ABC): + @abstractmethod + async def no_current_game_to_make_move( + self, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def no_current_game_to_cancel_game( + self, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def no_game_to_make_ai_move( + self, + game_id: UUID, + /, + ) -> None: ... + + @abstractmethod + async def no_current_game( + self, + user_id: int, + /, + ) -> None: ... + @abstractmethod async def game_against_ai_started( self, @@ -33,20 +62,28 @@ async def user_move_maked( @abstractmethod async def ai_move_maked( self, - user_id: int, game: Game, move: AiMove, + ai_id: UUID, /, ) -> None: ... @abstractmethod - async def game_completed( + async def game_was_completed_by_user( self, user_id: int, game: Game, /, ) -> None: ... + @abstractmethod + async def game_was_completed_by_ai( + self, + ai_id: UUID, + game: Game, + /, + ) -> None: ... + @abstractmethod async def user_already_in_game_to_start_game_against_ai( self, user: User, /, @@ -96,3 +133,19 @@ async def already_completed_game_to_cancel( user_id: int, /, ) -> None: ... + + @abstractmethod + async def already_completed_game_to_make_ai_move( + self, + game: Game, + ai_id: UUID, + /, + ) -> None: ... + + @abstractmethod + async def not_ai_current_move_to_make_ai_move( + self, + game: Game, + ai_id: UUID, + /, + ) -> None: ... diff --git a/src/ttt/application/game/game/ports/game_tasks.py b/src/ttt/application/game/game/ports/game_tasks.py new file mode 100644 index 0000000..e83aff7 --- /dev/null +++ b/src/ttt/application/game/game/ports/game_tasks.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod +from uuid import UUID + + +class GameTasks(ABC): + @abstractmethod + async def make_ai_move( + self, + game_id: UUID, + ai_id: UUID, + /, + ) -> None: ... diff --git a/src/ttt/application/game/game/ports/game_views.py b/src/ttt/application/game/game/ports/game_views.py index fc5653e..3f69bae 100644 --- a/src/ttt/application/game/game/ports/game_views.py +++ b/src/ttt/application/game/game/ports/game_views.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from uuid import UUID from ttt.entities.core.game.game import Game @@ -18,12 +19,19 @@ async def started_game_view( ) -> None: ... @abstractmethod - async def no_game_view( + async def no_current_game_view( self, user_id: int, /, ) -> None: ... + @abstractmethod + async def no_game_with_id_view( + self, + game_id: UUID, + /, + ) -> None: ... + @abstractmethod async def game_already_complteted_view( self, diff --git a/src/ttt/application/game/game/ports/games.py b/src/ttt/application/game/game/ports/games.py index 46ce560..4f9457d 100644 --- a/src/ttt/application/game/game/ports/games.py +++ b/src/ttt/application/game/game/ports/games.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from uuid import UUID from ttt.entities.core.game.game import Game @@ -9,3 +10,6 @@ class NoGameError(Exception): ... class Games(ABC): @abstractmethod async def current_user_game(self, user_id: int, /) -> Game | None: ... + + @abstractmethod + async def game_with_id(self, game_id: UUID, /) -> Game | None: ... diff --git a/src/ttt/application/game/game/start_game_with_ai.py b/src/ttt/application/game/game/start_game_with_ai.py index ff17603..1bc1040 100644 --- a/src/ttt/application/game/game/start_game_with_ai.py +++ b/src/ttt/application/game/game/start_game_with_ai.py @@ -4,10 +4,11 @@ from ttt.application.common.ports.emojis import Emojis from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway from ttt.application.game.game.ports.game_log import GameLog +from ttt.application.game.game.ports.game_tasks import GameTasks from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games from ttt.application.user.common.ports.user_views import CommonUserViews @@ -28,11 +29,16 @@ class StartGameWithAi: user_views: CommonUserViews games: Games game_views: GameViews - transaction: Transaction + transaction: SerializableTransaction ai_gateway: GameAiGateway log: GameLog + tasks: GameTasks async def __call__(self, user_id: int, ai_type: AiType) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + game_id = await self.uuids.random_uuid() ai_id = await self.uuids.random_uuid() cell_id_matrix = await self.uuids.random_uuid_matrix((3, 3)) @@ -64,37 +70,16 @@ async def __call__(self, user_id: int, ai_type: AiType) -> None: await self.log.user_already_in_game_to_start_game_against_ai( user, ) + await self.transaction.commit() await self.game_views.user_already_in_game_view(user_id) else: await self.log.game_against_ai_started(started_game.game) + await self.map_(tracking) - if started_game.next_move_ai_id is None: - await self.map_(tracking) - await self.game_views.started_game_view(started_game.game) - else: - await self.game_views.started_game_view(started_game.game) - - ( - free_cell_random, - ai_move_cell_number_int, - ) = await gather( - self.randoms.random(), - self.ai_gateway.next_move_cell_number_int( - started_game.game, - started_game.next_move_ai_id, - ), - ) - ai_move = started_game.game.make_ai_move( - started_game.next_move_ai_id, - ai_move_cell_number_int, - free_cell_random, - tracking, - ) - await self.log.ai_move_maked( - user_id, - started_game.game, - ai_move, + if started_game.next_move_ai_id is not None: + await self.tasks.make_ai_move( + started_game.game.id, started_game.next_move_ai_id, ) - await self.map_(tracking) - await self.game_views.game_view(started_game.game) + await self.transaction.commit() + await self.game_views.started_game_view(started_game.game) diff --git a/src/ttt/application/game/game/view_game.py b/src/ttt/application/game/game/view_game.py index ba289b8..af05ad4 100644 --- a/src/ttt/application/game/game/view_game.py +++ b/src/ttt/application/game/game/view_game.py @@ -1,13 +1,16 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, + SerializableTransaction, +) from ttt.application.game.game.ports.game_log import GameLog from ttt.application.game.game.ports.game_views import GameViews @dataclass(frozen=True, unsafe_hash=False) class ViewGame: - transaction: Transaction + transaction: ReadonlyTransaction game_views: GameViews log: GameLog diff --git a/src/ttt/application/invitation_to_game/game/accpet_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/accpet_invitation_to_game.py index f4093a2..225ded6 100644 --- a/src/ttt/application/invitation_to_game/game/accpet_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/accpet_invitation_to_game.py @@ -5,7 +5,7 @@ from ttt.application.common.ports.emojis import Emojis from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 InvitationToGameLog, @@ -27,7 +27,7 @@ @dataclass(frozen=True, unsafe_hash=False) class AcceptInvitationToGame: map_: Map - transaction: Transaction + transaction: SerializableTransaction views: InvitationToGameViews log: InvitationToGameLog invitations_to_game: InvitationsToGame @@ -40,6 +40,10 @@ async def __call__( user_id: int, invitation_to_game_id: UUID, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: ( invitation_to_game, @@ -63,6 +67,7 @@ async def __call__( await self.log.no_invitation_to_game_to_accept( user_id, invitation_to_game_id, ) + await self.transaction.commit() await self.views.no_invitation_to_game_to_accept_view( user_id, invitation_to_game_id, ) @@ -86,6 +91,7 @@ async def __call__( invitation_to_game, user_id, ) ) + await self.transaction.commit() await ( self.views .user_is_not_invited_user_to_accept_invitation_to_game_view( @@ -96,6 +102,7 @@ async def __call__( await self.log.invitation_to_game_is_not_active_to_accept( invitation_to_game, user_id, ) + await self.transaction.commit() await ( self.views.invitation_to_game_is_not_active_to_accept_view( invitation_to_game, user_id, @@ -107,6 +114,7 @@ async def __call__( invitation_to_game, error.users, ) ) + await self.transaction.commit() await ( self.views .users_already_in_game_to_accept_invitation_to_game_view( @@ -118,6 +126,7 @@ async def __call__( invitation_to_game, game, ) await self.map_(tracking) + await self.transaction.commit() await self.views.accepted_invitation_to_game_view( invitation_to_game, game, ) diff --git a/src/ttt/application/invitation_to_game/game/auto_cancel_invitations_to_game.py b/src/ttt/application/invitation_to_game/game/auto_cancel_invitations_to_game.py index d2de8a4..b3430ae 100644 --- a/src/ttt/application/invitation_to_game/game/auto_cancel_invitations_to_game.py +++ b/src/ttt/application/invitation_to_game/game/auto_cancel_invitations_to_game.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.clock import Clock -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.invitation_to_game.game.ports.invitation_to_game_dao import ( # noqa: E501 InvitationToGameDao, ) @@ -15,12 +15,16 @@ @dataclass(frozen=True, unsafe_hash=False) class AutoCancelInvitationsToGame: - transaction: Transaction + transaction: SerializableTransaction log: InvitationToGameLog clock: Clock invitation_to_game_dao: InvitationToGameDao async def __call__(self) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: expiration_datetime = await self.clock.current_datetime() auto_cancelled_invitations_to_game_ids = await ( diff --git a/src/ttt/application/invitation_to_game/game/cancel_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/cancel_invitation_to_game.py index 7586559..ea0b313 100644 --- a/src/ttt/application/invitation_to_game/game/cancel_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/cancel_invitation_to_game.py @@ -2,7 +2,7 @@ from uuid import UUID from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 InvitationToGameLog, ) @@ -22,7 +22,7 @@ @dataclass(frozen=True, unsafe_hash=False) class CancelInvitationToGame: map_: Map - transaction: Transaction + transaction: SerializableTransaction views: InvitationToGameViews log: InvitationToGameLog invitations_to_game: InvitationsToGame @@ -32,6 +32,10 @@ async def __call__( user_id: int, invitation_to_game_id: UUID, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: invitation_to_game = await ( self.invitations_to_game.invitation_to_game_with_id( @@ -43,6 +47,7 @@ async def __call__( await self.log.no_invitation_to_game_to_cancel( user_id, invitation_to_game_id, ) + await self.transaction.commit() await self.views.no_invitation_to_game_to_cancel_view( user_id, invitation_to_game_id, ) @@ -58,6 +63,7 @@ async def __call__( invitation_to_game, user_id, ) ) + await self.transaction.commit() await ( self.views .user_is_not_inviting_user_to_cancel_invitation_to_game_view( @@ -68,6 +74,7 @@ async def __call__( await self.log.invitation_to_game_is_not_active_to_cancel( invitation_to_game, user_id, ) + await self.transaction.commit() await ( self.views.invitation_to_game_is_not_active_to_cancel_view( invitation_to_game, user_id, @@ -78,6 +85,7 @@ async def __call__( invitation_to_game, ) await self.map_(tracking) + await self.transaction.commit() await self.views.cancelled_invitation_to_game_view( invitation_to_game, ) diff --git a/src/ttt/application/invitation_to_game/game/invite_to_game.py b/src/ttt/application/invitation_to_game/game/invite_to_game.py index 494b932..e53fd1d 100644 --- a/src/ttt/application/invitation_to_game/game/invite_to_game.py +++ b/src/ttt/application/invitation_to_game/game/invite_to_game.py @@ -6,7 +6,7 @@ Map, NotUniqueActiveInvitationToGameUserIdsError, ) -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 InvitationToGameLog, @@ -27,7 +27,7 @@ class InviteToGame: map_: Map uuids: UUIDs - transaction: Transaction + transaction: SerializableTransaction clock: Clock users: Users user_views: CommonUserViews @@ -35,12 +35,17 @@ class InviteToGame: log: InvitationToGameLog async def __call__(self, user_id: int, invited_user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, invited_user = await self.users.users_with_ids( (user_id, invited_user_id), ) if user is None: + await self.transaction.commit() await self.user_views.user_is_not_registered_view(user_id) return @@ -64,6 +69,7 @@ async def __call__(self, user_id: int, invited_user_id: int) -> None: ) except InvitationSelfToGameError: await self.log.invitation_self_to_game(user) + await self.transaction.commit() await self.views.invitation_self_to_game_view(user) return @@ -71,6 +77,7 @@ async def __call__(self, user_id: int, invited_user_id: int) -> None: await self.map_(tracking) except NotUniqueActiveInvitationToGameUserIdsError: await self.log.double_invitation_to_game(invitation_to_game) + await self.transaction.commit() await self.views.double_invitation_to_game_view( invitation_to_game, ) @@ -78,4 +85,5 @@ async def __call__(self, user_id: int, invited_user_id: int) -> None: await self.log.user_invited_other_user_to_game( invitation_to_game, ) + await self.transaction.commit() await self.views.invitation_to_game_view(invitation_to_game) diff --git a/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_dao.py b/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_dao.py index 7c770c8..47ee6c5 100644 --- a/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_dao.py +++ b/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_dao.py @@ -10,4 +10,7 @@ async def set_auto_cancelled_where_invitation_datetime_le_and_active( self, datetime: datetime, /, - ) -> Sequence[UUID]: ... + ) -> Sequence[UUID]: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 diff --git a/src/ttt/application/invitation_to_game/game/reject_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/reject_invitation_to_game.py index 7117aa7..6372e11 100644 --- a/src/ttt/application/invitation_to_game/game/reject_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/reject_invitation_to_game.py @@ -2,7 +2,7 @@ from uuid import UUID from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 InvitationToGameLog, ) @@ -22,7 +22,7 @@ @dataclass(frozen=True, unsafe_hash=False) class RejectInvitationToGame: map_: Map - transaction: Transaction + transaction: SerializableTransaction views: InvitationToGameViews log: InvitationToGameLog invitations_to_game: InvitationsToGame @@ -32,6 +32,10 @@ async def __call__( user_id: int, invitation_to_game_id: UUID, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: invitation_to_game = await ( self.invitations_to_game.invitation_to_game_with_id( @@ -43,6 +47,7 @@ async def __call__( await self.log.no_invitation_to_game_to_reject( user_id, invitation_to_game_id, ) + await self.transaction.commit() await self.views.no_invitation_to_game_to_reject_view( user_id, invitation_to_game_id, ) @@ -58,6 +63,7 @@ async def __call__( invitation_to_game, user_id, ) ) + await self.transaction.commit() await ( self.views .user_is_not_invited_user_to_reject_invitation_to_game_view( @@ -68,6 +74,7 @@ async def __call__( await self.log.invitation_to_game_is_not_active_to_reject( invitation_to_game, user_id, ) + await self.transaction.commit() await ( self.views.invitation_to_game_is_not_active_to_reject_view( invitation_to_game, user_id, @@ -78,6 +85,7 @@ async def __call__( invitation_to_game, ) await self.map_(tracking) + await self.transaction.commit() await self.views.rejected_invitation_to_game_view( invitation_to_game, ) diff --git a/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py index 927ed49..734ba20 100644 --- a/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from uuid import UUID -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) @@ -10,7 +10,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewIncomingInvitationToGame: views: InvitationToGameViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int, invitation_to_game_id: UUID) -> None: async with self.transaction: diff --git a/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py b/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py index 1a53067..b313763 100644 --- a/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) @@ -9,7 +9,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewIncomingInvitationsToGame: views: InvitationToGameViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py index 28d21f5..41e909b 100644 --- a/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) @@ -9,7 +9,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewOneIncomingInvitationToGame: views: InvitationToGameViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py b/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py index 86cc1d3..3e525f9 100644 --- a/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py @@ -1,6 +1,9 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, + SerializableTransaction, +) from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) @@ -9,7 +12,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewOutcomingInvitationsToGame: views: InvitationToGameViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py b/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py index 58b72f2..c8f36b4 100644 --- a/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py +++ b/src/ttt/application/stars_purchase/complete_stars_purchase_payment.py @@ -1,11 +1,9 @@ from dataclasses import dataclass +from uuid import UUID from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction -from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 - PaidStarsPurchasePaymentInbox, -) +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.stars_purchase.ports.stars_purchase_log import ( StarsPurchaseLog, ) @@ -16,59 +14,68 @@ from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users from ttt.entities.finance.payment.payment import PaymentIsNotInProcessError +from ttt.entities.finance.payment.success import PaymentSuccess from ttt.entities.tools.tracking import Tracking @dataclass(frozen=True, unsafe_hash=False) class CompleteStarsPurchasePayment: clock: Clock - inbox: PaidStarsPurchasePaymentInbox users: Users - transaction: Transaction + transaction: SerializableTransaction map_: Map common_views: CommonUserViews stars_purchase_views: StarsPurchaseViews log: StarsPurchaseLog stars_purchases: StarsPurchases - async def __call__(self) -> None: - async for paid_payment in self.inbox: - current_datetime = await self.clock.current_datetime() + async def __call__( + self, + purchase_id: UUID, + success: PaymentSuccess, + ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 - async with self.transaction: - stars_purchase = ( - await self.stars_purchases.stars_purchase_with_id( - paid_payment.purchase_id, - ) + current_datetime = await self.clock.current_datetime() + + async with self.transaction: + stars_purchase = ( + await self.stars_purchases.stars_purchase_with_id( + purchase_id, ) + ) - if stars_purchase is None: - await self.log.no_stars_purchase_to_complete_payment( - paid_payment.purchase_id, - ) - return + if stars_purchase is None: + await self.log.no_stars_purchase_to_complete_payment( + purchase_id, + ) + await self.transaction.commit() + return - try: - tracking = Tracking() - stars_purchase.complete_payment( - paid_payment.success, - current_datetime, - tracking, - ) - except PaymentIsNotInProcessError: - await self.log.double_stars_purchase_payment_completion( - stars_purchase, - paid_payment, - ) - else: - await self.log.stars_purchase_payment_completed( + try: + tracking = Tracking() + stars_purchase.complete_payment( + success, + current_datetime, + tracking, + ) + except PaymentIsNotInProcessError: + await self.log.double_stars_purchase_payment_completion( + stars_purchase, + success, + ) + await self.transaction.commit() + else: + await self.log.stars_purchase_payment_completed( + stars_purchase, + success, + ) + await self.map_(tracking) + await self.transaction.commit() + await ( + self.stars_purchase_views.completed_stars_purchase_view( stars_purchase, - paid_payment, - ) - - await self.map_(tracking) - await ( - self.stars_purchase_views.completed_stars_purchase_view( - stars_purchase, - ) ) + ) diff --git a/src/ttt/application/stars_purchase/dto/common.py b/src/ttt/application/stars_purchase/dto/common.py deleted file mode 100644 index b0711f9..0000000 --- a/src/ttt/application/stars_purchase/dto/common.py +++ /dev/null @@ -1,11 +0,0 @@ -from dataclasses import dataclass -from uuid import UUID - -from ttt.entities.finance.payment.success import PaymentSuccess - - -@dataclass(frozen=True) -class PaidStarsPurchasePayment: - purchase_id: UUID - user_id: int - success: PaymentSuccess diff --git a/src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py b/src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py deleted file mode 100644 index f0e7c9f..0000000 --- a/src/ttt/application/stars_purchase/ports/paid_stars_purchase_payment_inbox.py +++ /dev/null @@ -1,12 +0,0 @@ -from abc import ABC, abstractmethod -from collections.abc import AsyncIterator - -from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment - - -class PaidStarsPurchasePaymentInbox(ABC): - @abstractmethod - async def push(self, payment: PaidStarsPurchasePayment) -> None: ... - - @abstractmethod - def __aiter__(self) -> AsyncIterator[PaidStarsPurchasePayment]: ... diff --git a/src/ttt/application/stars_purchase/ports/stars_purchase_log.py b/src/ttt/application/stars_purchase/ports/stars_purchase_log.py index cfb73c2..357e8a1 100644 --- a/src/ttt/application/stars_purchase/ports/stars_purchase_log.py +++ b/src/ttt/application/stars_purchase/ports/stars_purchase_log.py @@ -1,10 +1,10 @@ from abc import ABC, abstractmethod from uuid import UUID -from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment from ttt.entities.core.stars import Stars from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase from ttt.entities.core.user.user import User +from ttt.entities.finance.payment.success import PaymentSuccess class StarsPurchaseLog(ABC): @@ -25,7 +25,8 @@ async def stars_puchase_payment_started( @abstractmethod async def stars_purchase_payment_completion_started( self, - payment: PaidStarsPurchasePayment, + purchase_id: UUID, + success: PaymentSuccess, /, ) -> None: ... @@ -33,7 +34,7 @@ async def stars_purchase_payment_completion_started( async def stars_purchase_payment_completed( self, stars_purchase: StarsPurchase, - payment: PaidStarsPurchasePayment, + success: PaymentSuccess, /, ) -> None: ... @@ -41,7 +42,8 @@ async def stars_purchase_payment_completed( async def double_stars_purchase_payment_completion( self, stars_purchase: StarsPurchase, - paid_payment: PaidStarsPurchasePayment, + success: PaymentSuccess, + /, ) -> None: ... @abstractmethod diff --git a/src/ttt/application/stars_purchase/ports/stars_purchase_tasks.py b/src/ttt/application/stars_purchase/ports/stars_purchase_tasks.py new file mode 100644 index 0000000..62f16b8 --- /dev/null +++ b/src/ttt/application/stars_purchase/ports/stars_purchase_tasks.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from uuid import UUID + +from ttt.entities.finance.payment.success import PaymentSuccess + + +class StarsPurchaseTasks(ABC): + @abstractmethod + async def complete_stars_purchase_payment( + self, + purchase_id: UUID, + success: PaymentSuccess, + /, + ) -> None: ... diff --git a/src/ttt/application/stars_purchase/start_stars_purchase.py b/src/ttt/application/stars_purchase/start_stars_purchase.py index 41ef7c9..9fbf468 100644 --- a/src/ttt/application/stars_purchase/start_stars_purchase.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase.py @@ -3,7 +3,7 @@ from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.stars_purchase.ports.stars_purchase_log import ( StarsPurchaseLog, @@ -26,7 +26,7 @@ @dataclass(frozen=True, unsafe_hash=False) class StartStarsPurchase: - transaction: Transaction + transaction: SerializableTransaction users: Users uuids: UUIDs clock: Clock @@ -37,6 +37,10 @@ class StartStarsPurchase: log: StarsPurchaseLog async def __call__(self, user_id: int, stars: Stars) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, purchase_id = await gather( self.users.user_with_id(user_id), @@ -44,6 +48,7 @@ async def __call__(self, user_id: int, stars: Stars) -> None: ) if user is None: + await self.transaction.commit() await self.common_views.user_is_not_registered_view(user_id) return @@ -57,6 +62,7 @@ async def __call__(self, user_id: int, stars: Stars) -> None: user, stars, ) + await self.transaction.commit() await ( self.stars_purchase_views .invalid_stars_for_stars_purchase_view(user_id) @@ -64,6 +70,6 @@ async def __call__(self, user_id: int, stars: Stars) -> None: return else: await self.log.stars_puchase_started(stars_purchase) - await self.map_(tracking) await self.payment_gateway.send_invoice(stars_purchase) + await self.transaction.commit() diff --git a/src/ttt/application/stars_purchase/start_stars_purchase_payment.py b/src/ttt/application/stars_purchase/start_stars_purchase_payment.py index 7eb9a02..542aead 100644 --- a/src/ttt/application/stars_purchase/start_stars_purchase_payment.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase_payment.py @@ -4,7 +4,7 @@ from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.stars_purchase.ports.stars_purchase_log import ( StarsPurchaseLog, @@ -19,7 +19,7 @@ @dataclass(frozen=True, unsafe_hash=False) class StartStarsPurchasePayment: - transaction: Transaction + transaction: SerializableTransaction uuids: UUIDs clock: Clock stars_purchases: StarsPurchases @@ -27,7 +27,11 @@ class StartStarsPurchasePayment: map_: Map log: StarsPurchaseLog - async def __call__(self, purchase_id: UUID) -> None: + async def __call__(self, purchase_id: UUID, retry: bool) -> None: # noqa: FBT001 + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: stars_purchase, payment_id, current_datetime = await gather( self.stars_purchases.stars_purchase_with_id(purchase_id), @@ -51,12 +55,18 @@ async def __call__(self, purchase_id: UUID) -> None: await self.log.double_stars_purchase_payment_start( stars_purchase, ) - await self.payment_gateway.stop_payment_due_to_dublicate( - payment_id, - ) + await self.transaction.commit() + + if retry: + await self.payment_gateway.start_payment(payment_id) + else: + await self.payment_gateway.stop_payment_due_to_dublicate( + payment_id, + ) else: await self.log.stars_puchase_payment_started( stars_purchase, ) await self.map_(tracking) + await self.transaction.commit() await self.payment_gateway.start_payment(payment_id) diff --git a/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py index a159520..8d3a78f 100644 --- a/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py @@ -1,32 +1,36 @@ from dataclasses import dataclass +from uuid import UUID -from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment -from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 - PaidStarsPurchasePaymentInbox, -) from ttt.application.stars_purchase.ports.stars_purchase_log import ( StarsPurchaseLog, ) from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 StarsPurchasePaymentGateway, ) +from ttt.application.stars_purchase.ports.stars_purchase_tasks import ( + StarsPurchaseTasks, +) from ttt.application.stars_purchase.ports.stars_purchase_views import ( StarsPurchaseViews, ) +from ttt.entities.finance.payment.success import PaymentSuccess @dataclass(frozen=True, unsafe_hash=False) class StartStarsPurchasePaymentCompletion: - inbox: PaidStarsPurchasePaymentInbox + tasks: StarsPurchaseTasks payment_gateway: StarsPurchasePaymentGateway views: StarsPurchaseViews log: StarsPurchaseLog - async def __call__(self, paid_payment: PaidStarsPurchasePayment) -> None: - await self.inbox.push(paid_payment) + async def __call__( + self, + user_id: int, + purchase_id: UUID, + success: PaymentSuccess, + ) -> None: + await self.tasks.complete_stars_purchase_payment(purchase_id, success) await self.log.stars_purchase_payment_completion_started( - paid_payment, - ) - await self.views.stars_purchase_will_be_completed_view( - paid_payment.user_id, + purchase_id, success, ) + await self.views.stars_purchase_will_be_completed_view(user_id) diff --git a/src/ttt/application/user/authorize_as_admin.py b/src/ttt/application/user/authorize_as_admin.py index f98e4e0..dcdfb93 100644 --- a/src/ttt/application/user/authorize_as_admin.py +++ b/src/ttt/application/user/authorize_as_admin.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.original_admin_token import ( OriginalAdminToken, ) @@ -19,7 +19,7 @@ @dataclass(frozen=True, unsafe_hash=False) class AuthorizeAsAdmin: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: CommonUserLog @@ -27,6 +27,10 @@ class AuthorizeAsAdmin: views: CommonUserViews async def __call__(self, user_id: int, admin_token: Token) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, original_admin_token = await gather( self.users.user_with_id(user_id), @@ -34,6 +38,7 @@ async def __call__(self, user_id: int, admin_token: Token) -> None: ) if user is None: + await self.transaction.commit() await self.views.user_is_not_registered_view(user_id) return @@ -44,15 +49,18 @@ async def __call__(self, user_id: int, admin_token: Token) -> None: ) except UserAlreadyAdminError: await self.log.user_already_admin_to_get_admin_rights(user) + await self.transaction.commit() await self.views.user_already_admin_to_get_admin_rights_view( user, ) except AdminTokenMismatchError: await self.log.admin_token_mismatch_to_get_admin_rights(user) + await self.transaction.commit() await self.views.admin_token_mismatch_to_get_admin_rights_view( user, ) else: await self.log.user_authorized_as_admin(user) await self.map_(tracking) + await self.transaction.commit() await self.views.user_authorized_as_admin_view(user) diff --git a/src/ttt/application/user/authorize_other_user_as_admin.py b/src/ttt/application/user/authorize_other_user_as_admin.py index 17bce3a..436e53f 100644 --- a/src/ttt/application/user/authorize_other_user_as_admin.py +++ b/src/ttt/application/user/authorize_other_user_as_admin.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users @@ -14,19 +14,24 @@ @dataclass(frozen=True, unsafe_hash=False) class AuthorizeOtherUserAsAdmin: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: CommonUserLog views: CommonUserViews async def __call__(self, user_id: int, other_user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, other_user = await self.users.users_with_ids( (user_id, other_user_id), ) if user is None: + await self.transaction.commit() await self.views.user_is_not_registered_view(user_id) return @@ -42,6 +47,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) ) + await self.transaction.commit() await ( self.views .not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_admin_view( @@ -55,6 +61,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) ) + await self.transaction.commit() await ( self.views .other_user_already_admin_to_authorize_other_user_as_admin_view( @@ -66,6 +73,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) await self.map_(tracking) + await self.transaction.commit() await self.views.user_authorized_other_user_as_admin_view( user, other_user, ) diff --git a/src/ttt/application/user/change_other_user_account/change_other_user_account.py b/src/ttt/application/user/change_other_user_account/change_other_user_account.py index 367a8e9..d8223d3 100644 --- a/src/ttt/application/user/change_other_user_account/change_other_user_account.py +++ b/src/ttt/application/user/change_other_user_account/change_other_user_account.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.change_other_user_account.ports.user_log import ( ChangeOtherUserAccountLog, ) @@ -20,7 +20,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ChangeOtherUserAccount: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: ChangeOtherUserAccountLog @@ -33,12 +33,17 @@ async def __call__( other_user_id: int, other_user_account_stars_vector: Stars, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, other_user = await self.users.users_with_ids( (user_id, other_user_id), ) if user is None: + await self.transaction.commit() await self.common_views.user_is_not_registered_view(user_id) return @@ -57,6 +62,7 @@ async def __call__( other_user_id, other_user_account_stars_vector, ) + await self.transaction.commit() await self.common_views.user_is_not_admin_view(user) except NegativeAccountError: await self.log.negative_account_on_change_other_user_account( @@ -65,6 +71,7 @@ async def __call__( other_user_id, other_user_account_stars_vector, ) + await self.transaction.commit() await ( self.views .negative_account_on_change_other_user_account_view( @@ -79,6 +86,7 @@ async def __call__( user, other_user, other_user_account_stars_vector, ) await self.map_(tracking) + await self.transaction.commit() await self.views.user_changed_other_user_account_view( user, other_user, other_user_account_stars_vector, ) diff --git a/src/ttt/application/user/change_other_user_account/set_other_user_account.py b/src/ttt/application/user/change_other_user_account/set_other_user_account.py index 63bc1db..9c6216b 100644 --- a/src/ttt/application/user/change_other_user_account/set_other_user_account.py +++ b/src/ttt/application/user/change_other_user_account/set_other_user_account.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.change_other_user_account.ports.user_log import ( ChangeOtherUserAccountLog, ) @@ -18,7 +18,7 @@ @dataclass(frozen=True, unsafe_hash=False) class SetOtherUserAccount: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: ChangeOtherUserAccountLog @@ -31,6 +31,10 @@ async def __call__( other_user_id: int, other_user_account_stars: Stars, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, other_user = await self.users.users_with_ids( (user_id, other_user_id), @@ -55,6 +59,7 @@ async def __call__( other_user_id, other_user_account_stars, ) + await self.transaction.commit() await self.common_views.user_is_not_admin_view(user) except NegativeAccountError: await self.log.negative_account_on_set_other_user_account( @@ -63,6 +68,7 @@ async def __call__( other_user_id, other_user_account_stars, ) + await self.transaction.commit() await ( self.views.negative_account_on_set_other_user_account_view( user, @@ -74,6 +80,7 @@ async def __call__( else: await self.log.user_set_other_user_account(user, other_user) await self.map_(tracking) + await self.transaction.commit() await self.views.user_set_other_user_account_view( user, other_user, diff --git a/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py b/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py index 3848008..06a8a73 100644 --- a/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py +++ b/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction from ttt.application.user.change_other_user_account.ports.user_views import ( ChangeOtherUserAccountViews, ) @@ -8,7 +8,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewUserAccountToChange: - transaction: Transaction + transaction: ReadonlyTransaction views: ChangeOtherUserAccountViews async def __call__( diff --git a/src/ttt/application/user/deauthorize_other_user_as_admin.py b/src/ttt/application/user/deauthorize_other_user_as_admin.py index 5ce8ea7..0e46cc5 100644 --- a/src/ttt/application/user/deauthorize_other_user_as_admin.py +++ b/src/ttt/application/user/deauthorize_other_user_as_admin.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users @@ -14,13 +14,17 @@ @dataclass(frozen=True, unsafe_hash=False) class DeauthorizeOtherUserAsAdmin: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: CommonUserLog views: CommonUserViews async def __call__(self, user_id: int, other_user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user, other_user = await self.users.users_with_ids( (user_id, other_user_id), @@ -40,6 +44,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) ) + await self.transaction.commit() await ( self.views .not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_admin_view( @@ -53,6 +58,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) ) + await self.transaction.commit() await ( self.views .other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize_view( @@ -64,6 +70,7 @@ async def __call__(self, user_id: int, other_user_id: int) -> None: user, other_user, ) await self.map_(tracking) + await self.transaction.commit() await self.views.user_deauthorized_other_user_as_admin_view( user, other_user, ) diff --git a/src/ttt/application/user/emoji_purchase/buy_emoji.py b/src/ttt/application/user/emoji_purchase/buy_emoji.py index a431b54..d059246 100644 --- a/src/ttt/application/user/emoji_purchase/buy_emoji.py +++ b/src/ttt/application/user/emoji_purchase/buy_emoji.py @@ -3,7 +3,7 @@ from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users @@ -25,7 +25,7 @@ class BuyEmoji: uuids: UUIDs clock: Clock - transaction: Transaction + transaction: SerializableTransaction users: Users common_views: CommonUserViews emoji_purchase_views: EmojiPurchaseUserViews @@ -37,6 +37,10 @@ async def __call__( user_id: int, emoji_str: str | None, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + if emoji_str is None: await self.emoji_purchase_views.invalid_emoji_to_buy_view(user_id) return @@ -56,6 +60,7 @@ async def __call__( user = await self.users.user_with_id(user_id) if user is None: + await self.transaction.commit() await self.common_views.user_is_not_registered_view(user_id) return @@ -69,10 +74,12 @@ async def __call__( ) except EmojiAlreadyPurchasedError: await self.log.emoji_already_purchased_to_buy(user, emoji) + await self.transaction.commit() await self.emoji_purchase_views.emoji_already_purchased_view( user_id, ) except NotEnoughStarsError as error: + await self.transaction.commit() await ( self.emoji_purchase_views .not_enough_stars_to_buy_emoji_view( @@ -82,8 +89,8 @@ async def __call__( ) else: await self.log.user_bought_emoji(user, emoji) - await self.map_(tracking) + await self.transaction.commit() await self.emoji_purchase_views.emoji_was_purchased_view( user_id, ) diff --git a/src/ttt/application/user/emoji_selection/select_emoji.py b/src/ttt/application/user/emoji_selection/select_emoji.py index c868094..cebfbce 100644 --- a/src/ttt/application/user/emoji_selection/select_emoji.py +++ b/src/ttt/application/user/emoji_selection/select_emoji.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users from ttt.application.user.emoji_selection.ports.user_log import ( @@ -17,7 +17,7 @@ @dataclass(frozen=True, unsafe_hash=False) class SelectEmoji: - transaction: Transaction + transaction: SerializableTransaction users: Users user_views: CommonUserViews emoji_selection_views: EmojiSelectionUserViews @@ -29,6 +29,10 @@ async def __call__( user_id: int, emoji_str: str, ) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + try: emoji = Emoji(emoji_str) except InvalidEmojiError: @@ -42,6 +46,7 @@ async def __call__( user = await self.users.user_with_id(user_id) if user is None: + await self.transaction.commit() await self.user_views.user_is_not_registered_view(user_id) return @@ -50,11 +55,12 @@ async def __call__( user.select_emoji(emoji, tracking) except EmojiNotPurchasedError: await self.log.emoji_not_purchased_to_select(user, emoji) + await self.transaction.commit() await ( self.emoji_selection_views .emoji_not_purchased_to_select_view(user_id) ) else: await self.log.user_selected_emoji(user, emoji) - await self.map_(tracking) + await self.transaction.commit() diff --git a/src/ttt/application/user/game/dont_wait_for_matchmaking.py b/src/ttt/application/user/game/dont_wait_for_matchmaking.py index 194a24d..70dafbd 100644 --- a/src/ttt/application/user/game/dont_wait_for_matchmaking.py +++ b/src/ttt/application/user/game/dont_wait_for_matchmaking.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users from ttt.application.user.game.ports.user_log import GameUserLog @@ -13,17 +13,22 @@ @dataclass(frozen=True, unsafe_hash=False) class DontWaitForMatchmaking: map_: Map - transaction: Transaction + transaction: SerializableTransaction users: Users user_views: CommonUserViews views: GameUserViews log: GameUserLog async def __call__(self, user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user = await self.users.user_with_id(user_id) if user is None: + await self.transaction.commit() await self.user_views.user_is_not_registered_view(user_id) return @@ -34,6 +39,7 @@ async def __call__(self, user_id: int) -> None: await self.log.user_is_not_waiting_for_matchmaking_to_dont_wait( user, ) + await self.transaction.commit() await ( self.views .user_is_not_waiting_for_matchmaking_to_dont_wait_view(user) @@ -41,5 +47,5 @@ async def __call__(self, user_id: int) -> None: else: await self.log.user_is_not_waiting_for_matchmaking(user) await self.map_(tracking) - + await self.transaction.commit() await self.views.user_is_not_waiting_for_matchmaking_view(user) diff --git a/src/ttt/application/user/game/matchmake.py b/src/ttt/application/user/game/matchmake.py index eddcc10..cdf688f 100644 --- a/src/ttt/application/user/game/matchmake.py +++ b/src/ttt/application/user/game/matchmake.py @@ -4,7 +4,10 @@ from ttt.application.common.ports.emojis import Emojis from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + NotSerializableTransaction, + SerializableTransaction, +) from ttt.application.common.ports.uuids import UUIDs from ttt.application.user.common.ports.users import Users from ttt.application.user.game.ports.user_log import GameUserLog @@ -17,7 +20,7 @@ @dataclass(frozen=True, unsafe_hash=False) class Matchmake: map_: Map - transaction: Transaction + transaction: NotSerializableTransaction users: Users log: GameUserLog views: GameUserViews @@ -32,9 +35,13 @@ async def __call__(self) -> None: await self.log.games_were_matched(games) await gather( self.views.matched_games_view(games), - self.map_(tracking), + self._output_tracking(tracking), ) + async def _output_tracking(self, tracking: Tracking) -> None: + await self.map_(tracking) + await self.transaction.commit() + async def _result(self, tracking: Tracking) -> list[Game]: users, input_ = await gather( self.users.some_users_waiting_for_matchmaking_to_matchmake(), diff --git a/src/ttt/application/user/game/view_matchmaking.py b/src/ttt/application/user/game/view_matchmaking.py index b4bb7d3..43b31f8 100644 --- a/src/ttt/application/user/game/view_matchmaking.py +++ b/src/ttt/application/user/game/view_matchmaking.py @@ -1,12 +1,12 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction from ttt.application.user.game.ports.user_views import GameUserViews @dataclass(frozen=True, unsafe_hash=False) class ViewMatchmaking: - transaction: Transaction + transaction: ReadonlyTransaction views: GameUserViews async def __call__(self, user_id: int) -> None: diff --git a/src/ttt/application/user/game/wait_for_matchmaking.py b/src/ttt/application/user/game/wait_for_matchmaking.py index b1d8e91..df1ca08 100644 --- a/src/ttt/application/user/game/wait_for_matchmaking.py +++ b/src/ttt/application/user/game/wait_for_matchmaking.py @@ -2,7 +2,7 @@ from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users from ttt.application.user.game.ports.user_log import GameUserLog @@ -17,7 +17,7 @@ @dataclass(frozen=True, unsafe_hash=False) class WaitForMatchmaking: map_: Map - transaction: Transaction + transaction: SerializableTransaction clock: Clock users: Users user_views: CommonUserViews @@ -25,10 +25,15 @@ class WaitForMatchmaking: log: GameUserLog async def __call__(self, user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user = await self.users.user_with_id(user_id) if user is None: + await self.transaction.commit() await self.user_views.user_is_not_registered_view(user_id) return @@ -39,16 +44,18 @@ async def __call__(self, user_id: int) -> None: user.wait_for_matchmaking(current_datetime, tracking) except UserAlreadyWaitingForMatchmakingError: await self.log.user_is_already_waiting_for_matchmaking(user) + await self.transaction.commit() await self.views.user_is_already_waiting_for_matchmaking_view( user, ) except UserIsInGameError: await self.log.user_is_in_game_to_wait_for_matchmaking(user) + await self.transaction.commit() await self.views.user_is_in_game_to_wait_for_matchmaking_view( user, ) else: await self.log.user_is_waiting_for_matchmaking(user) await self.map_(tracking) - + await self.transaction.commit() await self.views.user_is_waiting_for_matchmaking_view(user) diff --git a/src/ttt/application/user/register_user.py b/src/ttt/application/user/register_user.py index f54efd0..ac6eb92 100644 --- a/src/ttt/application/user/register_user.py +++ b/src/ttt/application/user/register_user.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map, NotUniqueUserIdError -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.entities.core.user.user import register_user from ttt.entities.tools.tracking import Tracking @@ -9,11 +9,15 @@ @dataclass(frozen=True, unsafe_hash=False) class RegisterUser: - transaction: Transaction + transaction: SerializableTransaction map_: Map log: CommonUserLog async def __call__(self, user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + tracking = Tracking() user = register_user(user_id, tracking) diff --git a/src/ttt/application/user/relinquish_admin_right.py b/src/ttt/application/user/relinquish_admin_right.py index bd17fd2..8b40534 100644 --- a/src/ttt/application/user/relinquish_admin_right.py +++ b/src/ttt/application/user/relinquish_admin_right.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users @@ -11,17 +11,22 @@ @dataclass(frozen=True, unsafe_hash=False) class RelinquishAdminRight: - transaction: Transaction + transaction: SerializableTransaction users: Users map_: Map log: CommonUserLog views: CommonUserViews async def __call__(self, user_id: int) -> None: + """ + :raises ttt.application.common.errors.serialization_error.SerializationError: + """ # noqa: E501 + async with self.transaction: user = await self.users.user_with_id(user_id) if user is None: + await self.transaction.commit() await self.views.user_is_not_registered_view(user_id) return @@ -30,8 +35,10 @@ async def __call__(self, user_id: int) -> None: user.relinquish_admin_right(tracking) except NotAdminError: await self.log.not_admin_to_relinquish_admin_right(user) + await self.transaction.commit() await self.views.not_admin_to_relinquish_admin_right_view(user) else: await self.log.user_relinquished_admin_rights(user) await self.map_(tracking) + await self.transaction.commit() await self.views.user_relinquished_admin_rights_view(user) diff --git a/src/ttt/application/user/view_admin_menu.py b/src/ttt/application/user/view_admin_menu.py index f9e51b8..18fbf5d 100644 --- a/src/ttt/application/user/view_admin_menu.py +++ b/src/ttt/application/user/view_admin_menu.py @@ -1,13 +1,13 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction from ttt.application.user.common.ports.user_views import CommonUserViews @dataclass(frozen=True, unsafe_hash=False) class ViewAdminMenu: views: CommonUserViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/application/user/view_main_menu.py b/src/ttt/application/user/view_main_menu.py index e798f21..c752c57 100644 --- a/src/ttt/application/user/view_main_menu.py +++ b/src/ttt/application/user/view_main_menu.py @@ -1,13 +1,13 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction from ttt.application.user.common.ports.user_views import CommonUserViews @dataclass(frozen=True, unsafe_hash=False) class ViewMainMenu: views: CommonUserViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/application/user/view_other_user.py b/src/ttt/application/user/view_other_user.py index 58fbd75..91ba5bd 100644 --- a/src/ttt/application/user/view_other_user.py +++ b/src/ttt/application/user/view_other_user.py @@ -1,6 +1,9 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, + SerializableTransaction, +) from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users @@ -10,7 +13,7 @@ class ViewOtherUser: views: CommonUserViews users: Users - transaction: Transaction + transaction: ReadonlyTransaction log: CommonUserLog async def __call__(self, user_id: int, other_user_id: int) -> None: diff --git a/src/ttt/application/user/view_user.py b/src/ttt/application/user/view_user.py index 1cc5020..d4160c4 100644 --- a/src/ttt/application/user/view_user.py +++ b/src/ttt/application/user/view_user.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews @@ -8,7 +8,7 @@ @dataclass(frozen=True, unsafe_hash=False) class ViewUser: views: CommonUserViews - transaction: Transaction + transaction: ReadonlyTransaction log: CommonUserLog async def __call__(self, user_id: int) -> None: diff --git a/src/ttt/application/user/view_user_emojis.py b/src/ttt/application/user/view_user_emojis.py index 27925a0..6e644f1 100644 --- a/src/ttt/application/user/view_user_emojis.py +++ b/src/ttt/application/user/view_user_emojis.py @@ -1,13 +1,13 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import ReadonlyTransaction from ttt.application.user.common.ports.user_views import CommonUserViews @dataclass(frozen=True, unsafe_hash=False) class ViewUserEmojis: views: CommonUserViews - transaction: Transaction + transaction: ReadonlyTransaction async def __call__(self, user_id: int) -> None: async with self.transaction: diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index fa35454..1e77ba5 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -14,7 +14,7 @@ from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms -from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway from ttt.application.game.game.ports.game_dao import GameDao @@ -194,7 +194,7 @@ def provide_gemini(self, secrets: Secrets, envs: Envs) -> Gemini: provide_transaction = provide( InPostgresTransaction, - provides=Transaction, + provides=SerializableTransaction, scope=Scope.REQUEST, ) diff --git a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py index 3b2b331..8fdd72b 100644 --- a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py +++ b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py @@ -56,3 +56,8 @@ async def stop_payment_due_to_error(self, payment_id: UUID) -> None: ok=False, error_message=message, ) + + +# INVOICE +# OK? +# | PAY | Dulicate From a1d4ea8cb28d55204f824c74799c906d4e242fb9 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Sun, 21 Sep 2025 20:55:48 +0700 Subject: [PATCH 22/45] fix(`adapters`): implement new `transaction`s (#62) --- .../infrastructure/adapters/transaction.py | 91 ++++++++++++++----- .../sqlalchemy/serialization.py | 18 ++++ 2 files changed, 84 insertions(+), 25 deletions(-) create mode 100644 src/ttt/infrastructure/sqlalchemy/serialization.py diff --git a/src/ttt/infrastructure/adapters/transaction.py b/src/ttt/infrastructure/adapters/transaction.py index 017bb9f..fdb680e 100644 --- a/src/ttt/infrastructure/adapters/transaction.py +++ b/src/ttt/infrastructure/adapters/transaction.py @@ -1,34 +1,56 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from types import TracebackType from typing import Self -from sqlalchemy.ext.asyncio import AsyncSession, AsyncSessionTransaction +from sqlalchemy.ext.asyncio import AsyncSession -from ttt.application.common.ports.transaction import Transaction -from ttt.entities.tools.assertion import not_none +from ttt.application.common.ports.transaction import ( + NotSerializableTransaction, + ReadonlyTransaction, + SerializableTransaction, +) +from ttt.entities.tools.assertion import assert_, not_none +from ttt.infrastructure.sqlalchemy.serialization import ( + reraise_serialization_error, +) @dataclass -class InPostgresTransaction(Transaction): +class InPostgresSerializableTransaction(SerializableTransaction): _session: AsyncSession - _transaction: AsyncSessionTransaction | None = field( - init=False, - default=None, - ) - _nesting_counter: int = field( - init=False, - default=0, - ) async def __aenter__(self) -> Self: - self._nesting_counter += 1 + assert_(not self._session.in_transaction()) + await self._session.connection( + execution_options={"isolation_level": "SERIALIZABLE"}, + ) + return self - if self._transaction is None: - self._transaction = await self._session.begin() - elif not self._transaction.is_active: - await self._session.rollback() - self._transaction = await self._session.begin() + async def __aexit__( + self, + error_type: type[BaseException] | None, + error: BaseException | None, + traceback: TracebackType | None, + ) -> None: + transaction = not_none(self._session.get_transaction()) + with reraise_serialization_error(): + await transaction.__aexit__(error_type, error, traceback) + async def commit(self) -> None: + transaction = not_none(self._session.get_transaction()) + with reraise_serialization_error(): + await transaction.commit() + + +@dataclass +class InPostgresNotSerializableTransaction(NotSerializableTransaction): + _session: AsyncSession + + async def __aenter__(self) -> Self: + assert_(not self._session.in_transaction()) + await self._session.connection( + execution_options={"isolation_level": "READ COMMITED"}, + ) return self async def __aexit__( @@ -37,10 +59,29 @@ async def __aexit__( error: BaseException | None, traceback: TracebackType | None, ) -> None: - self._nesting_counter -= 1 + transaction = not_none(self._session.get_transaction()) + await transaction.__aexit__(error_type, error, traceback) - if self._nesting_counter == 0: - transaction = not_none(self._transaction) - await transaction.__aexit__(error_type, error, traceback) - self._transaction = None - return + async def commit(self) -> None: + transaction = not_none(self._session.get_transaction()) + await transaction.commit() + + +@dataclass +class InPostgresReadonlyTransaction(ReadonlyTransaction): + _session: AsyncSession + + async def __aenter__(self) -> Self: + assert_(not self._session.in_transaction()) + options = {"isolation_level": "SERIALIZABLE", "readonly": True} + await self._session.connection(execution_options=options) + return self + + async def __aexit__( + self, + error_type: type[BaseException] | None, + error: BaseException | None, + traceback: TracebackType | None, + ) -> None: + transaction = not_none(self._session.get_transaction()) + await transaction.__aexit__(error_type, error, traceback) diff --git a/src/ttt/infrastructure/sqlalchemy/serialization.py b/src/ttt/infrastructure/sqlalchemy/serialization.py new file mode 100644 index 0000000..3fb7bd9 --- /dev/null +++ b/src/ttt/infrastructure/sqlalchemy/serialization.py @@ -0,0 +1,18 @@ +from collections.abc import Iterator +from contextlib import contextmanager + +from psycopg.errors import SerializationFailure +from sqlalchemy.exc import OperationalError + +from ttt.application.common.errors.serialization_error import SerializationError + + +@contextmanager +def reraise_serialization_error() -> Iterator[None]: + try: + yield + except OperationalError as error: + if isinstance(error.orig, SerializationFailure): + raise SerializationError from error + + raise error from error From 137da13583c9903444e26dae71c10c8bd63c8f34 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 22 Sep 2025 20:40:57 +0700 Subject: [PATCH 23/45] fix: implement new io (#62) --- deploy/dev/nats/streams/game.json | 14 ++ deploy/dev/nats/streams/stars_purchase.json | 14 ++ deploy/prod/nats/streams/game.json | 14 ++ deploy/prod/nats/streams/stars_purchase.json | 14 ++ pyproject.toml | 1 + .../game/game/make_ai_move_in_game.py | 17 +-- .../game/game/make_move_in_game.py | 5 +- .../application/game/game/ports/game_log.py | 7 - .../application/game/game/ports/game_tasks.py | 1 + src/ttt/application/game/game/ports/games.py | 3 +- .../game/game/start_game_with_ai.py | 7 +- ...start_stars_purchase_payment_completion.py | 9 +- .../user/common/ports/user_locks.py | 10 ++ src/ttt/application/user/game/matchmake.py | 5 +- src/ttt/infrastructure/adapters/game_log.py | 121 ++++++++++++++---- src/ttt/infrastructure/adapters/game_tasks.py | 23 ++++ src/ttt/infrastructure/adapters/games.py | 17 ++- src/ttt/infrastructure/adapters/map.py | 13 +- .../paid_stars_purchase_payment_inbox.py | 22 ---- .../adapters/stars_purchase_log.py | 30 +++-- .../adapters/stars_purchase_tasks.py | 31 +++++ .../adapters/stars_purchases.py | 1 - src/ttt/infrastructure/adapters/user_locks.py | 19 +++ src/ttt/infrastructure/nats/messages.py | 26 ---- .../nats/paid_stars_purchase_payment_inbox.py | 42 ------ .../{nats => taskiq}/__init__.py | 0 src/ttt/infrastructure/taskiq/broker.py | 64 +++++++++ .../infrastructure/taskiq/tasks/__init__.py | 0 .../complete_stars_purchase_payment_task.py | 35 +++++ .../taskiq/tasks/make_ai_move_in_game_task.py | 25 ++++ src/ttt/main/common/di.py | 98 +++++++++----- src/ttt/main/tg_bot/start_tg_bot.py | 19 ++- .../complete_stars_purchase_payment_task.py | 17 --- uv.lock | 79 ++++++++++++ 34 files changed, 588 insertions(+), 215 deletions(-) create mode 100644 deploy/dev/nats/streams/game.json create mode 100644 deploy/dev/nats/streams/stars_purchase.json create mode 100644 deploy/prod/nats/streams/game.json create mode 100644 deploy/prod/nats/streams/stars_purchase.json create mode 100644 src/ttt/application/user/common/ports/user_locks.py create mode 100644 src/ttt/infrastructure/adapters/game_tasks.py delete mode 100644 src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py create mode 100644 src/ttt/infrastructure/adapters/stars_purchase_tasks.py create mode 100644 src/ttt/infrastructure/adapters/user_locks.py delete mode 100644 src/ttt/infrastructure/nats/messages.py delete mode 100644 src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py rename src/ttt/infrastructure/{nats => taskiq}/__init__.py (100%) create mode 100644 src/ttt/infrastructure/taskiq/broker.py create mode 100644 src/ttt/infrastructure/taskiq/tasks/__init__.py create mode 100644 src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py create mode 100644 src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py delete mode 100644 src/ttt/presentation/tasks/complete_stars_purchase_payment_task.py diff --git a/deploy/dev/nats/streams/game.json b/deploy/dev/nats/streams/game.json new file mode 100644 index 0000000..786ef94 --- /dev/null +++ b/deploy/dev/nats/streams/game.json @@ -0,0 +1,14 @@ +{ + "name": "GAME", + "subjects": ["game.>"], + "retention": "limits", + "max_consumers": -1, + "max_msgs": -1, + "max_bytes": -1, + "max_age": 31536000000000000, + "max_msg_size": -1, + "storage": "file", + "discard": "old", + "num_replicas": 1, + "duplicate_window": 0 +} diff --git a/deploy/dev/nats/streams/stars_purchase.json b/deploy/dev/nats/streams/stars_purchase.json new file mode 100644 index 0000000..31fa3e0 --- /dev/null +++ b/deploy/dev/nats/streams/stars_purchase.json @@ -0,0 +1,14 @@ +{ + "name": "STARS_PURCHASE", + "subjects": ["stars_purchase.>"], + "retention": "limits", + "max_consumers": -1, + "max_msgs": -1, + "max_bytes": -1, + "max_age": 31536000000000000, + "max_msg_size": -1, + "storage": "file", + "discard": "old", + "num_replicas": 1, + "duplicate_window": 0 +} diff --git a/deploy/prod/nats/streams/game.json b/deploy/prod/nats/streams/game.json new file mode 100644 index 0000000..786ef94 --- /dev/null +++ b/deploy/prod/nats/streams/game.json @@ -0,0 +1,14 @@ +{ + "name": "GAME", + "subjects": ["game.>"], + "retention": "limits", + "max_consumers": -1, + "max_msgs": -1, + "max_bytes": -1, + "max_age": 31536000000000000, + "max_msg_size": -1, + "storage": "file", + "discard": "old", + "num_replicas": 1, + "duplicate_window": 0 +} diff --git a/deploy/prod/nats/streams/stars_purchase.json b/deploy/prod/nats/streams/stars_purchase.json new file mode 100644 index 0000000..31fa3e0 --- /dev/null +++ b/deploy/prod/nats/streams/stars_purchase.json @@ -0,0 +1,14 @@ +{ + "name": "STARS_PURCHASE", + "subjects": ["stars_purchase.>"], + "retention": "limits", + "max_consumers": -1, + "max_msgs": -1, + "max_bytes": -1, + "max_age": 31536000000000000, + "max_msg_size": -1, + "storage": "file", + "discard": "old", + "num_replicas": 1, + "duplicate_window": 0 +} diff --git a/pyproject.toml b/pyproject.toml index 2f18ede..4e36112 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "openai==1.97.0", "structlog==25.4.0", "structlog-sentry==2.2.1", + "taskiq==0.11.18", ] [dependency-groups] diff --git a/src/ttt/application/game/game/make_ai_move_in_game.py b/src/ttt/application/game/game/make_ai_move_in_game.py index 5974e9c..b4a0ac0 100644 --- a/src/ttt/application/game/game/make_ai_move_in_game.py +++ b/src/ttt/application/game/game/make_ai_move_in_game.py @@ -4,13 +4,16 @@ from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms -from ttt.application.common.ports.transaction import SerializableTransaction +from ttt.application.common.ports.transaction import ( + NotSerializableTransaction, +) from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway from ttt.application.game.game.ports.game_dao import GameDao from ttt.application.game.game.ports.game_log import GameLog from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games +from ttt.application.user.common.ports.user_locks import UserLocks from ttt.application.user.common.ports.users import Users from ttt.entities.core.game.game import ( AlreadyCompletedGameError, @@ -28,17 +31,15 @@ class MakeAiMoveInGame: uuids: UUIDs randoms: Randoms ai_gateway: GameAiGateway - transaction: SerializableTransaction + transaction: NotSerializableTransaction log: GameLog dao: GameDao + locks: UserLocks - async def __call__(self, game_id: UUID, ai_id: UUID) -> None: - """ - :raises ttt.application.common.errors.serialization_error.SerializationError: - """ # noqa: E501 - + async def __call__(self, user_id: int, game_id: UUID, ai_id: UUID) -> None: async with self.transaction: - game = await self.games.game_with_id(game_id) + await self.locks.lock_user_by_id(user_id) + game = await self.games.not_locked_game_with_id(game_id) if game is None: await self.log.no_game_to_make_ai_move(game_id) diff --git a/src/ttt/application/game/game/make_move_in_game.py b/src/ttt/application/game/game/make_move_in_game.py index 2018c14..1a929b6 100644 --- a/src/ttt/application/game/game/make_move_in_game.py +++ b/src/ttt/application/game/game/make_move_in_game.py @@ -11,6 +11,7 @@ from ttt.application.game.game.ports.game_tasks import GameTasks from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games +from ttt.application.user.common.ports.user_locks import UserLocks from ttt.application.user.common.ports.users import Users from ttt.entities.core.game.cell import AlreadyFilledCellError from ttt.entities.core.game.game import ( @@ -34,6 +35,7 @@ class MakeMoveInGame: log: GameLog dao: GameDao tasks: GameTasks + locks: UserLocks async def __call__( self, @@ -118,8 +120,9 @@ async def __call__( await self.map_(tracking) if user_move.next_move_ai_id is not None: + await self.locks.lock_user_by_id(user_id) await self.tasks.make_ai_move( - game.id, user_move.next_move_ai_id, + user_id, game.id, user_move.next_move_ai_id, ) await self.transaction.commit() diff --git a/src/ttt/application/game/game/ports/game_log.py b/src/ttt/application/game/game/ports/game_log.py index d890408..419283b 100644 --- a/src/ttt/application/game/game/ports/game_log.py +++ b/src/ttt/application/game/game/ports/game_log.py @@ -28,13 +28,6 @@ async def no_game_to_make_ai_move( /, ) -> None: ... - @abstractmethod - async def no_current_game( - self, - user_id: int, - /, - ) -> None: ... - @abstractmethod async def game_against_ai_started( self, diff --git a/src/ttt/application/game/game/ports/game_tasks.py b/src/ttt/application/game/game/ports/game_tasks.py index e83aff7..2284bc6 100644 --- a/src/ttt/application/game/game/ports/game_tasks.py +++ b/src/ttt/application/game/game/ports/game_tasks.py @@ -6,6 +6,7 @@ class GameTasks(ABC): @abstractmethod async def make_ai_move( self, + user_id: int, game_id: UUID, ai_id: UUID, /, diff --git a/src/ttt/application/game/game/ports/games.py b/src/ttt/application/game/game/ports/games.py index 4f9457d..eec98a7 100644 --- a/src/ttt/application/game/game/ports/games.py +++ b/src/ttt/application/game/game/ports/games.py @@ -12,4 +12,5 @@ class Games(ABC): async def current_user_game(self, user_id: int, /) -> Game | None: ... @abstractmethod - async def game_with_id(self, game_id: UUID, /) -> Game | None: ... + async def not_locked_game_with_id(self, game_id: UUID, /) -> Game | None: + ... diff --git a/src/ttt/application/game/game/start_game_with_ai.py b/src/ttt/application/game/game/start_game_with_ai.py index 1bc1040..cef7aaa 100644 --- a/src/ttt/application/game/game/start_game_with_ai.py +++ b/src/ttt/application/game/game/start_game_with_ai.py @@ -11,6 +11,7 @@ from ttt.application.game.game.ports.game_tasks import GameTasks from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games +from ttt.application.user.common.ports.user_locks import UserLocks from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.common.ports.users import Users from ttt.entities.core.game.ai import AiType @@ -33,6 +34,7 @@ class StartGameWithAi: ai_gateway: GameAiGateway log: GameLog tasks: GameTasks + locks: UserLocks async def __call__(self, user_id: int, ai_type: AiType) -> None: """ @@ -77,8 +79,11 @@ async def __call__(self, user_id: int, ai_type: AiType) -> None: await self.map_(tracking) if started_game.next_move_ai_id is not None: + await self.locks.lock_user_by_id(user_id) await self.tasks.make_ai_move( - started_game.game.id, started_game.next_move_ai_id, + user_id, + started_game.game.id, + started_game.next_move_ai_id, ) await self.transaction.commit() diff --git a/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py index 8d3a78f..2403c58 100644 --- a/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py @@ -1,6 +1,10 @@ from dataclasses import dataclass from uuid import UUID +from ttt.application.common.ports.transaction import ReadonlyTransaction +from ttt.application.stars_purchase.ports.stars_purchase_locks import ( + StarsPurchaseLocks, +) from ttt.application.stars_purchase.ports.stars_purchase_log import ( StarsPurchaseLog, ) @@ -22,6 +26,7 @@ class StartStarsPurchasePaymentCompletion: payment_gateway: StarsPurchasePaymentGateway views: StarsPurchaseViews log: StarsPurchaseLog + locks: StarsPurchaseLocks async def __call__( self, @@ -29,7 +34,9 @@ async def __call__( purchase_id: UUID, success: PaymentSuccess, ) -> None: - await self.tasks.complete_stars_purchase_payment(purchase_id, success) + await self.tasks.complete_stars_purchase_payment( + purchase_id, success, + ) await self.log.stars_purchase_payment_completion_started( purchase_id, success, ) diff --git a/src/ttt/application/user/common/ports/user_locks.py b/src/ttt/application/user/common/ports/user_locks.py new file mode 100644 index 0000000..bfc811e --- /dev/null +++ b/src/ttt/application/user/common/ports/user_locks.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + + +class UserLocks(ABC): + @abstractmethod + async def lock_user_by_id( + self, + user_id: int, + /, + ) -> None: ... diff --git a/src/ttt/application/user/game/matchmake.py b/src/ttt/application/user/game/matchmake.py index cdf688f..9d5c9e7 100644 --- a/src/ttt/application/user/game/matchmake.py +++ b/src/ttt/application/user/game/matchmake.py @@ -4,10 +4,7 @@ from ttt.application.common.ports.emojis import Emojis from ttt.application.common.ports.map import Map -from ttt.application.common.ports.transaction import ( - NotSerializableTransaction, - SerializableTransaction, -) +from ttt.application.common.ports.transaction import NotSerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.user.common.ports.users import Users from ttt.application.user.game.ports.user_log import GameUserLog diff --git a/src/ttt/infrastructure/adapters/game_log.py b/src/ttt/infrastructure/adapters/game_log.py index 377b998..84fd524 100644 --- a/src/ttt/infrastructure/adapters/game_log.py +++ b/src/ttt/infrastructure/adapters/game_log.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from uuid import UUID from structlog.types import FilteringBoundLogger @@ -12,6 +13,98 @@ class StructlogGameLog(GameLog): _logger: FilteringBoundLogger + async def no_current_game_to_make_move( + self, + user_id: int, + /, + ) -> None: + await self._logger.awarning( + "no_current_game_to_make_move", + user_id=user_id, + ) + + async def no_current_game_to_cancel_game( + self, + user_id: int, + /, + ) -> None: + await self._logger.awarning( + "no_current_game_to_cancel_game", + user_id=user_id, + ) + + async def no_game_to_make_ai_move( + self, + game_id: UUID, + /, + ) -> None: + await self._logger.ainfo( + "no_game_to_make_ai_move", + game_id=game_id.hex, + ) + + async def game_was_completed_by_user( + self, + user_id: int, + game: Game, + /, + ) -> None: + await self._logger.ainfo( + "game_was_completed_by_user", + user_id=user_id, + game_id=game.id.hex, + ) + + async def game_was_completed_by_ai( + self, + ai_id: UUID, + game: Game, + /, + ) -> None: + await self._logger.ainfo( + "game_was_completed_by_ai", + ai_id=ai_id.hex, + game_id=game.id.hex, + ) + + async def already_completed_game_to_make_move( + self, + game: Game, + user_id: int, + cell_number_int: int, + /, + ) -> None: + await self._logger.awarning( + "already_completed_game_to_make_move", + user_id=user_id, + game_id=game.id.hex, + cell_number=cell_number_int, + ) + + async def already_completed_game_to_make_ai_move( + self, + game: Game, + ai_id: UUID, + /, + ) -> None: + await self._logger.ainfo( + "already_completed_game_to_make_move", + ai_id=ai_id.hex, + game_id=game.id.hex, + ) + + async def not_ai_current_move_to_make_ai_move( + self, + game: Game, + ai_id: UUID, + /, + ) -> None: + await self._logger.ainfo( + "not_ai_current_move_to_make_ai_move", + ai_id=ai_id.hex, + game_id=game.id.hex, + ) + async def game_against_ai_started( self, game: Game, @@ -50,14 +143,14 @@ async def user_move_maked( async def ai_move_maked( self, - user_id: int, game: Game, move: AiMove, + ai_id: UUID, /, ) -> None: await self._logger.ainfo( "ai_move_maked", - user_id=user_id, + ai_id=ai_id.hex, game_id=game.id.hex, filled_cell_number=int(move.filled_cell_number), was_move_random=move.was_random, @@ -75,20 +168,6 @@ async def game_completed( game_id=game.id.hex, ) - async def already_completed_game_to_make_move( - self, - game: Game, - user_id: int, - cell_number_int: int, - /, - ) -> None: - await self._logger.ainfo( - "already_completed_game_to_make_move", - user_id=user_id, - game_id=game.id.hex, - cell_number_int=cell_number_int, - ) - async def not_current_player_to_make_move( self, game: Game, @@ -152,13 +231,3 @@ async def user_already_in_game_to_start_game_against_ai( "user_already_in_game_to_start_game_against_ai", user_id=user.id, ) - - async def current_game_viewed( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "current_game_viewed", - user_id=user_id, - ) diff --git a/src/ttt/infrastructure/adapters/game_tasks.py b/src/ttt/infrastructure/adapters/game_tasks.py new file mode 100644 index 0000000..25912db --- /dev/null +++ b/src/ttt/infrastructure/adapters/game_tasks.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.application.game.game.ports.game_tasks import GameTasks +from ttt.infrastructure.sqlalchemy.tables.game import TableGame +from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import ( + make_ai_move_in_game_broker_task, +) + + +@dataclass +class TaskiqGameTasks(GameTasks): + async def make_ai_move( + self, + user_id: int, + game_id: UUID, + ai_id: UUID, + /, + ) -> None: + await make_ai_move_in_game_broker_task.kiq(user_id, game_id, ai_id) diff --git a/src/ttt/infrastructure/adapters/games.py b/src/ttt/infrastructure/adapters/games.py index 6d611a1..c5fdd69 100644 --- a/src/ttt/infrastructure/adapters/games.py +++ b/src/ttt/infrastructure/adapters/games.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from uuid import UUID from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -14,13 +15,6 @@ class InPostgresGames(Games): _session: AsyncSession async def current_user_game(self, user_id: int, /) -> Game | None: - lock_stmt = ( - select(TableGame.id) - .where(TableUser.current_game_id == TableGame.id) - .with_for_update() - ) - await self._session.execute(lock_stmt) - join_condition = ( (TableUser.id == user_id) & (TableUser.current_game_id == TableGame.id) @@ -32,3 +26,12 @@ async def current_user_game(self, user_id: int, /) -> Game | None: return None return table_game.entity() + + async def not_locked_game_with_id(self, game_id: UUID, /) -> Game | None: + stmt = ( + select(TableGame) + .where(TableGame.id == game_id) + .with_for_update() + ) + table_game = await self._session.scalar(stmt) + return None if table_game is None else table_game.entity() diff --git a/src/ttt/infrastructure/adapters/map.py b/src/ttt/infrastructure/adapters/map.py index 8ef3fcb..58fd1c8 100644 --- a/src/ttt/infrastructure/adapters/map.py +++ b/src/ttt/infrastructure/adapters/map.py @@ -1,9 +1,10 @@ from dataclasses import dataclass -from psycopg.errors import UniqueViolation -from sqlalchemy.exc import IntegrityError +from psycopg.errors import SerializationFailure, UniqueViolation +from sqlalchemy.exc import IntegrityError, OperationalError from sqlalchemy.ext.asyncio import AsyncSession +from ttt.application.common.errors.serialization_error import SerializationError from ttt.application.common.ports.map import ( Map, MappableTracking, @@ -40,6 +41,8 @@ async def __call__( await self._session.flush() except IntegrityError as error: self._handle_integrity_error(error) + except OperationalError as error: + self._handle_operational_error(error) def _handle_integrity_error(self, error: IntegrityError) -> None: match error.orig: @@ -54,3 +57,9 @@ def _handle_integrity_error(self, error: IntegrityError) -> None: case _: ... raise error from error + + def _handle_operational_error(self, error: OperationalError) -> None: + if isinstance(error.orig, SerializationFailure): + raise SerializationError from error + + raise error from error diff --git a/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py b/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py deleted file mode 100644 index 3d26168..0000000 --- a/src/ttt/infrastructure/adapters/paid_stars_purchase_payment_inbox.py +++ /dev/null @@ -1,22 +0,0 @@ -from collections.abc import AsyncIterator -from dataclasses import dataclass - -from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment -from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 - PaidStarsPurchasePaymentInbox, -) -from ttt.infrastructure.nats.paid_stars_purchase_payment_inbox import ( - InNatsPaidStarsPurchasePaymentInbox as OriginalInNatsPaidStarsPurchasePaymentInbox, # noqa: E501 -) - - -@dataclass(frozen=True) -class InNatsPaidStarsPurchasePaymentInbox(PaidStarsPurchasePaymentInbox): - _inbox: OriginalInNatsPaidStarsPurchasePaymentInbox - - async def push(self, payment: PaidStarsPurchasePayment) -> None: - await self._inbox.push(payment) - - async def __aiter__(self) -> AsyncIterator[PaidStarsPurchasePayment]: - async for payment in self._inbox: - yield payment diff --git a/src/ttt/infrastructure/adapters/stars_purchase_log.py b/src/ttt/infrastructure/adapters/stars_purchase_log.py index 0b66f74..5f3da67 100644 --- a/src/ttt/infrastructure/adapters/stars_purchase_log.py +++ b/src/ttt/infrastructure/adapters/stars_purchase_log.py @@ -3,13 +3,13 @@ from structlog.types import FilteringBoundLogger -from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment from ttt.application.stars_purchase.ports.stars_purchase_log import ( StarsPurchaseLog, ) from ttt.entities.core.stars import Stars from ttt.entities.core.stars_purchase.stars_purchase import StarsPurchase from ttt.entities.core.user.user import User +from ttt.entities.finance.payment.success import PaymentSuccess @dataclass(frozen=True, unsafe_hash=False) @@ -40,35 +40,33 @@ async def stars_puchase_payment_started( async def stars_purchase_payment_completion_started( self, - payment: PaidStarsPurchasePayment, + purchase_id: UUID, + success: PaymentSuccess, /, ) -> None: await self._logger.ainfo( "stars_purchase_payment_completion_started", - user_id=payment.user_id, - purchase_id=payment.purchase_id.hex, + purchase_id=purchase_id.hex, ) async def stars_purchase_payment_completed( self, stars_purchase: StarsPurchase, - payment: PaidStarsPurchasePayment, + success: PaymentSuccess, /, ) -> None: await self._logger.ainfo( "stars_purchase_payment_completed", - user_id=stars_purchase.user.id, purchase_id=stars_purchase.id_.hex, ) async def double_stars_purchase_payment_completion( self, stars_purchase: StarsPurchase, - paid_payment: PaidStarsPurchasePayment, + success: PaymentSuccess, ) -> None: - await self._logger.awarning( + await self._logger.ainfo( "double_stars_purchase_payment_completion", - user_id=stars_purchase.user.id, purchase_id=stars_purchase.id_.hex, ) @@ -79,7 +77,6 @@ async def invalid_stars_for_stars_purchase( ) -> None: await self._logger.aerror( "invalid_stars_for_stars_purchase", - user_id=user.id, stars=stars, ) @@ -89,7 +86,6 @@ async def double_stars_purchase_payment_start( ) -> None: await self._logger.ainfo( "double_stars_purchase_payment_start", - user_id=stars_purchase.user.id, purchase_id=stars_purchase.id_.hex, ) @@ -97,10 +93,18 @@ async def no_stars_purchase_to_start_payment( self, purchase_id: UUID, /, - ) -> None: ... + ) -> None: + await self._logger.aerror( + "no_stars_purchase_to_start_payment", + purchase_id=purchase_id.hex, + ) async def no_stars_purchase_to_complete_payment( self, purchase_id: UUID, /, - ) -> None: ... + ) -> None: + await self._logger.aerror( + "no_stars_purchase_to_complete_payment", + purchase_id=purchase_id.hex, + ) diff --git a/src/ttt/infrastructure/adapters/stars_purchase_tasks.py b/src/ttt/infrastructure/adapters/stars_purchase_tasks.py new file mode 100644 index 0000000..1e573a4 --- /dev/null +++ b/src/ttt/infrastructure/adapters/stars_purchase_tasks.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.application.stars_purchase.ports.stars_purchase_tasks import ( + StarsPurchaseTasks, +) +from ttt.entities.finance.payment.success import PaymentSuccess +from ttt.infrastructure.sqlalchemy.tables.stars_purchase import ( + TableStarsPurchase, +) +from ttt.infrastructure.taskiq.tasks.complete_stars_purchase_payment_task import ( # noqa: E501 + complete_stars_purchase_payment_task, +) + + +@dataclass +class TaskiqStarsPurchaseTasks(StarsPurchaseTasks): + async def complete_stars_purchase_payment( + self, + purchase_id: UUID, + success: PaymentSuccess, + /, + ) -> None: + await complete_stars_purchase_payment_task.kiq( + purchase_id, + success.id, + success.gateway_id, + ) diff --git a/src/ttt/infrastructure/adapters/stars_purchases.py b/src/ttt/infrastructure/adapters/stars_purchases.py index 0c16e8b..32bd0de 100644 --- a/src/ttt/infrastructure/adapters/stars_purchases.py +++ b/src/ttt/infrastructure/adapters/stars_purchases.py @@ -23,7 +23,6 @@ async def stars_purchase_with_id( stmt = ( select(TableStarsPurchase) .where(TableStarsPurchase.id == id_) - .with_for_update() ) table_stars_purchase = await self._session.scalar(stmt) diff --git a/src/ttt/infrastructure/adapters/user_locks.py b/src/ttt/infrastructure/adapters/user_locks.py new file mode 100644 index 0000000..b0bfea3 --- /dev/null +++ b/src/ttt/infrastructure/adapters/user_locks.py @@ -0,0 +1,19 @@ +from pydantic.dataclasses import dataclass +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.application.user.common.ports.user_locks import UserLocks +from ttt.infrastructure.sqlalchemy.tables.user import TableUser + + +@dataclass(frozen=True) +class InPostgresUserLocks(UserLocks): + _session: AsyncSession + + async def lock_user_by_id( + self, + user_id: int, + /, + ) -> None: + stmt = select(1).select_from(TableUser).where(TableUser.id == user_id) + await self._session.execute(stmt) diff --git a/src/ttt/infrastructure/nats/messages.py b/src/ttt/infrastructure/nats/messages.py deleted file mode 100644 index 917556d..0000000 --- a/src/ttt/infrastructure/nats/messages.py +++ /dev/null @@ -1,26 +0,0 @@ -from collections.abc import AsyncIterator - -from nats.aio.msg import Msg -from nats.errors import TimeoutError as NatsTimeoutError -from nats.js import JetStreamContext - - -async def at_least_once_messages( - subscription: JetStreamContext.PullSubscription, - max_len: int = 1, - timeout: float | None = 5, # noqa: ASYNC109 - heartbeat: float | None = None, -) -> AsyncIterator[Msg]: - try: - messages = await subscription.fetch(max_len, timeout, heartbeat) - except NatsTimeoutError: - return - - for message in messages: - try: - yield message - except BaseException as error: - await message.nak() - raise error from error - else: - await message.ack() diff --git a/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py b/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py deleted file mode 100644 index 502571d..0000000 --- a/src/ttt/infrastructure/nats/paid_stars_purchase_payment_inbox.py +++ /dev/null @@ -1,42 +0,0 @@ -from collections.abc import AsyncIterator -from dataclasses import dataclass, field -from types import TracebackType -from typing import ClassVar, Self - -from nats.js import JetStreamContext -from pydantic import TypeAdapter - -from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment -from ttt.infrastructure.nats.messages import at_least_once_messages - - -@dataclass -class InNatsPaidStarsPurchasePaymentInbox: - _js: JetStreamContext - - _subscription: JetStreamContext.PullSubscription = field(init=False) - _adapter: ClassVar = TypeAdapter(PaidStarsPurchasePayment) - _subject: ClassVar = "user.stars_purchase.paid_payment_inbox" - - async def __aenter__(self) -> Self: - self._subscription = await self._js.pull_subscribe( - self._subject, - "ttt-user-stars_purchase-paid_payment_inbox", - "USER", - ) - return self - - async def __aexit__( - self, - _: type[BaseException] | None, - __: BaseException | None, - ___: TracebackType | None, - ) -> None: ... - - async def push(self, payment: PaidStarsPurchasePayment) -> None: - json = self._adapter.dump_json(payment) - await self._js.publish(self._subject, json) - - async def __aiter__(self) -> AsyncIterator[PaidStarsPurchasePayment]: - async for message in at_least_once_messages(self._subscription): - yield self._adapter.validate_json(message.data) diff --git a/src/ttt/infrastructure/nats/__init__.py b/src/ttt/infrastructure/taskiq/__init__.py similarity index 100% rename from src/ttt/infrastructure/nats/__init__.py rename to src/ttt/infrastructure/taskiq/__init__.py diff --git a/src/ttt/infrastructure/taskiq/broker.py b/src/ttt/infrastructure/taskiq/broker.py new file mode 100644 index 0000000..eb82caa --- /dev/null +++ b/src/ttt/infrastructure/taskiq/broker.py @@ -0,0 +1,64 @@ +from collections.abc import AsyncGenerator +from typing import NewType, Protocol + +from nats.aio.msg import Msg as NatsMessage +from nats.errors import TimeoutError as NatsTimeoutError +from nats.js import JetStreamContext +from taskiq import ( + AckableMessage, + AsyncBroker, + BrokerMessage, +) + + +class PullSubscribe(Protocol): + async def __call__( + self, js: JetStreamContext, subject: str, /, + ) -> JetStreamContext.PullSubscription: ... + + +class NatsBroker(AsyncBroker): + _consumer: JetStreamContext.PullSubscription + js: JetStreamContext + + def __init__( + self, + subject: str, + pull_subscribe: PullSubscribe, + pull_consume_batch: int = 1, + pull_consume_timeout: float | None = 5, + ) -> None: + super().__init__() + self._subject = subject + self._pull_subscribe = pull_subscribe + self._pull_consume_batch = pull_consume_batch + self._pull_consume_timeout = pull_consume_timeout + + async def startup(self) -> None: + await super().startup() + self._consumer = await self._pull_subscribe(self.js, self._subject) + + async def kick(self, message: BrokerMessage) -> None: + await self.js.publish( + self._subject, + payload=message.message, + headers=message.labels, + ) + + async def listen(self) -> AsyncGenerator[AckableMessage]: + while True: + try: + nats_messages: list[NatsMessage] = await self._consumer.fetch( + batch=self._pull_consume_batch, + timeout=self._pull_consume_timeout, + ) + for nats_message in nats_messages: + yield AckableMessage( + data=nats_message.data, + ack=nats_message.ack, + ) + except NatsTimeoutError: + continue + + +NatsBrokers = NewType("NatsBrokers", tuple[NatsBroker, ...]) diff --git a/src/ttt/infrastructure/taskiq/tasks/__init__.py b/src/ttt/infrastructure/taskiq/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py b/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py new file mode 100644 index 0000000..86b0aad --- /dev/null +++ b/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py @@ -0,0 +1,35 @@ +from uuid import UUID + +from dishka.integrations.taskiq import FromDishka, inject + +from ttt.application.stars_purchase.complete_stars_purchase_payment import ( + CompleteStarsPurchasePayment, +) +from ttt.entities.finance.payment.success import PaymentSuccess +from ttt.infrastructure.taskiq.broker import NatsBroker + + +complete_stars_purchase_payment_broker = NatsBroker( + "stars_purchase.stars_purchase.complete_stars_purchase_payment", + lambda js, sub: js.pull_subscribe( + sub, + "ttt-stars_purchase-stars_purchase-complete_stars_purchase_payment", + "STARS_PURCHASE", + ), +) + + +@complete_stars_purchase_payment_broker.task() +@inject(patch_module=True) +async def complete_stars_purchase_payment_task( + purchase_id: UUID, + payment_success_id: str, + payment_success_gateway_id: str, + complete_stars_purchase_payment: FromDishka[CompleteStarsPurchasePayment], +) -> None: + payment_success = PaymentSuccess( + payment_success_id, payment_success_gateway_id, + ) + await complete_stars_purchase_payment( + purchase_id, payment_success, + ) diff --git a/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py b/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py new file mode 100644 index 0000000..d8a2f35 --- /dev/null +++ b/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py @@ -0,0 +1,25 @@ +from uuid import UUID + +from dishka.integrations.taskiq import FromDishka, inject + +from ttt.application.game.game.make_ai_move_in_game import MakeAiMoveInGame +from ttt.infrastructure.taskiq.broker import NatsBroker + + +make_ai_move_in_game_broker = NatsBroker( + "game.game.make_ai_move_in_game", + lambda js, sub: js.pull_subscribe( + sub, "ttt-game-game-make_ai_move_in_game", "GAME", + ), +) + + +@make_ai_move_in_game_broker.task() +@inject(patch_module=True) +async def make_ai_move_in_game_broker_task( + user_id: int, + game_id: UUID, + ai_id: UUID, + make_ai_move_in_game: FromDishka[MakeAiMoveInGame], +) -> None: + await make_ai_move_in_game(user_id, game_id, ai_id) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 1e77ba5..7a141a5 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -1,4 +1,6 @@ +from asyncio import gather from collections.abc import AsyncIterator +from typing import NewType from dishka import Provider, Scope, provide from nats import connect as connect_to_nats @@ -14,11 +16,16 @@ from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms -from ttt.application.common.ports.transaction import SerializableTransaction +from ttt.application.common.ports.transaction import ( + NotSerializableTransaction, + ReadonlyTransaction, + SerializableTransaction, +) from ttt.application.common.ports.uuids import UUIDs from ttt.application.game.game.ports.game_ai_gateway import GameAiGateway from ttt.application.game.game.ports.game_dao import GameDao from ttt.application.game.game.ports.game_log import GameLog +from ttt.application.game.game.ports.game_tasks import GameTasks from ttt.application.game.game.ports.games import Games from ttt.application.invitation_to_game.game.ports.invitation_to_game_dao import ( # noqa: E501 InvitationToGameDao, @@ -29,12 +36,12 @@ from ttt.application.invitation_to_game.game.ports.invitations_to_game import ( InvitationsToGame, ) -from ttt.application.stars_purchase.ports.paid_stars_purchase_payment_inbox import ( # noqa: E501 - PaidStarsPurchasePaymentInbox, -) from ttt.application.stars_purchase.ports.stars_purchase_log import ( StarsPurchaseLog, ) +from ttt.application.stars_purchase.ports.stars_purchase_tasks import ( + StarsPurchaseTasks, +) from ttt.application.stars_purchase.ports.stars_purchases import StarsPurchases from ttt.application.user.change_other_user_account.ports.user_log import ( ChangeOtherUserAccountLog, @@ -42,6 +49,7 @@ from ttt.application.user.common.ports.original_admin_token import ( OriginalAdminToken, ) +from ttt.application.user.common.ports.user_locks import UserLocks from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.users import Users from ttt.application.user.emoji_purchase.ports.user_log import ( @@ -55,6 +63,7 @@ from ttt.infrastructure.adapters.game_ai_gateway import GeminiGameAiGateway from ttt.infrastructure.adapters.game_dao import PostgresGameDao from ttt.infrastructure.adapters.game_log import StructlogGameLog +from ttt.infrastructure.adapters.game_tasks import TaskiqGameTasks from ttt.infrastructure.adapters.games import InPostgresGames from ttt.infrastructure.adapters.invitation_to_game_dao import ( PostgresInvitationToGameDao, @@ -69,15 +78,20 @@ from ttt.infrastructure.adapters.original_admin_token import ( TokenAsOriginalAdminToken, ) -from ttt.infrastructure.adapters.paid_stars_purchase_payment_inbox import ( - InNatsPaidStarsPurchasePaymentInbox, -) from ttt.infrastructure.adapters.randoms import MersenneTwisterRandoms from ttt.infrastructure.adapters.stars_purchase_log import ( StructlogStarsPurchaseLog, ) +from ttt.infrastructure.adapters.stars_purchase_tasks import ( + TaskiqStarsPurchaseTasks, +) from ttt.infrastructure.adapters.stars_purchases import PostgresStarsPurchases -from ttt.infrastructure.adapters.transaction import InPostgresTransaction +from ttt.infrastructure.adapters.transaction import ( + InPostgresNotSerializableTransaction, + InPostgresReadonlyTransaction, + InPostgresSerializableTransaction, +) +from ttt.infrastructure.adapters.user_locks import InPostgresUserLocks from ttt.infrastructure.adapters.user_log import ( StructlogChangeOtherUserAccountLog, StructlogCommonUserLog, @@ -87,12 +101,12 @@ ) from ttt.infrastructure.adapters.users import InPostgresUsers from ttt.infrastructure.adapters.uuids import UUIDv4s -from ttt.infrastructure.nats.paid_stars_purchase_payment_inbox import ( - InNatsPaidStarsPurchasePaymentInbox as OriginalInNatsPaidStarsPurchasePaymentInbox, # noqa: E501 -) from ttt.infrastructure.openai.gemini import Gemini, gemini from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets +from ttt.infrastructure.taskiq.broker import NatsBrokers +from ttt.infrastructure.taskiq.tasks.complete_stars_purchase_payment_task import complete_stars_purchase_payment_broker +from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import make_ai_move_in_game_broker class InfrastructureProvider(Provider): @@ -146,16 +160,8 @@ async def provide_redis_pool( finally: await pool.aclose() - @provide(scope=Scope.REQUEST) - async def provide_request_redis( - self, - pool: ConnectionPool, - ) -> AsyncIterator[Redis]: - async with Redis(connection_pool=pool) as redis: - yield redis - @provide(scope=Scope.APP) - async def provide_app_redis(self, envs: Envs) -> AsyncIterator[Redis]: + async def provide_redis(self, envs: Envs) -> AsyncIterator[Redis]: async with Redis.from_url(str(envs.redis_url)) as redis: yield redis @@ -174,29 +180,35 @@ async def provide_jetstream(self, nats: Nats) -> JetStreamContext: return nats.jetstream() @provide(scope=Scope.APP) - async def provide_original_in_nats_paid_stars_purchase_payment_inbox( - self, - jetstream: JetStreamContext, - ) -> AsyncIterator[OriginalInNatsPaidStarsPurchasePaymentInbox]: - inbox = OriginalInNatsPaidStarsPurchasePaymentInbox(jetstream) - async with inbox: - yield inbox + async def provide_taskiq_brokers(self, js: JetStreamContext) -> NatsBrokers: + nats_brokers = ( + complete_stars_purchase_payment_broker, + make_ai_move_in_game_broker, + ) + for broker in nats_brokers: + broker.js = js + + return NatsBrokers(nats_brokers) @provide(scope=Scope.APP) def provide_gemini(self, secrets: Secrets, envs: Envs) -> Gemini: return gemini(secrets.gemini_api_key, envs.gemini_url) - provide_in_nats_paid_stars_purchase_payment_inbox = provide( - InNatsPaidStarsPurchasePaymentInbox, - provides=PaidStarsPurchasePaymentInbox, - scope=Scope.APP, - ) - - provide_transaction = provide( - InPostgresTransaction, + provide_serializable_transaction = provide( + InPostgresSerializableTransaction, provides=SerializableTransaction, scope=Scope.REQUEST, ) + provide_not_serializable_transaction = provide( + InPostgresNotSerializableTransaction, + provides=NotSerializableTransaction, + scope=Scope.REQUEST, + ) + provide_readonly_transaction = provide( + InPostgresReadonlyTransaction, + provides=ReadonlyTransaction, + scope=Scope.REQUEST, + ) provide_games = provide( InPostgresGames, @@ -314,3 +326,19 @@ def provide_randoms(self) -> Randoms: provides=InvitationToGameLog, scope=Scope.REQUEST, ) + provide_user_locks = provide( + InPostgresUserLocks, + provides=UserLocks, + scope=Scope.REQUEST, + ) + + provide_game_tasks = provide( + TaskiqGameTasks, + provides=GameTasks, + scope=Scope.APP, + ) + provide_stars_purchase_tasks = provide( + TaskiqStarsPurchaseTasks, + provides=StarsPurchaseTasks, + scope=Scope.APP, + ) diff --git a/src/ttt/main/tg_bot/start_tg_bot.py b/src/ttt/main/tg_bot/start_tg_bot.py index d17d4eb..b1e3de9 100644 --- a/src/ttt/main/tg_bot/start_tg_bot.py +++ b/src/ttt/main/tg_bot/start_tg_bot.py @@ -1,4 +1,5 @@ import logging +from asyncio import gather from functools import partial from aiogram import Bot, Dispatcher @@ -8,7 +9,10 @@ AiogramMiddlewareData, ContainerMiddleware, ) +from dishka.integrations.taskiq import setup_dishka +from taskiq.api.receiver import run_receiver_task +from ttt.infrastructure.taskiq.broker import NatsBrokers from ttt.presentation.tasks.unkillable_tasks import UnkillableTasks @@ -27,9 +31,22 @@ async def start_tg_bot(container: AsyncContainer) -> None: ) await tasks(next_container) + nats_brokers = await container.get(NatsBrokers) + + for broker in nats_brokers: + setup_dishka(container, broker) + + await gather(*( + broker.startup() + for broker in nats_brokers + )) + bot = await container.get(Bot) try: - await dp.start_polling(bot) + await gather( + dp.start_polling(bot), + gather(*(run_receiver_task(broker) for broker in nats_brokers)), + ) finally: await container.close() diff --git a/src/ttt/presentation/tasks/complete_stars_purchase_payment_task.py b/src/ttt/presentation/tasks/complete_stars_purchase_payment_task.py deleted file mode 100644 index 78d78b8..0000000 --- a/src/ttt/presentation/tasks/complete_stars_purchase_payment_task.py +++ /dev/null @@ -1,17 +0,0 @@ -from dataclasses import dataclass - -from ttt.application.stars_purchase.complete_stars_purchase_payment import ( - CompleteStarsPurchasePayment, -) -from ttt.presentation.tasks.task import NextContainer, Task - - -@dataclass(frozen=True) -class CompleteStarsPurchasePaymentTask(Task): - async def __call__(self, container: NextContainer) -> None: - while True: - async with container() as request: - complete_stars_purchase_payment = await request.get( - CompleteStarsPurchasePayment, - ) - await complete_stars_purchase_payment() diff --git a/uv.lock b/uv.lock index e59404d..0816c7e 100644 --- a/uv.lock +++ b/uv.lock @@ -395,6 +395,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + [[package]] name = "in-memory-db" version = "0.3.0" @@ -413,6 +425,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, ] +[[package]] +name = "izulu" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/58/6d6335c78b7ade54d8a6c6dbaa589e5c21b3fd916341d5a16f774c72652a/izulu-0.50.0.tar.gz", hash = "sha256:cc8e252d5e8560c70b95380295008eeb0786f7b745a405a40d3556ab3252d5f5", size = 48558, upload-time = "2025-03-24T15:52:21.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/9f/bf9d33546bbb6e5e80ebafe46f90b7d8b4a77410b7b05160b0ca8978c15a/izulu-0.50.0-py3-none-any.whl", hash = "sha256:4e9ae2508844e7c5f62c468a8b9e2deba2f60325ef63f01e65b39fd9a6b3fab4", size = 18095, upload-time = "2025-03-24T15:52:19.667Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -767,6 +788,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pycron" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/5d/340be12ae4a69c33102dfb6ddc1dc6e53e69b2d504fa26b5d34a472c3057/pycron-3.2.0.tar.gz", hash = "sha256:e125a28aca0295769541a40633f70b602579df48c9cb357c36c28d2628ba2b13", size = 4248, upload-time = "2025-06-05T13:24:12.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/76/caf316909f4545e7158e0e1defd8956a1da49f4af04f5d16b18c358dfeac/pycron-3.2.0-py3-none-any.whl", hash = "sha256:6d2349746270bd642b71b9f7187cf13f4d9ee2412b4710396a507b5fe4f60dac", size = 4904, upload-time = "2025-06-05T13:24:11.477Z" }, +] + [[package]] name = "pydantic" version = "2.10.6" @@ -904,6 +934,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, ] +[[package]] +name = "pytz" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1039,6 +1078,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/ba/1b1226db704c16426d59e1fcbeaf5138dbc5a50d424aab92b453d33add47/structlog_sentry-2.2.1-py3-none-any.whl", hash = "sha256:868c82348bc18b7d51ef8f878c57e82835c1758dba38f7589fa02a267096a95d", size = 11218, upload-time = "2024-11-12T14:31:50.594Z" }, ] +[[package]] +name = "taskiq" +version = "0.11.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "importlib-metadata" }, + { name = "izulu" }, + { name = "packaging" }, + { name = "pycron" }, + { name = "pydantic" }, + { name = "pytz" }, + { name = "taskiq-dependencies" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/4d/0d1b3b6c77a45d7a8c685a9c916b2532cca36a26771831949b874f6d15c3/taskiq-0.11.18.tar.gz", hash = "sha256:b83e1b70aee74d0a197d4a4a5ba165b8ba85b12a2b3b7ebfa3c6fdcc9e3128a7", size = 54323, upload-time = "2025-07-15T16:25:54.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/d5/46505f57c140d10d4c36f6bd2f2047fb0460e4d5b9b841dc3b93ab8c893d/taskiq-0.11.18-py3-none-any.whl", hash = "sha256:0df58be24e4ef5d19c8ef02581d35d392b0d780d3fe37950e0478022b85ce288", size = 79608, upload-time = "2025-07-15T16:25:52.707Z" }, +] + +[[package]] +name = "taskiq-dependencies" +version = "1.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/90/47a627696e53bfdcacabc3e8c05b73bf1424685bcb5f17209cb8b12da1bf/taskiq_dependencies-1.5.7.tar.gz", hash = "sha256:0d3b240872ef152b719153b9526d866d2be978aeeaea6600e878414babc2dcb4", size = 14875, upload-time = "2025-02-26T22:07:39.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/6d/4a012f2de002c2e93273f5e7d3e3feea02f7fdbb7b75ca2ca1dd10703091/taskiq_dependencies-1.5.7-py3-none-any.whl", hash = "sha256:6fcee5d159bdb035ef915d4d848826169b6f06fe57cc2297a39b62ea3e76036f", size = 13801, upload-time = "2025-02-26T22:07:38.622Z" }, +] + [[package]] name = "tqdm" version = "4.67.1" @@ -1071,6 +1139,7 @@ dependencies = [ { name = "sqlalchemy" }, { name = "structlog" }, { name = "structlog-sentry" }, + { name = "taskiq" }, ] [package.dev-dependencies] @@ -1101,6 +1170,7 @@ requires-dist = [ { name = "sqlalchemy", specifier = "==2.0.41" }, { name = "structlog", specifier = "==25.4.0" }, { name = "structlog-sentry", specifier = "==2.2.1" }, + { name = "taskiq", specifier = "==0.11.18" }, ] [package.metadata.requires-dev] @@ -1200,3 +1270,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 8664e90be743da1e4bf1e91247f87118280e6d9a Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:03:00 +0700 Subject: [PATCH 24/45] fix: add `SerializationError` retrying (#62) --- src/ttt/application/common/ports/retry.py | 6 +++ .../start_stars_purchase_payment.py | 6 ++- ...start_stars_purchase_payment_completion.py | 9 ---- src/ttt/infrastructure/adapters/retry.py | 12 ++++++ src/ttt/infrastructure/retrier.py | 43 +++++++++++++++++++ .../complete_stars_purchase_payment_task.py | 6 +-- .../taskiq/tasks/make_ai_move_in_game_task.py | 4 +- src/ttt/main/common/di.py | 10 +++++ .../stars_purchase_payment_gateway.py | 5 --- .../aiogram/user/routes/handle_payment.py | 9 ++-- .../user/routes/handle_pre_checkout_query.py | 4 +- .../authorize_other_user_as_admin_window.py | 8 +++- .../change_other_user_account2_window.py | 5 ++- .../deauthorize_other_user_as_admin_window.py | 8 +++- .../admin_dialog/main_window.py | 12 ++++-- .../admin_dialog/other_user_profile_window.py | 6 ++- .../relinquish_admin_right2_window.py | 4 +- .../ai_type_to_start_game_window.py | 4 +- .../main_dialog/emoji_shop_window.py | 4 +- .../main_dialog/emojis_window.py | 7 ++- .../main_dialog/game_start_window.py | 10 +++-- .../aiogram_dialog/main_dialog/game_window.py | 4 +- .../incoming_invitation_to_game_window.py | 11 ++++- .../incoming_invitations_to_game_window.py | 7 ++- .../aiogram_dialog/main_dialog/main_window.py | 13 ++++-- .../outcoming_invitations_to_game_window.py | 10 +++-- .../main_dialog/profile_window.py | 4 +- .../main_dialog/stars_shop_window.py | 4 +- .../auto_cancel_invitations_to_game_task.py | 4 +- src/ttt/presentation/tasks/matchmake_tasks.py | 4 +- 30 files changed, 185 insertions(+), 58 deletions(-) create mode 100644 src/ttt/application/common/ports/retry.py create mode 100644 src/ttt/infrastructure/adapters/retry.py create mode 100644 src/ttt/infrastructure/retrier.py diff --git a/src/ttt/application/common/ports/retry.py b/src/ttt/application/common/ports/retry.py new file mode 100644 index 0000000..d50e8da --- /dev/null +++ b/src/ttt/application/common/ports/retry.py @@ -0,0 +1,6 @@ +from abc import ABC, abstractmethod + + +class Retry(ABC): + @abstractmethod + def __bool__(self) -> bool: ... diff --git a/src/ttt/application/stars_purchase/start_stars_purchase_payment.py b/src/ttt/application/stars_purchase/start_stars_purchase_payment.py index 542aead..37fa8b4 100644 --- a/src/ttt/application/stars_purchase/start_stars_purchase_payment.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase_payment.py @@ -4,6 +4,7 @@ from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map +from ttt.application.common.ports.retry import Retry from ttt.application.common.ports.transaction import SerializableTransaction from ttt.application.common.ports.uuids import UUIDs from ttt.application.stars_purchase.ports.stars_purchase_log import ( @@ -26,8 +27,9 @@ class StartStarsPurchasePayment: payment_gateway: StarsPurchasePaymentGateway map_: Map log: StarsPurchaseLog + retry: Retry - async def __call__(self, purchase_id: UUID, retry: bool) -> None: # noqa: FBT001 + async def __call__(self, purchase_id: UUID) -> None: """ :raises ttt.application.common.errors.serialization_error.SerializationError: """ # noqa: E501 @@ -57,7 +59,7 @@ async def __call__(self, purchase_id: UUID, retry: bool) -> None: # noqa: FBT00 ) await self.transaction.commit() - if retry: + if self.retry: await self.payment_gateway.start_payment(payment_id) else: await self.payment_gateway.stop_payment_due_to_dublicate( diff --git a/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py index 2403c58..ddbb4b3 100644 --- a/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py +++ b/src/ttt/application/stars_purchase/start_stars_purchase_payment_completion.py @@ -1,16 +1,9 @@ from dataclasses import dataclass from uuid import UUID -from ttt.application.common.ports.transaction import ReadonlyTransaction -from ttt.application.stars_purchase.ports.stars_purchase_locks import ( - StarsPurchaseLocks, -) from ttt.application.stars_purchase.ports.stars_purchase_log import ( StarsPurchaseLog, ) -from ttt.application.stars_purchase.ports.stars_purchase_payment_gateway import ( # noqa: E501 - StarsPurchasePaymentGateway, -) from ttt.application.stars_purchase.ports.stars_purchase_tasks import ( StarsPurchaseTasks, ) @@ -23,10 +16,8 @@ @dataclass(frozen=True, unsafe_hash=False) class StartStarsPurchasePaymentCompletion: tasks: StarsPurchaseTasks - payment_gateway: StarsPurchasePaymentGateway views: StarsPurchaseViews log: StarsPurchaseLog - locks: StarsPurchaseLocks async def __call__( self, diff --git a/src/ttt/infrastructure/adapters/retry.py b/src/ttt/infrastructure/adapters/retry.py new file mode 100644 index 0000000..fc986ca --- /dev/null +++ b/src/ttt/infrastructure/adapters/retry.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.retry import Retry +from ttt.infrastructure.retrier import Retrier + + +@dataclass +class RetrierRetry(Retry): + _retries: Retrier + + def __bool__(self) -> bool: + return self._retries.retry() diff --git a/src/ttt/infrastructure/retrier.py b/src/ttt/infrastructure/retrier.py new file mode 100644 index 0000000..3e3178c --- /dev/null +++ b/src/ttt/infrastructure/retrier.py @@ -0,0 +1,43 @@ +from collections.abc import Callable +from dataclasses import dataclass, field + + +type MaxRetries = int +type Retries = int + + +@dataclass +class Retrier: + _max_retries_map: dict[type[BaseException], MaxRetries] + + _retries_map: dict[type[BaseException], Retries] = field( + init=False, default_factory=dict, + ) + + def retry(self) -> bool: + return bool(self._retries_map) + + async def __call__[**PmT, RT]( + self, + action: Callable[PmT, RT], + *args: PmT.args, + **kwargs: PmT.kwargs, + ) -> RT: + while True: + try: + return action(*args, **kwargs) + except BaseException as error: + if type(error) not in self._max_retries_map: + raise error from error + + for error_type, max_retries in self._max_retries_map.items(): + if not isinstance(error, error_type): + continue + + if error_type not in self._retries_map: + self._retries_map[error_type] = 0 + + self._retries_map[error_type] += 1 + + if self._retries_map[error_type] > max_retries: + raise error from error diff --git a/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py b/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py index 86b0aad..928c7fc 100644 --- a/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py +++ b/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py @@ -6,6 +6,7 @@ CompleteStarsPurchasePayment, ) from ttt.entities.finance.payment.success import PaymentSuccess +from ttt.infrastructure.retrier import Retrier from ttt.infrastructure.taskiq.broker import NatsBroker @@ -26,10 +27,9 @@ async def complete_stars_purchase_payment_task( payment_success_id: str, payment_success_gateway_id: str, complete_stars_purchase_payment: FromDishka[CompleteStarsPurchasePayment], + retrier: FromDishka[Retrier], ) -> None: payment_success = PaymentSuccess( payment_success_id, payment_success_gateway_id, ) - await complete_stars_purchase_payment( - purchase_id, payment_success, - ) + await retrier(complete_stars_purchase_payment, purchase_id, payment_success) diff --git a/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py b/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py index d8a2f35..d7e54e1 100644 --- a/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py +++ b/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py @@ -3,6 +3,7 @@ from dishka.integrations.taskiq import FromDishka, inject from ttt.application.game.game.make_ai_move_in_game import MakeAiMoveInGame +from ttt.infrastructure.retrier import Retrier from ttt.infrastructure.taskiq.broker import NatsBroker @@ -21,5 +22,6 @@ async def make_ai_move_in_game_broker_task( game_id: UUID, ai_id: UUID, make_ai_move_in_game: FromDishka[MakeAiMoveInGame], + retrier: FromDishka[Retrier], ) -> None: - await make_ai_move_in_game(user_id, game_id, ai_id) + await retrier(make_ai_move_in_game, user_id, game_id, ai_id) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 7a141a5..504f8e6 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -13,9 +13,11 @@ create_async_engine, ) +from ttt.application.common.errors.serialization_error import SerializationError from ttt.application.common.ports.clock import Clock from ttt.application.common.ports.map import Map from ttt.application.common.ports.randoms import Randoms +from ttt.application.common.ports.retry import Retry from ttt.application.common.ports.transaction import ( NotSerializableTransaction, ReadonlyTransaction, @@ -79,6 +81,7 @@ TokenAsOriginalAdminToken, ) from ttt.infrastructure.adapters.randoms import MersenneTwisterRandoms +from ttt.infrastructure.adapters.retry import RetrierRetry from ttt.infrastructure.adapters.stars_purchase_log import ( StructlogStarsPurchaseLog, ) @@ -104,6 +107,7 @@ from ttt.infrastructure.openai.gemini import Gemini, gemini from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets +from ttt.infrastructure.retrier import Retrier from ttt.infrastructure.taskiq.broker import NatsBrokers from ttt.infrastructure.taskiq.tasks.complete_stars_purchase_payment_task import complete_stars_purchase_payment_broker from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import make_ai_move_in_game_broker @@ -342,3 +346,9 @@ def provide_randoms(self) -> Randoms: provides=StarsPurchaseTasks, scope=Scope.APP, ) + + @provide(scope=Scope.REQUEST) + def provide_retrier(self) -> Retrier: + return Retrier(_max_retries_map={SerializationError: 10}) + + provide_retry = provide(RetrierRetry, provides=Retry, scope=Scope.REQUEST) diff --git a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py index 8fdd72b..3b2b331 100644 --- a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py +++ b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py @@ -56,8 +56,3 @@ async def stop_payment_due_to_error(self, payment_id: UUID) -> None: ok=False, error_message=message, ) - - -# INVOICE -# OK? -# | PAY | Dulicate diff --git a/src/ttt/presentation/aiogram/user/routes/handle_payment.py b/src/ttt/presentation/aiogram/user/routes/handle_payment.py index 2afed92..86b4271 100644 --- a/src/ttt/presentation/aiogram/user/routes/handle_payment.py +++ b/src/ttt/presentation/aiogram/user/routes/handle_payment.py @@ -3,12 +3,12 @@ from dishka import AsyncContainer from dishka.integrations.aiogram import inject -from ttt.application.stars_purchase.dto.common import PaidStarsPurchasePayment from ttt.application.stars_purchase.start_stars_purchase_payment_completion import ( # noqa: E501 StartStarsPurchasePaymentCompletion, ) from ttt.entities.finance.payment.success import PaymentSuccess from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram.user.invoices import ( StarsPurchaseInvoicePayload, invoce_payload_adapter, @@ -36,12 +36,13 @@ async def _( match invoce_payload: case StarsPurchaseInvoicePayload(): + retrier = await dishka_container.get(Retrier) start_stars_purchase_payment_completion = ( await dishka_container.get(StartStarsPurchasePaymentCompletion) ) - payment = PaidStarsPurchasePayment( - invoce_payload.purchase_id, + await retrier( + start_stars_purchase_payment_completion, invoce_payload.user_id, + invoce_payload.purchase_id, success, ) - await start_stars_purchase_payment_completion(payment) diff --git a/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py b/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py index 1508cf0..b1f473d 100644 --- a/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py +++ b/src/ttt/presentation/aiogram/user/routes/handle_pre_checkout_query.py @@ -6,6 +6,7 @@ from ttt.application.stars_purchase.start_stars_purchase_payment import ( StartStarsPurchasePayment, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram.user.invoices import ( StarsPurchaseInvoicePayload, invoce_payload_adapter, @@ -27,5 +28,6 @@ async def _( match invoce_payload: case StarsPurchaseInvoicePayload(): + retrier = await dishka_container.get(Retrier) action = await dishka_container.get(StartStarsPurchasePayment) - await action(invoce_payload.purchase_id) + await retrier(action, invoce_payload.purchase_id) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/authorize_other_user_as_admin_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/authorize_other_user_as_admin_window.py index b2ca006..a76f2d9 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/authorize_other_user_as_admin_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/authorize_other_user_as_admin_window.py @@ -14,6 +14,7 @@ AuthorizeOtherUserAsAdmin, ) from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, @@ -26,6 +27,7 @@ async def input_user_id( _: MessageInput, manager: DialogManager, authorize_other_user_as_admin: FromDishka[AuthorizeOtherUserAsAdmin], + retrier: FromDishka[Retrier], ) -> None: try: other_user_id = int(message.text) # type: ignore[arg-type] @@ -37,8 +39,10 @@ async def input_user_id( ShowMode.DELETE_AND_SEND, ) else: - await authorize_other_user_as_admin( - not_none(message.from_user).id, other_user_id, + await retrier( + authorize_other_user_as_admin, + not_none(message.from_user).id, + other_user_id, ) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account2_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account2_window.py index b366004..fe43a05 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account2_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account2_window.py @@ -26,6 +26,7 @@ ) from ttt.entities.core.stars import Stars from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.hint import hint @@ -46,6 +47,7 @@ async def getter( event_from_user: User, view_user_account_to_change: FromDishka[ViewUserAccountToChange], result_buffer: FromDishka[ResultBuffer], + retrier: FromDishka[Retrier], dialog_manager: DialogManager, **_: Any, # noqa: ANN401 ) -> dict[str, Any]: @@ -57,7 +59,8 @@ async def getter( view = ChangeOtherUserAccount2View(stars) return view.window_data() - await view_user_account_to_change( + await retrier( + view_user_account_to_change, event_from_user.id, dialog_manager.start_data["other_user_id"], ) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/deauthorize_other_user_as_admin_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/deauthorize_other_user_as_admin_window.py index 5283b8b..64aaaad 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/deauthorize_other_user_as_admin_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/deauthorize_other_user_as_admin_window.py @@ -15,6 +15,7 @@ DeauthorizeOtherUserAsAdmin, ) from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, @@ -27,6 +28,7 @@ async def input_user_id( _: MessageInput, manager: DialogManager, deauthorize_other_user_as_admin: FromDishka[DeauthorizeOtherUserAsAdmin], + retrier: FromDishka[Retrier], ) -> None: try: other_user_id = int(message.text) # type: ignore[arg-type] @@ -38,8 +40,10 @@ async def input_user_id( ShowMode.DELETE_AND_SEND, ) else: - await deauthorize_other_user_as_admin( - not_none(message.from_user).id, other_user_id, + await retrier( + deauthorize_other_user_as_admin, + not_none(message.from_user).id, + other_user_id, ) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py index f995ffe..e76bd1b 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py @@ -15,6 +15,7 @@ from ttt.application.user.relinquish_admin_right import RelinquishAdminRight from ttt.application.user.view_admin_menu import ViewAdminMenu from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import ( AdminDialogState, AdminRightName, @@ -84,10 +85,11 @@ async def main_getter( *, event_from_user: User, view_admin_menu: FromDishka[ViewAdminMenu], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_admin_menu(event_from_user.id) + await retrier(view_admin_menu, event_from_user.id) view: AdminMainMenuView = result_buffer(AdminMainMenuView) # type: ignore[arg-type] return view.window_data() @@ -99,6 +101,7 @@ async def input_admin_token( _: MessageInput, manager: DialogManager, authorize_as_admin: FromDishka[AuthorizeAsAdmin], + retrier: FromDishka[Retrier], ) -> None: admin_token = message.text @@ -111,7 +114,9 @@ async def input_admin_token( ) return - await authorize_as_admin(not_none(message.from_user).id, admin_token) + await retrier( + authorize_as_admin, not_none(message.from_user).id, admin_token, + ) @inject @@ -120,8 +125,9 @@ async def on_relinquish_admin_right_clicked( _: Button, __: DialogManager, relinquish_admin_right: FromDishka[RelinquishAdminRight], + retrier: FromDishka[Retrier], ) -> None: - await relinquish_admin_right(callback.from_user.id) + await retrier(relinquish_admin_right, callback.from_user.id) @FuncText diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py index 0903491..2d4eda2 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py @@ -17,6 +17,7 @@ from ttt.entities.core.user.admin_right import AdminRight from ttt.entities.core.user.rank import UsersWithMaxRating, rank from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import ( AdminDialogState, AdminRightName, @@ -81,6 +82,7 @@ async def input_user_id( _: MessageInput, manager: DialogManager, view_other_user: FromDishka[ViewOtherUser], + retrier: FromDishka[Retrier], ) -> None: try: other_user_id = int(message.text) # type: ignore[arg-type] @@ -92,7 +94,9 @@ async def input_user_id( ShowMode.DELETE_AND_SEND, ) else: - await view_other_user(not_none(message.from_user).id, other_user_id) + await retrier( + view_other_user, not_none(message.from_user).id, other_user_id, + ) other_user_profile_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right2_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right2_window.py index 20e9afd..8b7a570 100644 --- a/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right2_window.py +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right2_window.py @@ -9,6 +9,7 @@ from magic_filter import F from ttt.application.user.relinquish_admin_right import RelinquishAdminRight +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( @@ -22,8 +23,9 @@ async def on_yes_clicked( _: Button, __: DialogManager, relinquish_admin_right: FromDishka[RelinquishAdminRight], + retrier: FromDishka[Retrier], ) -> None: - await relinquish_admin_right(callback.from_user.id) + await retrier(relinquish_admin_right, callback.from_user.id) relinquish_admin_right2_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py index a5547bc..67a09ee 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py @@ -12,6 +12,7 @@ from ttt.application.game.game.start_game_with_ai import StartGameWithAi from ttt.entities.core.game.ai import AiType +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState @@ -21,8 +22,9 @@ async def on_game_against_gemini_2_0_flash_clicked( _: Button, __: DialogManager, start_game_with_ai: FromDishka[StartGameWithAi], + retrier: FromDishka[Retrier], ) -> None: - await start_game_with_ai(callback.from_user.id, AiType.gemini_2_0_flash) + await retrier(start_game_with_ai, callback.from_user.id, AiType.gemini_2_0_flash) ai_type_to_start_game_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/emoji_shop_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/emoji_shop_window.py index 11c225c..23e8d6c 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/emoji_shop_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/emoji_shop_window.py @@ -13,6 +13,7 @@ from ttt.application.user.emoji_purchase.buy_emoji import BuyEmoji from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram.user.parsing import parsed_emoji_str from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( @@ -27,10 +28,11 @@ async def handler( _: MessageInput, __: DialogManager, buy_emoji: FromDishka[BuyEmoji], + retrier: FromDishka[Retrier], ) -> None: emoji_str = parsed_emoji_str(message) - await buy_emoji(not_none(message.from_user).id, emoji_str) + await retrier(buy_emoji, not_none(message.from_user).id, emoji_str) emoji_shop_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/emojis_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/emojis_window.py index cdaf6dd..3c2552b 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/emojis_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/emojis_window.py @@ -17,6 +17,7 @@ from ttt.application.user.emoji_selection.select_emoji import SelectEmoji from ttt.application.user.view_user_emojis import ViewUserEmojis +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState from ttt.presentation.result_buffer import ResultBuffer @@ -65,9 +66,10 @@ async def emoji_getter( event_from_user: User, view_user_emojis: FromDishka[ViewUserEmojis], result_buffer: FromDishka[ResultBuffer], + retrier: FromDishka[Retrier], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_user_emojis(event_from_user.id) + await retrier(view_user_emojis, event_from_user.id) view = result_buffer(EmojiMenuView) return view.window_data() @@ -80,8 +82,9 @@ async def on_emoji_selected( __: DialogManager, emoji_str: str, select_emoji: FromDishka[SelectEmoji], + retrier: FromDishka[Retrier], ) -> None: - await select_emoji(callback.from_user.id, emoji_str) + await retrier(select_emoji, callback.from_user.id, emoji_str) emoji_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py index 2bac329..f905cd6 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/game_start_window.py @@ -19,6 +19,7 @@ ) from ttt.application.user.game.view_matchmaking import ViewMatchmaking from ttt.application.user.game.wait_for_matchmaking import WaitForMatchmaking +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( @@ -39,8 +40,9 @@ async def on_wait_for_matchmaking_clicked( _: Button, __: DialogManager, wait_for_matchmaking: FromDishka[WaitForMatchmaking], + retrier: FromDishka[Retrier], ) -> None: - await wait_for_matchmaking(callback.from_user.id) + await retrier(wait_for_matchmaking, callback.from_user.id) @inject @@ -49,8 +51,9 @@ async def on_dont_wait_for_matchmaking_clicked( _: Button, __: DialogManager, dont_wait_for_matchmaking: FromDishka[DontWaitForMatchmaking], + retrier: FromDishka[Retrier], ) -> None: - await dont_wait_for_matchmaking(callback.from_user.id) + await retrier(dont_wait_for_matchmaking, callback.from_user.id) @inject @@ -58,10 +61,11 @@ async def getter( *, event_from_user: User, view_matchmaking: FromDishka[ViewMatchmaking], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_matchmaking(event_from_user.id) + await retrier(view_matchmaking, event_from_user.id) view = result_buffer(GameStartView) return view.window_data() diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/game_window.py index ce5a98c..b9490df 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/game_window.py @@ -26,6 +26,7 @@ from ttt.entities.core.user.loss import UserLoss from ttt.entities.core.user.win import UserWin from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, @@ -43,9 +44,10 @@ async def on_cell_clicked( button: Button, _: DialogManager, make_move_in_game: FromDishka[MakeMoveInGame], + retrier: FromDishka[Retrier], ) -> None: cell_number_int = int(not_none(button.widget_id)[-1]) - await make_move_in_game(callback.from_user.id, cell_number_int) + await retrier(make_move_in_game, callback.from_user.id, cell_number_int) def cell_button(cell_number_int: int) -> Button: diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py index 16923bc..0c4c70a 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py @@ -22,6 +22,7 @@ from ttt.application.invitation_to_game.game.reject_invitation_to_game import ( RejectInvitationToGame, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.func_text import FuncText from ttt.presentation.aiogram_dialog.common.wigets.hint import hint @@ -43,12 +44,15 @@ async def on_accept_clicked( _: Button, manager: DialogManager, accept_invitation_to_game: FromDishka[AcceptInvitationToGame], + retrier: FromDishka[Retrier], ) -> None: if not isinstance(manager.start_data, dict): raise TypeError invitation_id = UUID(hex=manager.start_data["main"]["id_hex"]) - await accept_invitation_to_game(callback.from_user.id, invitation_id) + await retrier( + accept_invitation_to_game, callback.from_user.id, invitation_id, + ) @inject @@ -57,12 +61,15 @@ async def on_reject_clicked( _: Button, manager: DialogManager, reject_invitation_to_game: FromDishka[RejectInvitationToGame], + retrier: FromDishka[Retrier], ) -> None: if not isinstance(manager.start_data, dict): raise TypeError invitation_id = UUID(hex=manager.start_data["main"]["id_hex"]) - await reject_invitation_to_game(callback.from_user.id, invitation_id) + await retrier( + reject_invitation_to_game, callback.from_user.id, invitation_id, + ) async def incoming_invitation_to_game_html( # noqa: RUF029 diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py index 49f0170..3e27c83 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py @@ -20,6 +20,7 @@ from ttt.application.invitation_to_game.game.view_incoming_invitations_to_game import ( # noqa: E501 ViewIncomingInvitationsToGame, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.func_text import FuncText from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState @@ -55,10 +56,11 @@ async def getter( *, event_from_user: User, view_invitations: FromDishka[ViewIncomingInvitationsToGame], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_invitations(event_from_user.id) + await retrier(view_invitations, event_from_user.id) view = result_buffer(IncomingInvitationsToGameView) return view.window_data() @@ -71,10 +73,11 @@ async def on_invitation_selected( manager: DialogManager, invitation_id_hex: str, view_invitation_to_game: FromDishka[ViewIncomingInvitationToGame], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], ) -> None: invitation_id = UUID(hex=invitation_id_hex) - await view_invitation_to_game(callback_query.from_user.id, invitation_id) + await retrier(view_invitation_to_game, callback_query.from_user.id, invitation_id) view = result_buffer.result if not isinstance(view, IncomingInvitationToGameView | None): diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py index 780ea97..c87ccf8 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py @@ -21,6 +21,7 @@ from ttt.entities.core.stars import Stars from ttt.entities.core.user.rank import UsersWithMaxRating, rank from ttt.entities.elo.rating import EloRating +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.func_text import FuncText from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( @@ -73,10 +74,11 @@ async def main_getter( *, event_from_user: User, view_main_menu: FromDishka[ViewMainMenu], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_main_menu(event_from_user.id) + await retrier(view_main_menu, event_from_user.id) view = result_buffer(MainMenuView) return view.window_data() @@ -88,8 +90,9 @@ async def on_cancel_game_clicked( _: Button, __: DialogManager, cancel_game: FromDishka[CancelGame], + retrier: FromDishka[Retrier], ) -> None: - await cancel_game(callback.from_user.id) + await retrier(cancel_game, callback.from_user.id) @inject @@ -98,9 +101,10 @@ async def on_back_to_game_clicked( _: Button, manager: DialogManager, view_game: FromDishka[ViewGame], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], ) -> None: - await view_game(callback.from_user.id) + await retrier(view_game, callback.from_user.id) view = result_buffer(ActiveGameView) data = view.window_data() @@ -113,9 +117,10 @@ async def on_incoming_invitation_to_game_clicked( _: Button, manager: DialogManager, view_invitation_to_game: FromDishka[ViewOneIncomingInvitationToGame], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], ) -> None: - await view_invitation_to_game(callback_query.from_user.id) + await retrier(view_invitation_to_game, callback_query.from_user.id) view = result_buffer.result if not isinstance(view, IncomingInvitationToGameView | None): diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py index 1e2edf4..5bf279a 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py @@ -24,6 +24,7 @@ from ttt.application.invitation_to_game.game.view_outcoming_invitations_to_game import ( # noqa: E501 ViewOutcomingInvitationsToGame, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, @@ -58,10 +59,11 @@ async def getter( *, event_from_user: User, view_invitations: FromDishka[ViewOutcomingInvitationsToGame], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_invitations(event_from_user.id) + await retrier(view_invitations, event_from_user.id) view = result_buffer(OutcomingInvitationsToGameView) return view.window_data() @@ -74,9 +76,10 @@ async def on_invitation_selected( __: DialogManager, invitation_id_hex: str, cancel_invitation_to_game: FromDishka[CancelInvitationToGame], + retrier: FromDishka[Retrier], ) -> None: invitation_id = UUID(hex=invitation_id_hex) - await cancel_invitation_to_game(callback_query.from_user.id, invitation_id) + await retrier(cancel_invitation_to_game, callback_query.from_user.id, invitation_id) @inject @@ -85,6 +88,7 @@ async def input_user_id( _: MessageInput, manager: DialogManager, invite_to_game: FromDishka[InviteToGame], + retrier: FromDishka[Retrier], ) -> None: try: invited_user_id = int(message.text) # type: ignore[arg-type] @@ -96,7 +100,7 @@ async def input_user_id( ShowMode.DELETE_AND_SEND, ) else: - await invite_to_game(not_none(message.from_user).id, invited_user_id) + await retrier(invite_to_game, not_none(message.from_user).id, invited_user_id) outcoming_invitations_to_game_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py index 7c1ba14..70527cb 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py @@ -12,6 +12,7 @@ from ttt.application.user.view_user import ViewUser from ttt.entities.core.user.rank import UsersWithMaxRating, rank +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState from ttt.presentation.result_buffer import ResultBuffer @@ -58,10 +59,11 @@ async def profile_getter( *, event_from_user: User, view_user: FromDishka[ViewUser], + retrier: FromDishka[Retrier], result_buffer: FromDishka[ResultBuffer], **_: Any, # noqa: ANN401 ) -> dict[str, Any]: - await view_user(event_from_user.id) + await retrier(view_user, event_from_user.id) view = result_buffer(UserProfileView) return view.window_data() diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py index 42bb68a..8a7f349 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/stars_shop_window.py @@ -15,6 +15,7 @@ from ttt.application.stars_purchase.start_stars_purchase import ( StartStarsPurchase, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, @@ -28,6 +29,7 @@ async def on_stars_purchase_clicked( button: Button, _: DialogManager, start_stars_purchase: FromDishka[StartStarsPurchase], + retrier: FromDishka[Retrier], ) -> None: match button.widget_id: case "8192_stars_purchase": @@ -41,7 +43,7 @@ async def on_stars_purchase_clicked( case _: raise ValueError(button.widget_id) - await start_stars_purchase(callback.from_user.id, stars) + await retrier(start_stars_purchase, callback.from_user.id, stars) @inject diff --git a/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py b/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py index ba5e7f5..00e69d1 100644 --- a/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py +++ b/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py @@ -4,6 +4,7 @@ from ttt.application.invitation_to_game.game.auto_cancel_invitations_to_game import ( # noqa: E501 AutoCancelInvitationsToGame, ) +from ttt.infrastructure.retrier import Retrier from ttt.presentation.tasks.task import NextContainer, Task @@ -15,7 +16,8 @@ async def __call__(self, container: NextContainer) -> None: while True: await sleep(self._interval_seconds) async with container() as request: + retrier = await request.get(Retrier) cancel_invitations = await request.get( AutoCancelInvitationsToGame, ) - await cancel_invitations() + await retrier(cancel_invitations) diff --git a/src/ttt/presentation/tasks/matchmake_tasks.py b/src/ttt/presentation/tasks/matchmake_tasks.py index 5d7b06d..8e013ac 100644 --- a/src/ttt/presentation/tasks/matchmake_tasks.py +++ b/src/ttt/presentation/tasks/matchmake_tasks.py @@ -4,6 +4,7 @@ from structlog.types import FilteringBoundLogger from ttt.application.user.game.matchmake import Matchmake +from ttt.infrastructure.retrier import Retrier from ttt.infrastructure.structlog.logger import unexpected_error_log from ttt.presentation.tasks.task import NextContainer, Task @@ -31,6 +32,7 @@ async def _matchmake(self, container: NextContainer) -> None: try: async with self._semaphore, container() as request: matchmake = await request.get(Matchmake) - await matchmake() + retrier = await request.get(Retrier) + await retrier(matchmake) except Exception as error: # noqa: BLE001 await unexpected_error_log(self._logger, error) From 8be8d86d1c26dab93c632ad43b58e9f073d93e0f Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:05:36 +0700 Subject: [PATCH 25/45] style: fix `ruff` errors (#62) --- src/ttt/application/game/game/start_game_with_ai.py | 1 - src/ttt/application/game/game/view_game.py | 1 - .../game/view_incoming_invitation_to_game.py | 4 +++- .../game/view_incoming_invitations_to_game.py | 4 +++- .../game/view_one_incoming_invitation_to_game.py | 4 +++- .../game/view_outcoming_invitations_to_game.py | 1 - .../view_user_account_to_change.py | 4 +++- src/ttt/application/user/game/view_matchmaking.py | 4 +++- src/ttt/application/user/view_admin_menu.py | 4 +++- src/ttt/application/user/view_main_menu.py | 4 +++- src/ttt/application/user/view_other_user.py | 1 - src/ttt/application/user/view_user.py | 4 +++- src/ttt/infrastructure/adapters/game_tasks.py | 4 ---- .../infrastructure/adapters/stars_purchase_tasks.py | 6 ------ src/ttt/main/common/di.py | 10 ++++++---- .../main_dialog/ai_type_to_start_game_window.py | 4 +++- .../main_dialog/incoming_invitations_to_game_window.py | 6 ++++-- .../outcoming_invitations_to_game_window.py | 8 ++++++-- 18 files changed, 43 insertions(+), 31 deletions(-) diff --git a/src/ttt/application/game/game/start_game_with_ai.py b/src/ttt/application/game/game/start_game_with_ai.py index cef7aaa..e501e77 100644 --- a/src/ttt/application/game/game/start_game_with_ai.py +++ b/src/ttt/application/game/game/start_game_with_ai.py @@ -1,4 +1,3 @@ -from asyncio import gather from dataclasses import dataclass from ttt.application.common.ports.emojis import Emojis diff --git a/src/ttt/application/game/game/view_game.py b/src/ttt/application/game/game/view_game.py index af05ad4..d436eac 100644 --- a/src/ttt/application/game/game/view_game.py +++ b/src/ttt/application/game/game/view_game.py @@ -2,7 +2,6 @@ from ttt.application.common.ports.transaction import ( ReadonlyTransaction, - SerializableTransaction, ) from ttt.application.game.game.ports.game_log import GameLog from ttt.application.game.game.ports.game_views import GameViews diff --git a/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py index 734ba20..b778869 100644 --- a/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py @@ -1,7 +1,9 @@ from dataclasses import dataclass from uuid import UUID -from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) diff --git a/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py b/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py index b313763..f0f0086 100644 --- a/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) diff --git a/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py b/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py index 41e909b..e21adcb 100644 --- a/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, ) diff --git a/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py b/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py index 3e525f9..a20cc66 100644 --- a/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py +++ b/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py @@ -2,7 +2,6 @@ from ttt.application.common.ports.transaction import ( ReadonlyTransaction, - SerializableTransaction, ) from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 InvitationToGameViews, diff --git a/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py b/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py index 06a8a73..eb97d85 100644 --- a/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py +++ b/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.user.change_other_user_account.ports.user_views import ( ChangeOtherUserAccountViews, ) diff --git a/src/ttt/application/user/game/view_matchmaking.py b/src/ttt/application/user/game/view_matchmaking.py index 43b31f8..32d24d1 100644 --- a/src/ttt/application/user/game/view_matchmaking.py +++ b/src/ttt/application/user/game/view_matchmaking.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.user.game.ports.user_views import GameUserViews diff --git a/src/ttt/application/user/view_admin_menu.py b/src/ttt/application/user/view_admin_menu.py index 18fbf5d..0d749fa 100644 --- a/src/ttt/application/user/view_admin_menu.py +++ b/src/ttt/application/user/view_admin_menu.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.user.common.ports.user_views import CommonUserViews diff --git a/src/ttt/application/user/view_main_menu.py b/src/ttt/application/user/view_main_menu.py index c752c57..1bef4a5 100644 --- a/src/ttt/application/user/view_main_menu.py +++ b/src/ttt/application/user/view_main_menu.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.user.common.ports.user_views import CommonUserViews diff --git a/src/ttt/application/user/view_other_user.py b/src/ttt/application/user/view_other_user.py index 91ba5bd..9490d9d 100644 --- a/src/ttt/application/user/view_other_user.py +++ b/src/ttt/application/user/view_other_user.py @@ -2,7 +2,6 @@ from ttt.application.common.ports.transaction import ( ReadonlyTransaction, - SerializableTransaction, ) from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews diff --git a/src/ttt/application/user/view_user.py b/src/ttt/application/user/view_user.py index d4160c4..0df060a 100644 --- a/src/ttt/application/user/view_user.py +++ b/src/ttt/application/user/view_user.py @@ -1,6 +1,8 @@ from dataclasses import dataclass -from ttt.application.common.ports.transaction import ReadonlyTransaction, SerializableTransaction +from ttt.application.common.ports.transaction import ( + ReadonlyTransaction, +) from ttt.application.user.common.ports.user_log import CommonUserLog from ttt.application.user.common.ports.user_views import CommonUserViews diff --git a/src/ttt/infrastructure/adapters/game_tasks.py b/src/ttt/infrastructure/adapters/game_tasks.py index 25912db..6a26168 100644 --- a/src/ttt/infrastructure/adapters/game_tasks.py +++ b/src/ttt/infrastructure/adapters/game_tasks.py @@ -1,11 +1,7 @@ from dataclasses import dataclass from uuid import UUID -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - from ttt.application.game.game.ports.game_tasks import GameTasks -from ttt.infrastructure.sqlalchemy.tables.game import TableGame from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import ( make_ai_move_in_game_broker_task, ) diff --git a/src/ttt/infrastructure/adapters/stars_purchase_tasks.py b/src/ttt/infrastructure/adapters/stars_purchase_tasks.py index 1e573a4..1732e52 100644 --- a/src/ttt/infrastructure/adapters/stars_purchase_tasks.py +++ b/src/ttt/infrastructure/adapters/stars_purchase_tasks.py @@ -1,16 +1,10 @@ from dataclasses import dataclass from uuid import UUID -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - from ttt.application.stars_purchase.ports.stars_purchase_tasks import ( StarsPurchaseTasks, ) from ttt.entities.finance.payment.success import PaymentSuccess -from ttt.infrastructure.sqlalchemy.tables.stars_purchase import ( - TableStarsPurchase, -) from ttt.infrastructure.taskiq.tasks.complete_stars_purchase_payment_task import ( # noqa: E501 complete_stars_purchase_payment_task, ) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 504f8e6..23815a9 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -1,6 +1,4 @@ -from asyncio import gather from collections.abc import AsyncIterator -from typing import NewType from dishka import Provider, Scope, provide from nats import connect as connect_to_nats @@ -109,8 +107,12 @@ from ttt.infrastructure.pydantic_settings.secrets import Secrets from ttt.infrastructure.retrier import Retrier from ttt.infrastructure.taskiq.broker import NatsBrokers -from ttt.infrastructure.taskiq.tasks.complete_stars_purchase_payment_task import complete_stars_purchase_payment_broker -from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import make_ai_move_in_game_broker +from ttt.infrastructure.taskiq.tasks.complete_stars_purchase_payment_task import ( # noqa: E501 + complete_stars_purchase_payment_broker, +) +from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import ( + make_ai_move_in_game_broker, +) class InfrastructureProvider(Provider): diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py index 67a09ee..eea3e62 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/ai_type_to_start_game_window.py @@ -24,7 +24,9 @@ async def on_game_against_gemini_2_0_flash_clicked( start_game_with_ai: FromDishka[StartGameWithAi], retrier: FromDishka[Retrier], ) -> None: - await retrier(start_game_with_ai, callback.from_user.id, AiType.gemini_2_0_flash) + await retrier( + start_game_with_ai, callback.from_user.id, AiType.gemini_2_0_flash, + ) ai_type_to_start_game_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py index 3e27c83..c01113b 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py @@ -67,7 +67,7 @@ async def getter( @inject -async def on_invitation_selected( +async def on_invitation_selected( # noqa: PLR0913, PLR0917 callback_query: CallbackQuery, _: Select[Any], manager: DialogManager, @@ -77,7 +77,9 @@ async def on_invitation_selected( result_buffer: FromDishka[ResultBuffer], ) -> None: invitation_id = UUID(hex=invitation_id_hex) - await retrier(view_invitation_to_game, callback_query.from_user.id, invitation_id) + await retrier( + view_invitation_to_game, callback_query.from_user.id, invitation_id, + ) view = result_buffer.result if not isinstance(view, IncomingInvitationToGameView | None): diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py index 5bf279a..f92ee97 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py @@ -79,7 +79,9 @@ async def on_invitation_selected( retrier: FromDishka[Retrier], ) -> None: invitation_id = UUID(hex=invitation_id_hex) - await retrier(cancel_invitation_to_game, callback_query.from_user.id, invitation_id) + await retrier( + cancel_invitation_to_game, callback_query.from_user.id, invitation_id, + ) @inject @@ -100,7 +102,9 @@ async def input_user_id( ShowMode.DELETE_AND_SEND, ) else: - await retrier(invite_to_game, not_none(message.from_user).id, invited_user_id) + await retrier( + invite_to_game, not_none(message.from_user).id, invited_user_id, + ) outcoming_invitations_to_game_window = Window( From 208c219cdd2ca5555a373a364bfdd0a652444c5b Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:14:11 +0700 Subject: [PATCH 26/45] fix: fix `mypy` errors (#62) --- src/ttt/infrastructure/retrier.py | 6 +++--- src/ttt/main/tg_bot/di.py | 11 ----------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/src/ttt/infrastructure/retrier.py b/src/ttt/infrastructure/retrier.py index 3e3178c..4301467 100644 --- a/src/ttt/infrastructure/retrier.py +++ b/src/ttt/infrastructure/retrier.py @@ -1,4 +1,4 @@ -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field @@ -19,13 +19,13 @@ def retry(self) -> bool: async def __call__[**PmT, RT]( self, - action: Callable[PmT, RT], + action: Callable[PmT, Awaitable[RT]], *args: PmT.args, **kwargs: PmT.kwargs, ) -> RT: while True: try: - return action(*args, **kwargs) + return await action(*args, **kwargs) except BaseException as error: if type(error) not in self._max_retries_map: raise error from error diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index c84473a..6a14aca 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -153,9 +153,6 @@ from ttt.presentation.tasks.auto_cancel_invitations_to_game_task import ( AutoCancelInvitationsToGameTask, ) -from ttt.presentation.tasks.complete_stars_purchase_payment_task import ( - CompleteStarsPurchasePaymentTask, -) from ttt.presentation.tasks.matchmake_tasks import MatchmakeTasks from ttt.presentation.tasks.unkillable_tasks import UnkillableTasks from ttt.presentation.unkillable_task_group import UnkillableTaskGroup @@ -269,12 +266,6 @@ def provide_auto_cancel_invitations_to_game_task( ), ) - @provide(scope=Scope.APP) - def provide_complete_stars_purchase_payment_task( - self, - ) -> CompleteStarsPurchasePaymentTask: - return CompleteStarsPurchasePaymentTask() - @provide(scope=Scope.APP) def provide_matchmake_tasks( self, @@ -301,12 +292,10 @@ async def unkillable_tasks( self, task_group: UnkillableTaskGroup, auto_cancel_invitations_to_game_task: AutoCancelInvitationsToGameTask, - complete_stars_purchase_payment_task: CompleteStarsPurchasePaymentTask, matchmake_tasks: MatchmakeTasks, ) -> UnkillableTasks: tasks = ( auto_cancel_invitations_to_game_task, - complete_stars_purchase_payment_task, matchmake_tasks, ) return UnkillableTasks(tasks, task_group) From 4e66648ba2bb8965a2e6979c347e356806a13118 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:42:13 +0700 Subject: [PATCH 27/45] ref: use `Envs` for `Retrier` providing (#62) --- deploy/dev/docker-compose.yaml | 2 ++ deploy/prod/docker-compose.yaml | 2 ++ src/ttt/infrastructure/pydantic_settings/envs.py | 2 ++ src/ttt/main/common/di.py | 6 ++++-- 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/deploy/dev/docker-compose.yaml b/deploy/dev/docker-compose.yaml index baa7861..19b1590 100644 --- a/deploy/dev/docker-compose.yaml +++ b/deploy/dev/docker-compose.yaml @@ -41,6 +41,8 @@ services: TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5 TTT_AUTO_CANCEL_INVITATIONS_TO_GAME_INTERVAL_SECONDS: 1 + + TTT_SERIALIZATION_ERROR_MAX_RETRIES: 10 secrets: - secrets command: ttt-dev diff --git a/deploy/prod/docker-compose.yaml b/deploy/prod/docker-compose.yaml index 7398e9c..82914cc 100644 --- a/deploy/prod/docker-compose.yaml +++ b/deploy/prod/docker-compose.yaml @@ -41,6 +41,8 @@ services: TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5 TTT_AUTO_CANCEL_INVITATIONS_TO_GAME_INTERVAL_SECONDS: 1 + + TTT_SERIALIZATION_ERROR_MAX_RETRIES: 10 secrets: - secrets networks: diff --git a/src/ttt/infrastructure/pydantic_settings/envs.py b/src/ttt/infrastructure/pydantic_settings/envs.py index 662cd7a..889b2c4 100644 --- a/src/ttt/infrastructure/pydantic_settings/envs.py +++ b/src/ttt/infrastructure/pydantic_settings/envs.py @@ -29,6 +29,8 @@ class Envs(BaseSettings): auto_cancel_invitations_to_game_interval_seconds: float + serialization_error_max_retries: int + @classmethod def settings_customise_sources( cls, diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 23815a9..658a0fa 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -350,7 +350,9 @@ def provide_randoms(self) -> Randoms: ) @provide(scope=Scope.REQUEST) - def provide_retrier(self) -> Retrier: - return Retrier(_max_retries_map={SerializationError: 10}) + def provide_retrier(self, envs: Envs) -> Retrier: + return Retrier(_max_retries_map={ + SerializationError: envs.serialization_error_max_retries, + }) provide_retry = provide(RetrierRetry, provides=Retry, scope=Scope.REQUEST) From 0d24ac4af06c7929cae02937410c3ed2ec5da02a Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Wed, 24 Sep 2025 21:29:16 +0700 Subject: [PATCH 28/45] fix: make it launchable (#62) --- deploy/dev/nats/add_streams.sh | 4 +- pyproject.toml | 1 + .../game/game/make_ai_move_in_game.py | 5 +- .../application/game/game/ports/game_views.py | 7 - .../infrastructure/adapters/transaction.py | 35 +++- src/ttt/infrastructure/adapters/user_locks.py | 3 +- ...9e277_remove_is_true_from_ix_users_has_.py | 44 +++++ .../infrastructure/sqlalchemy/tables/user.py | 2 +- src/ttt/infrastructure/taskiq/broker.py | 176 ++++++++++++++---- .../infrastructure/taskiq/tasks/__init__.py | 7 + src/ttt/infrastructure/taskiq/tasks/common.py | 4 + .../complete_stars_purchase_payment_task.py | 18 +- .../taskiq/tasks/make_ai_move_in_game_task.py | 18 +- src/ttt/infrastructure/taskiq/worker.py | 41 ++++ src/ttt/main/common/di.py | 34 ++-- src/ttt/main/tg_bot/di.py | 2 + src/ttt/main/tg_bot/start_tg_bot.py | 22 +-- src/ttt/presentation/adapters/game_views.py | 2 +- 18 files changed, 318 insertions(+), 107 deletions(-) create mode 100644 src/ttt/infrastructure/alembic/versions/2dcb2be9e277_remove_is_true_from_ix_users_has_.py create mode 100644 src/ttt/infrastructure/taskiq/tasks/common.py create mode 100644 src/ttt/infrastructure/taskiq/worker.py diff --git a/deploy/dev/nats/add_streams.sh b/deploy/dev/nats/add_streams.sh index 0ea074a..ed81301 100644 --- a/deploy/dev/nats/add_streams.sh +++ b/deploy/dev/nats/add_streams.sh @@ -1,3 +1,5 @@ #!/bin/bash -nats -s nats://nats:4222 stream add --config /mnt/streams/user.json +for stream_config_file in `ls -lx /mnt/streams`; do + nats -s nats://nats:4222 stream add --config /mnt/streams/$stream_config_file +done diff --git a/pyproject.toml b/pyproject.toml index 4e36112..d6bbee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "src/ttt/infrastructure/sqlalchemy/tables/__init__.py" = ["F401"] +"src/ttt/infrastructure/taskiq/tasks/__init__.py" = ["F401"] "src/ttt/infrastructure/adapters/*" = ["ARG002"] "src/ttt/presentation/adapters/*" = ["ARG002"] "src/ttt/presentation/*" = ["RUF001"] diff --git a/src/ttt/application/game/game/make_ai_move_in_game.py b/src/ttt/application/game/game/make_ai_move_in_game.py index b4a0ac0..2c17fc0 100644 --- a/src/ttt/application/game/game/make_ai_move_in_game.py +++ b/src/ttt/application/game/game/make_ai_move_in_game.py @@ -26,8 +26,7 @@ class MakeAiMoveInGame: map_: Map games: Games - game_views: GameViews - users: Users + views: GameViews uuids: UUIDs randoms: Randoms ai_gateway: GameAiGateway @@ -77,4 +76,4 @@ async def __call__(self, user_id: int, game_id: UUID, ai_id: UUID) -> None: await self.map_(tracking) await self.transaction.commit() - await self.game_views.game_view(game) + await self.views.game_view(game) diff --git a/src/ttt/application/game/game/ports/game_views.py b/src/ttt/application/game/game/ports/game_views.py index 3f69bae..a67678d 100644 --- a/src/ttt/application/game/game/ports/game_views.py +++ b/src/ttt/application/game/game/ports/game_views.py @@ -25,13 +25,6 @@ async def no_current_game_view( /, ) -> None: ... - @abstractmethod - async def no_game_with_id_view( - self, - game_id: UUID, - /, - ) -> None: ... - @abstractmethod async def game_already_complteted_view( self, diff --git a/src/ttt/infrastructure/adapters/transaction.py b/src/ttt/infrastructure/adapters/transaction.py index fdb680e..5052285 100644 --- a/src/ttt/infrastructure/adapters/transaction.py +++ b/src/ttt/infrastructure/adapters/transaction.py @@ -32,9 +32,16 @@ async def __aexit__( error: BaseException | None, traceback: TracebackType | None, ) -> None: - transaction = not_none(self._session.get_transaction()) + transaction = self._session.get_transaction() + + if transaction is None: + return + with reraise_serialization_error(): - await transaction.__aexit__(error_type, error, traceback) + if error is None: + await transaction.commit() + else: + await transaction.rollback() async def commit(self) -> None: transaction = not_none(self._session.get_transaction()) @@ -49,7 +56,7 @@ class InPostgresNotSerializableTransaction(NotSerializableTransaction): async def __aenter__(self) -> Self: assert_(not self._session.in_transaction()) await self._session.connection( - execution_options={"isolation_level": "READ COMMITED"}, + execution_options={"isolation_level": "READ COMMITTED"}, ) return self @@ -59,8 +66,15 @@ async def __aexit__( error: BaseException | None, traceback: TracebackType | None, ) -> None: - transaction = not_none(self._session.get_transaction()) - await transaction.__aexit__(error_type, error, traceback) + transaction = self._session.get_transaction() + + if transaction is None: + return + + if error is None: + await transaction.commit() + else: + await transaction.rollback() async def commit(self) -> None: transaction = not_none(self._session.get_transaction()) @@ -83,5 +97,12 @@ async def __aexit__( error: BaseException | None, traceback: TracebackType | None, ) -> None: - transaction = not_none(self._session.get_transaction()) - await transaction.__aexit__(error_type, error, traceback) + transaction = self._session.get_transaction() + + if transaction is None: + return + + if error is None: + await transaction.commit() + else: + await transaction.rollback() diff --git a/src/ttt/infrastructure/adapters/user_locks.py b/src/ttt/infrastructure/adapters/user_locks.py index b0bfea3..c3d96fe 100644 --- a/src/ttt/infrastructure/adapters/user_locks.py +++ b/src/ttt/infrastructure/adapters/user_locks.py @@ -1,4 +1,5 @@ -from pydantic.dataclasses import dataclass +from dataclasses import dataclass + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession diff --git a/src/ttt/infrastructure/alembic/versions/2dcb2be9e277_remove_is_true_from_ix_users_has_.py b/src/ttt/infrastructure/alembic/versions/2dcb2be9e277_remove_is_true_from_ix_users_has_.py new file mode 100644 index 0000000..8acfced --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/2dcb2be9e277_remove_is_true_from_ix_users_has_.py @@ -0,0 +1,44 @@ +""" +remove `IS TRUE` from `ix_users_has_matchmaking_waiting`. + +Revision ID: 2dcb2be9e277 +Revises: 6642791b3bf8 +Create Date: 2025-09-24 13:46:33.322702 + +""" + +from collections.abc import Sequence + +from alembic import op + + +revision: str = "2dcb2be9e277" +down_revision: str | None = "6642791b3bf8" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.drop_index( + op.f("ix_users_has_matchmaking_waiting"), + table_name="users", + postgresql_where="(has_matchmaking_waiting IS TRUE)", + ) + op.create_index( + op.f("ix_users_has_matchmaking_waiting"), + "users", + ["has_matchmaking_waiting"], + unique=False, + postgresql_where="has_matchmaking_waiting", + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_users_has_matchmaking_waiting"), table_name="users") + op.create_index( + op.f("ix_users_has_matchmaking_waiting"), + "users", + ["has_matchmaking_waiting"], + unique=False, + postgresql_where="(has_matchmaking_waiting IS TRUE)", + ) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/user.py b/src/ttt/infrastructure/sqlalchemy/tables/user.py index 4bc831c..0e84139 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/user.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/user.py @@ -122,7 +122,7 @@ class TableUser(Base[User]): Index( "ix_users_has_matchmaking_waiting", has_matchmaking_waiting, - postgresql_where=(has_matchmaking_waiting.is_(True)), + postgresql_where="has_matchmaking_waiting", ), ) diff --git a/src/ttt/infrastructure/taskiq/broker.py b/src/ttt/infrastructure/taskiq/broker.py index eb82caa..9013db2 100644 --- a/src/ttt/infrastructure/taskiq/broker.py +++ b/src/ttt/infrastructure/taskiq/broker.py @@ -1,5 +1,7 @@ -from collections.abc import AsyncGenerator -from typing import NewType, Protocol +from asyncio import Queue, TaskGroup, gather +from collections.abc import AsyncGenerator, Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any, overload from nats.aio.msg import Msg as NatsMessage from nats.errors import TimeoutError as NatsTimeoutError @@ -7,58 +9,166 @@ from taskiq import ( AckableMessage, AsyncBroker, + AsyncTaskiqDecoratedTask, BrokerMessage, ) -class PullSubscribe(Protocol): +@dataclass(frozen=True) +class PullSubscribe: + _func: Callable[ + [JetStreamContext, str], Awaitable[JetStreamContext.PullSubscription], + ] + async def __call__( - self, js: JetStreamContext, subject: str, /, - ) -> JetStreamContext.PullSubscription: ... + self, js: JetStreamContext, subject: str, + ) -> JetStreamContext.PullSubscription: + return await self._func(js, subject) -class NatsBroker(AsyncBroker): - _consumer: JetStreamContext.PullSubscription - js: JetStreamContext +@dataclass +class NatsQueue: + _subject: str + _pull_subscribe: PullSubscribe + _pull_consume_batch: int + _pull_consume_timeout: float | None - def __init__( - self, - subject: str, - pull_subscribe: PullSubscribe, - pull_consume_batch: int = 1, - pull_consume_timeout: float | None = 5, - ) -> None: - super().__init__() - self._subject = subject - self._pull_subscribe = pull_subscribe - self._pull_consume_batch = pull_consume_batch - self._pull_consume_timeout = pull_consume_timeout + _js: JetStreamContext = field(init=False) + _pull_subscription: JetStreamContext.PullSubscription = field(init=False) - async def startup(self) -> None: - await super().startup() - self._consumer = await self._pull_subscribe(self.js, self._subject) + async def startup(self, js: JetStreamContext) -> None: + self._js = js + self._pull_subscription = await self._pull_subscribe(js, self._subject) - async def kick(self, message: BrokerMessage) -> None: - await self.js.publish( + async def push(self, message: BrokerMessage) -> None: + await self._js.publish( self._subject, payload=message.message, headers=message.labels, ) - async def listen(self) -> AsyncGenerator[AckableMessage]: + async def pull_to(self, output: Queue[AckableMessage]) -> None: + nats_messages: list[NatsMessage] + while True: try: - nats_messages: list[NatsMessage] = await self._consumer.fetch( + nats_messages = await self._pull_subscription.fetch( batch=self._pull_consume_batch, timeout=self._pull_consume_timeout, ) - for nats_message in nats_messages: - yield AckableMessage( - data=nats_message.data, - ack=nats_message.ack, - ) except NatsTimeoutError: continue + ackable_messages = ( + AckableMessage( + data=nats_message.data, + ack=nats_message.ack, + ) + for nats_message in nats_messages + ) + await gather(*( + output.put(ackable_message) + for ackable_message in ackable_messages + )) + + +class NatsBroker(AsyncBroker): + js: JetStreamContext + pulling_queue: Queue[AckableMessage] + + def __init__( + self, + default_subject: str = "taskiq.>", + default_pull_subscribe: PullSubscribe | None = None, + default_pull_consume_batch: int = 1, + default_pull_consume_timeout: float | None = 5, + ) -> None: + super().__init__() + self._default_subject = default_subject + self._default_pull_subscribe = ( + default_pull_subscribe + or (lambda js, sub: js.pull_subscribe( + sub, + "taskiq", + "TASKIQ", + )) + ) + self._default_pull_consume_batch = default_pull_consume_batch + self._default_pull_consume_timeout = default_pull_consume_timeout + self._queue_by_task_name = dict[str, NatsQueue]() + + @overload + def task[**PmT, RT]( + self, + task_name: Callable[PmT, RT], + **labels: Any, # noqa: ANN401 + ) -> AsyncTaskiqDecoratedTask[PmT, RT]: + ... + + @overload + def task[**PmT, RT]( + self, + task_name: str | None = None, + **labels: Any, # noqa: ANN401 + ) -> Callable[ + [Callable[PmT, RT]], + AsyncTaskiqDecoratedTask[PmT, RT], + ]: + ... + + def task[**PmT, RT]( + self, + task_name: str | Callable[PmT, RT] | None = None, + **labels: Any, + ) -> Any: + subject = labels.pop("subject", self._default_subject) + pull_subscribe = labels.pop( + "pull_subscribe", self._default_pull_subscribe, + ) + pull_consume_batch = labels.pop( + "pull_consume_batch", self._default_pull_consume_batch, + ) + pull_consume_timeout = labels.pop( + "pull_consume_timeout", self._default_pull_consume_timeout, + ) + queue = NatsQueue( + subject, + pull_subscribe, + pull_consume_batch, + pull_consume_timeout, + ) + + result = super().task(task_name, **labels) + + if isinstance(result, AsyncTaskiqDecoratedTask): + self._queue_by_task_name[result.task_name] = queue + return result + + def decorator( + func: Callable[PmT, RT], + ) -> AsyncTaskiqDecoratedTask[PmT, RT]: + task = result(func) + self._queue_by_task_name[task.task_name] = queue + return task + + return decorator + + async def startup(self) -> None: + await super().startup() + await gather(*( + queue.startup(self.js) + for queue in self._queue_by_task_name.values() + )) + + async def kick(self, message: BrokerMessage) -> None: + await self._queue_by_task_name[message.task_name].push(message) + + async def listen(self) -> AsyncGenerator[AckableMessage]: + async with TaskGroup() as pulling_tasks: + for nats_queue in self._queue_by_task_name.values(): + pulling_tasks.create_task( + nats_queue.pull_to(self.pulling_queue), + ) -NatsBrokers = NewType("NatsBrokers", tuple[NatsBroker, ...]) + while True: + yield await self.pulling_queue.get() diff --git a/src/ttt/infrastructure/taskiq/tasks/__init__.py b/src/ttt/infrastructure/taskiq/tasks/__init__.py index e69de29..0dd3028 100644 --- a/src/ttt/infrastructure/taskiq/tasks/__init__.py +++ b/src/ttt/infrastructure/taskiq/tasks/__init__.py @@ -0,0 +1,7 @@ +from ttt.infrastructure.taskiq.tasks.common import nats_tasks +from ttt.infrastructure.taskiq.tasks.complete_stars_purchase_payment_task import ( # noqa: E501 + complete_stars_purchase_payment_task, +) +from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import ( + make_ai_move_in_game_broker_task, +) diff --git a/src/ttt/infrastructure/taskiq/tasks/common.py b/src/ttt/infrastructure/taskiq/tasks/common.py new file mode 100644 index 0000000..581166d --- /dev/null +++ b/src/ttt/infrastructure/taskiq/tasks/common.py @@ -0,0 +1,4 @@ +from ttt.infrastructure.taskiq.broker import NatsBroker + + +nats_tasks = NatsBroker() diff --git a/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py b/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py index 928c7fc..4147d57 100644 --- a/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py +++ b/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py @@ -7,20 +7,18 @@ ) from ttt.entities.finance.payment.success import PaymentSuccess from ttt.infrastructure.retrier import Retrier -from ttt.infrastructure.taskiq.broker import NatsBroker +from ttt.infrastructure.taskiq.broker import PullSubscribe +from ttt.infrastructure.taskiq.tasks.common import nats_tasks -complete_stars_purchase_payment_broker = NatsBroker( - "stars_purchase.stars_purchase.complete_stars_purchase_payment", - lambda js, sub: js.pull_subscribe( - sub, +@nats_tasks.task( + subject="stars_purchase.stars_purchase.complete_stars_purchase_payment", + pull_subscribe=PullSubscribe(lambda js, subject: js.pull_subscribe( + subject, "ttt-stars_purchase-stars_purchase-complete_stars_purchase_payment", - "STARS_PURCHASE", - ), + stream="STARS_PURCHASE", + )), ) - - -@complete_stars_purchase_payment_broker.task() @inject(patch_module=True) async def complete_stars_purchase_payment_task( purchase_id: UUID, diff --git a/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py b/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py index d7e54e1..9316dc9 100644 --- a/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py +++ b/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py @@ -4,18 +4,18 @@ from ttt.application.game.game.make_ai_move_in_game import MakeAiMoveInGame from ttt.infrastructure.retrier import Retrier -from ttt.infrastructure.taskiq.broker import NatsBroker +from ttt.infrastructure.taskiq.broker import PullSubscribe +from ttt.infrastructure.taskiq.tasks.common import nats_tasks -make_ai_move_in_game_broker = NatsBroker( - "game.game.make_ai_move_in_game", - lambda js, sub: js.pull_subscribe( - sub, "ttt-game-game-make_ai_move_in_game", "GAME", - ), +@nats_tasks.task( + subject="game.game.make_ai_move_in_game", + pull_subscribe=PullSubscribe(lambda js, subject: js.pull_subscribe( + subject, + durable="ttt-game-game-make_ai_move_in_game", + stream="GAME", + )), ) - - -@make_ai_move_in_game_broker.task() @inject(patch_module=True) async def make_ai_move_in_game_broker_task( user_id: int, diff --git a/src/ttt/infrastructure/taskiq/worker.py b/src/ttt/infrastructure/taskiq/worker.py new file mode 100644 index 0000000..cc4ee22 --- /dev/null +++ b/src/ttt/infrastructure/taskiq/worker.py @@ -0,0 +1,41 @@ +from asyncio import Event, TaskGroup, gather +from dataclasses import dataclass, field +from types import TracebackType +from typing import Self + +from dishka import AsyncContainer +from dishka.integrations.taskiq import setup_dishka +from taskiq.receiver import Receiver + + +@dataclass +class TaskiqBgWorker: + _receivers: tuple[Receiver, ...] + + _task_group: TaskGroup = field(init=False, default_factory=TaskGroup) + _is_finished: Event = field(init=False, default_factory=Event) + + async def __aenter__(self) -> Self: + await self._task_group.__aenter__() + return self + + async def __call__(self, container: AsyncContainer) -> None: + for receiver in self._receivers: + setup_dishka(container, receiver.broker) + + await gather(*( + receiver.broker.startup() + for receiver in self._receivers + )) + + for receiver in self._receivers: + self._task_group.create_task(receiver.listen(self._is_finished)) + + async def __aexit__( + self, + error_type: type[BaseException] | None, + error: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self._is_finished.set() + await self._task_group.__aexit__(error_type, error, traceback) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 658a0fa..3270f7c 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -1,3 +1,4 @@ +from asyncio import Queue from collections.abc import AsyncIterator from dishka import Provider, Scope, provide @@ -10,6 +11,7 @@ AsyncSession, create_async_engine, ) +from taskiq.receiver import Receiver from ttt.application.common.errors.serialization_error import SerializationError from ttt.application.common.ports.clock import Clock @@ -106,13 +108,9 @@ from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets from ttt.infrastructure.retrier import Retrier -from ttt.infrastructure.taskiq.broker import NatsBrokers -from ttt.infrastructure.taskiq.tasks.complete_stars_purchase_payment_task import ( # noqa: E501 - complete_stars_purchase_payment_broker, -) -from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import ( - make_ai_move_in_game_broker, -) +from ttt.infrastructure.taskiq.broker import NatsBroker +from ttt.infrastructure.taskiq.tasks.common import nats_tasks +from ttt.infrastructure.taskiq.worker import TaskiqBgWorker class InfrastructureProvider(Provider): @@ -145,7 +143,6 @@ async def provide_postgres_session( session = AsyncSession( engine, autoflush=False, - autobegin=False, expire_on_commit=False, ) @@ -186,15 +183,20 @@ async def provide_jetstream(self, nats: Nats) -> JetStreamContext: return nats.jetstream() @provide(scope=Scope.APP) - async def provide_taskiq_brokers(self, js: JetStreamContext) -> NatsBrokers: - nats_brokers = ( - complete_stars_purchase_payment_broker, - make_ai_move_in_game_broker, - ) - for broker in nats_brokers: - broker.js = js + async def provide_nats_broker( + self, js: JetStreamContext, + ) -> NatsBroker: + nats_tasks.js = js + nats_tasks.pulling_queue = Queue() - return NatsBrokers(nats_brokers) + return nats_tasks + + @provide(scope=Scope.APP) + async def provide_taskiq_bg_worker( + self, nats_broker: NatsBroker, + ) -> AsyncIterator[TaskiqBgWorker]: + async with TaskiqBgWorker((Receiver(nats_broker), )) as worker: + yield worker @provide(scope=Scope.APP) def provide_gemini(self, secrets: Secrets, envs: Envs) -> Gemini: diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index 6a14aca..9f3e4e9 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -27,6 +27,7 @@ from ttt.application.common.ports.emojis import Emojis from ttt.application.game.game.cancel_game import CancelGame +from ttt.application.game.game.make_ai_move_in_game import MakeAiMoveInGame from ttt.application.game.game.make_move_in_game import MakeMoveInGame from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.start_game_with_ai import StartGameWithAi @@ -436,6 +437,7 @@ class ApplicationProvider(Provider): ) provide_cancel_game = provide(CancelGame, scope=Scope.REQUEST) provide_make_move_in_game = provide(MakeMoveInGame, scope=Scope.REQUEST) + provide_make_ai_move_in_game = provide(MakeAiMoveInGame, scope=Scope.REQUEST) provide_view_game = provide(ViewGame, scope=Scope.REQUEST) provide_accept_invitation_to_game = provide( diff --git a/src/ttt/main/tg_bot/start_tg_bot.py b/src/ttt/main/tg_bot/start_tg_bot.py index b1e3de9..fa465ae 100644 --- a/src/ttt/main/tg_bot/start_tg_bot.py +++ b/src/ttt/main/tg_bot/start_tg_bot.py @@ -1,5 +1,4 @@ import logging -from asyncio import gather from functools import partial from aiogram import Bot, Dispatcher @@ -9,10 +8,8 @@ AiogramMiddlewareData, ContainerMiddleware, ) -from dishka.integrations.taskiq import setup_dishka -from taskiq.api.receiver import run_receiver_task -from ttt.infrastructure.taskiq.broker import NatsBrokers +from ttt.infrastructure.taskiq.worker import TaskiqBgWorker from ttt.presentation.tasks.unkillable_tasks import UnkillableTasks @@ -31,22 +28,11 @@ async def start_tg_bot(container: AsyncContainer) -> None: ) await tasks(next_container) - nats_brokers = await container.get(NatsBrokers) - - for broker in nats_brokers: - setup_dishka(container, broker) - - await gather(*( - broker.startup() - for broker in nats_brokers - )) - + taskiq_bg_worker = await container.get(TaskiqBgWorker) bot = await container.get(Bot) try: - await gather( - dp.start_polling(bot), - gather(*(run_receiver_task(broker) for broker in nats_brokers)), - ) + await taskiq_bg_worker(container) + await dp.start_polling(bot) finally: await container.close() diff --git a/src/ttt/presentation/adapters/game_views.py b/src/ttt/presentation/adapters/game_views.py index eb379bd..f69429d 100644 --- a/src/ttt/presentation/adapters/game_views.py +++ b/src/ttt/presentation/adapters/game_views.py @@ -64,7 +64,7 @@ async def started_game_view(self, game: Game, /) -> None: for user in game.users() )) - async def no_game_view(self, user_id: int, /) -> None: + async def no_current_game_view(self, user_id: int, /) -> None: dialog_manager = self._dialog_manager_for_user(user_id) data = {"hint": "❌ Игра уже закончилась"} From 10b0814f62621354875680d3873ab9b4189d79a0 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:57:35 +0700 Subject: [PATCH 29/45] fix(`infrastructure`): fix `postgres` io details (#62) --- src/ttt/infrastructure/adapters/games.py | 10 +++++++++- src/ttt/infrastructure/adapters/transaction.py | 6 +++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/ttt/infrastructure/adapters/games.py b/src/ttt/infrastructure/adapters/games.py index c5fdd69..cad7949 100644 --- a/src/ttt/infrastructure/adapters/games.py +++ b/src/ttt/infrastructure/adapters/games.py @@ -28,10 +28,18 @@ async def current_user_game(self, user_id: int, /) -> Game | None: return table_game.entity() async def not_locked_game_with_id(self, game_id: UUID, /) -> Game | None: + lock_stmt = ( + select(1) + .select_from(TableGame) + .where(TableGame.id == game_id) + .with_for_update() + ) stmt = ( select(TableGame) .where(TableGame.id == game_id) - .with_for_update() ) + + await self._session.execute(lock_stmt) table_game = await self._session.scalar(stmt) + return None if table_game is None else table_game.entity() diff --git a/src/ttt/infrastructure/adapters/transaction.py b/src/ttt/infrastructure/adapters/transaction.py index 5052285..3aa220f 100644 --- a/src/ttt/infrastructure/adapters/transaction.py +++ b/src/ttt/infrastructure/adapters/transaction.py @@ -38,7 +38,7 @@ async def __aexit__( return with reraise_serialization_error(): - if error is None: + if error is None and transaction.is_active: await transaction.commit() else: await transaction.rollback() @@ -71,7 +71,7 @@ async def __aexit__( if transaction is None: return - if error is None: + if error is None and transaction.is_active: await transaction.commit() else: await transaction.rollback() @@ -102,7 +102,7 @@ async def __aexit__( if transaction is None: return - if error is None: + if error is None and transaction.is_active: await transaction.commit() else: await transaction.rollback() From 470761bfb94a14e668bce56b9dbe671a85645e83 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Thu, 25 Sep 2025 17:58:24 +0700 Subject: [PATCH 30/45] chore(`deploy`): use `NATS_URL` env for `nats_streams` --- deploy/dev/docker-compose.yaml | 2 ++ deploy/dev/nats/add_streams.sh | 2 +- deploy/prod/docker-compose.yaml | 1 + deploy/prod/nats/add_streams.sh | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/deploy/dev/docker-compose.yaml b/deploy/dev/docker-compose.yaml index 19b1590..9d4ba40 100644 --- a/deploy/dev/docker-compose.yaml +++ b/deploy/dev/docker-compose.yaml @@ -101,6 +101,8 @@ services: - ./nats:/mnt entrypoint: [""] command: ["bash", "/mnt/add_streams.sh"] + environment: + NATS_URL: nats://nats:4222 volumes: backend-data: diff --git a/deploy/dev/nats/add_streams.sh b/deploy/dev/nats/add_streams.sh index ed81301..12c14a7 100644 --- a/deploy/dev/nats/add_streams.sh +++ b/deploy/dev/nats/add_streams.sh @@ -1,5 +1,5 @@ #!/bin/bash for stream_config_file in `ls -lx /mnt/streams`; do - nats -s nats://nats:4222 stream add --config /mnt/streams/$stream_config_file + nats stream add --config /mnt/streams/$stream_config_file done diff --git a/deploy/prod/docker-compose.yaml b/deploy/prod/docker-compose.yaml index 82914cc..9212405 100644 --- a/deploy/prod/docker-compose.yaml +++ b/deploy/prod/docker-compose.yaml @@ -156,6 +156,7 @@ services: networks: - nats environment: + NATS_URL: nats://nats:4222 NATS_TOKEN: ${NATS_TOKEN} entrypoint: [""] command: ["bash", "/mnt/add_streams.sh"] diff --git a/deploy/prod/nats/add_streams.sh b/deploy/prod/nats/add_streams.sh index 0ea074a..2058059 100644 --- a/deploy/prod/nats/add_streams.sh +++ b/deploy/prod/nats/add_streams.sh @@ -1,3 +1,3 @@ #!/bin/bash -nats -s nats://nats:4222 stream add --config /mnt/streams/user.json +nats stream add --config /mnt/streams/user.json From 6249a209da330c509baccb3e00b2aeb8cd10dcf5 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Thu, 25 Sep 2025 18:02:34 +0700 Subject: [PATCH 31/45] fix: use custom integrations for context-independent use (#62) --- src/ttt/infrastructure/adapters/game_tasks.py | 4 +- src/ttt/infrastructure/dishka/__init__.py | 0 .../infrastructure/dishka/next_container.py | 9 ++++ src/ttt/infrastructure/taskiq/middlewares.py | 41 +++++++++++++++++++ .../infrastructure/taskiq/tasks/__init__.py | 2 +- .../complete_stars_purchase_payment_task.py | 1 + .../taskiq/tasks/make_ai_move_in_game_task.py | 3 +- src/ttt/infrastructure/taskiq/worker.py | 7 +--- src/ttt/main/common/next_container.py | 25 +++++++++++ src/ttt/main/tg_bot/di.py | 26 ++++-------- src/ttt/main/tg_bot/start_tg_bot.py | 32 +++++++++------ src/ttt/main/tg_bot_dev/__main__.py | 2 - src/ttt/main/tg_bot_prod/__main__.py | 2 - .../aiogram/common/middlewares.py | 27 ++++++++++++ .../auto_cancel_invitations_to_game_task.py | 3 +- src/ttt/presentation/tasks/matchmake_tasks.py | 3 +- src/ttt/presentation/tasks/task.py | 3 +- .../presentation/tasks/unkillable_tasks.py | 3 +- 18 files changed, 145 insertions(+), 48 deletions(-) create mode 100644 src/ttt/infrastructure/dishka/__init__.py create mode 100644 src/ttt/infrastructure/dishka/next_container.py create mode 100644 src/ttt/infrastructure/taskiq/middlewares.py create mode 100644 src/ttt/main/common/next_container.py create mode 100644 src/ttt/presentation/aiogram/common/middlewares.py diff --git a/src/ttt/infrastructure/adapters/game_tasks.py b/src/ttt/infrastructure/adapters/game_tasks.py index 6a26168..ec2a8c3 100644 --- a/src/ttt/infrastructure/adapters/game_tasks.py +++ b/src/ttt/infrastructure/adapters/game_tasks.py @@ -3,7 +3,7 @@ from ttt.application.game.game.ports.game_tasks import GameTasks from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import ( - make_ai_move_in_game_broker_task, + make_ai_move_in_game_task, ) @@ -16,4 +16,4 @@ async def make_ai_move( ai_id: UUID, /, ) -> None: - await make_ai_move_in_game_broker_task.kiq(user_id, game_id, ai_id) + await make_ai_move_in_game_task.kiq(user_id, game_id, ai_id) diff --git a/src/ttt/infrastructure/dishka/__init__.py b/src/ttt/infrastructure/dishka/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ttt/infrastructure/dishka/next_container.py b/src/ttt/infrastructure/dishka/next_container.py new file mode 100644 index 0000000..068c325 --- /dev/null +++ b/src/ttt/infrastructure/dishka/next_container.py @@ -0,0 +1,9 @@ +from collections.abc import Mapping +from typing import Any, Protocol + +from dishka.async_container import AsyncContextWrapper + + +class NextContainer(Protocol): + def __call__(self, context: Mapping[Any, Any] = {}) -> AsyncContextWrapper: + ... diff --git a/src/ttt/infrastructure/taskiq/middlewares.py b/src/ttt/infrastructure/taskiq/middlewares.py new file mode 100644 index 0000000..4005d05 --- /dev/null +++ b/src/ttt/infrastructure/taskiq/middlewares.py @@ -0,0 +1,41 @@ +from typing import Any + +from dishka.integrations.taskiq import CONTAINER_NAME +from taskiq import TaskiqMessage, TaskiqMiddleware, TaskiqResult + +from ttt.infrastructure.dishka.next_container import NextContainer + + +class TaskiqNextContainerMiddleware(TaskiqMiddleware): + def __init__(self, next_container: NextContainer) -> None: + super().__init__() + self._next_container = next_container + + async def pre_execute( + self, + message: TaskiqMessage, + ) -> TaskiqMessage: + next_container = self._next_container({TaskiqMessage: message}) + + container = await next_container.__aenter__() # noqa: PLC2801 + message.labels[CONTAINER_NAME] = container + return message + + async def on_error( + self, + message: TaskiqMessage, # noqa: ARG002 + result: TaskiqResult[Any], + exception: BaseException, # noqa: ARG002 + ) -> None: + if CONTAINER_NAME in result.labels: + await result.labels[CONTAINER_NAME].close() + del result.labels[CONTAINER_NAME] + + async def post_execute( + self, + message: TaskiqMessage, # noqa: ARG002 + result: TaskiqResult[Any], + ) -> None: + if CONTAINER_NAME in result.labels: + await result.labels[CONTAINER_NAME].close() + del result.labels[CONTAINER_NAME] diff --git a/src/ttt/infrastructure/taskiq/tasks/__init__.py b/src/ttt/infrastructure/taskiq/tasks/__init__.py index 0dd3028..155670e 100644 --- a/src/ttt/infrastructure/taskiq/tasks/__init__.py +++ b/src/ttt/infrastructure/taskiq/tasks/__init__.py @@ -3,5 +3,5 @@ complete_stars_purchase_payment_task, ) from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import ( - make_ai_move_in_game_broker_task, + make_ai_move_in_game_task, ) diff --git a/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py b/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py index 4147d57..f9b0dc0 100644 --- a/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py +++ b/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py @@ -12,6 +12,7 @@ @nats_tasks.task( + task_name="stars_purchase-stars_purchase-complete_stars_purchase_payment", subject="stars_purchase.stars_purchase.complete_stars_purchase_payment", pull_subscribe=PullSubscribe(lambda js, subject: js.pull_subscribe( subject, diff --git a/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py b/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py index 9316dc9..596d0b1 100644 --- a/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py +++ b/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py @@ -9,6 +9,7 @@ @nats_tasks.task( + task_name="game-game-make_ai_move_in_game", subject="game.game.make_ai_move_in_game", pull_subscribe=PullSubscribe(lambda js, subject: js.pull_subscribe( subject, @@ -17,7 +18,7 @@ )), ) @inject(patch_module=True) -async def make_ai_move_in_game_broker_task( +async def make_ai_move_in_game_task( user_id: int, game_id: UUID, ai_id: UUID, diff --git a/src/ttt/infrastructure/taskiq/worker.py b/src/ttt/infrastructure/taskiq/worker.py index cc4ee22..b6e157b 100644 --- a/src/ttt/infrastructure/taskiq/worker.py +++ b/src/ttt/infrastructure/taskiq/worker.py @@ -3,8 +3,6 @@ from types import TracebackType from typing import Self -from dishka import AsyncContainer -from dishka.integrations.taskiq import setup_dishka from taskiq.receiver import Receiver @@ -19,10 +17,7 @@ async def __aenter__(self) -> Self: await self._task_group.__aenter__() return self - async def __call__(self, container: AsyncContainer) -> None: - for receiver in self._receivers: - setup_dishka(container, receiver.broker) - + async def __call__(self) -> None: await gather(*( receiver.broker.startup() for receiver in self._receivers diff --git a/src/ttt/main/common/next_container.py b/src/ttt/main/common/next_container.py new file mode 100644 index 0000000..3e50175 --- /dev/null +++ b/src/ttt/main/common/next_container.py @@ -0,0 +1,25 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from dishka import AsyncContainer +from dishka.async_container import AsyncContextWrapper + +from ttt.infrastructure.dishka.next_container import NextContainer + + +@dataclass +class NextContainerWithFilledContext(NextContainer): + _root_container: AsyncContainer + _context_type_hints: tuple[Any, ...] + + def __call__( + self, context: Mapping[Any, Any] = {}, + ) -> AsyncContextWrapper: + return self._root_container(self._filled_context(context)) + + def _filled_context(self, context: Mapping[Any, Any]) -> dict[Any, Any]: + return { + hint | None: context.get(hint) + for hint in self._context_type_hints + } diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index 9f3e4e9..b3f44d8 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -19,6 +19,7 @@ FromComponent, Provider, Scope, + from_context, provide, ) from dishka.integrations.aiogram import AiogramMiddlewareData @@ -165,15 +166,11 @@ class NoMessageInEventError(Exception): class PresentationProvider(Provider): - @provide(scope=Scope.REQUEST) - def provide_event(self, event: TelegramObject) -> TelegramObject | None: - return event - - @provide(scope=Scope.REQUEST) - def provide_aiogram_middleware_data( - self, data: AiogramMiddlewareData, - ) -> AiogramMiddlewareData | None: - return data + provide_aiogram_middleware_data = from_context( + AiogramMiddlewareData | None, + scope=Scope.REQUEST, + ) + provide_event = from_context(TelegramObject | None, scope=Scope.REQUEST) @provide(scope=Scope.APP) def provide_strage(self, redis: Redis) -> BaseStorage: @@ -347,13 +344,6 @@ def provide_callback_query( case _: return None - @provide(scope=Scope.REQUEST) - def provide_fsm_context( - self, - middleware_data: AiogramMiddlewareData, - ) -> FSMContext: - return cast(FSMContext, middleware_data["state"]) - @provide(scope=Scope.REQUEST) def provide_stars_purchase_payment_gateway( self, @@ -437,7 +427,9 @@ class ApplicationProvider(Provider): ) provide_cancel_game = provide(CancelGame, scope=Scope.REQUEST) provide_make_move_in_game = provide(MakeMoveInGame, scope=Scope.REQUEST) - provide_make_ai_move_in_game = provide(MakeAiMoveInGame, scope=Scope.REQUEST) + provide_make_ai_move_in_game = provide( + MakeAiMoveInGame, scope=Scope.REQUEST, + ) provide_view_game = provide(ViewGame, scope=Scope.REQUEST) provide_accept_invitation_to_game = provide( diff --git a/src/ttt/main/tg_bot/start_tg_bot.py b/src/ttt/main/tg_bot/start_tg_bot.py index fa465ae..70eefcd 100644 --- a/src/ttt/main/tg_bot/start_tg_bot.py +++ b/src/ttt/main/tg_bot/start_tg_bot.py @@ -1,38 +1,46 @@ import logging -from functools import partial from aiogram import Bot, Dispatcher from aiogram.types import TelegramObject from dishka import AsyncContainer from dishka.integrations.aiogram import ( AiogramMiddlewareData, - ContainerMiddleware, ) +from taskiq import TaskiqMessage +from ttt.infrastructure.taskiq.broker import NatsBroker +from ttt.infrastructure.taskiq.middlewares import TaskiqNextContainerMiddleware from ttt.infrastructure.taskiq.worker import TaskiqBgWorker +from ttt.main.common.next_container import NextContainerWithFilledContext +from ttt.presentation.aiogram.common.middlewares import ( + AiogramNextContainerMiddleware, +) from ttt.presentation.tasks.unkillable_tasks import UnkillableTasks async def start_tg_bot(container: AsyncContainer) -> None: - dp = await container.get(Dispatcher) - middleware = ContainerMiddleware(container) + next_container = NextContainerWithFilledContext( + container, + (TelegramObject, AiogramMiddlewareData, TaskiqMessage), + ) + dp = await container.get(Dispatcher) + middleware = AiogramNextContainerMiddleware(next_container) for observer in dp.observers.values(): observer.middleware(middleware) - logging.basicConfig(level=logging.INFO) - - tasks = await container.get(UnkillableTasks) - next_container = partial( - container, {TelegramObject: None, AiogramMiddlewareData: None}, - ) - await tasks(next_container) + nats_broker = await container.get(NatsBroker) + nats_broker.add_middlewares(TaskiqNextContainerMiddleware(next_container)) taskiq_bg_worker = await container.get(TaskiqBgWorker) + tasks = await container.get(UnkillableTasks) bot = await container.get(Bot) + logging.basicConfig(level=logging.INFO) + try: - await taskiq_bg_worker(container) + # await tasks(next_container) + await taskiq_bg_worker() await dp.start_polling(bot) finally: await container.close() diff --git a/src/ttt/main/tg_bot_dev/__main__.py b/src/ttt/main/tg_bot_dev/__main__.py index 80cd6ef..29940f6 100644 --- a/src/ttt/main/tg_bot_dev/__main__.py +++ b/src/ttt/main/tg_bot_dev/__main__.py @@ -1,7 +1,6 @@ import asyncio from dishka import make_async_container -from dishka.integrations.aiogram import AiogramProvider from ttt.main.common.di import InfrastructureProvider from ttt.main.tg_bot.di import ( @@ -17,7 +16,6 @@ async def amain() -> None: container = make_async_container( - AiogramProvider(), ApplicationProvider(), PresentationProvider(), InfrastructureProvider(), diff --git a/src/ttt/main/tg_bot_prod/__main__.py b/src/ttt/main/tg_bot_prod/__main__.py index be8ff15..2e4cce2 100644 --- a/src/ttt/main/tg_bot_prod/__main__.py +++ b/src/ttt/main/tg_bot_prod/__main__.py @@ -2,7 +2,6 @@ import sentry_sdk from dishka import make_async_container -from dishka.integrations.aiogram import AiogramProvider from ttt import __version__ from ttt.infrastructure.pydantic_settings.secrets import Secrets @@ -20,7 +19,6 @@ async def amain() -> None: container = make_async_container( - AiogramProvider(), ApplicationProvider(), PresentationProvider(), InfrastructureProvider(), diff --git a/src/ttt/presentation/aiogram/common/middlewares.py b/src/ttt/presentation/aiogram/common/middlewares.py new file mode 100644 index 0000000..3d67846 --- /dev/null +++ b/src/ttt/presentation/aiogram/common/middlewares.py @@ -0,0 +1,27 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject +from dishka.integrations.aiogram import CONTAINER_NAME, AiogramMiddlewareData + +from ttt.infrastructure.dishka.next_container import NextContainer + + +class AiogramNextContainerMiddleware(BaseMiddleware): + def __init__(self, next_container: NextContainer) -> None: + self.next_container = next_container + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: # noqa: ANN401 + context = { + TelegramObject: event, + AiogramMiddlewareData: data, + } + async with self.next_container(context) as sub_container: + data[CONTAINER_NAME] = sub_container + return await handler(event, data) diff --git a/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py b/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py index 00e69d1..dc4321d 100644 --- a/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py +++ b/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py @@ -4,8 +4,9 @@ from ttt.application.invitation_to_game.game.auto_cancel_invitations_to_game import ( # noqa: E501 AutoCancelInvitationsToGame, ) +from ttt.infrastructure.dishka.next_container import NextContainer from ttt.infrastructure.retrier import Retrier -from ttt.presentation.tasks.task import NextContainer, Task +from ttt.presentation.tasks.task import Task @dataclass(frozen=True) diff --git a/src/ttt/presentation/tasks/matchmake_tasks.py b/src/ttt/presentation/tasks/matchmake_tasks.py index 8e013ac..2ee4448 100644 --- a/src/ttt/presentation/tasks/matchmake_tasks.py +++ b/src/ttt/presentation/tasks/matchmake_tasks.py @@ -6,7 +6,8 @@ from ttt.application.user.game.matchmake import Matchmake from ttt.infrastructure.retrier import Retrier from ttt.infrastructure.structlog.logger import unexpected_error_log -from ttt.presentation.tasks.task import NextContainer, Task +from ttt.presentation.tasks.task import Task +from ttt.infrastructure.dishka.next_container import NextContainer @dataclass diff --git a/src/ttt/presentation/tasks/task.py b/src/ttt/presentation/tasks/task.py index 21e6830..c1b5708 100644 --- a/src/ttt/presentation/tasks/task.py +++ b/src/ttt/presentation/tasks/task.py @@ -3,8 +3,7 @@ from dishka.async_container import AsyncContextWrapper - -type NextContainer = Callable[[], AsyncContextWrapper] +from ttt.infrastructure.dishka.next_container import NextContainer class Task(Protocol): diff --git a/src/ttt/presentation/tasks/unkillable_tasks.py b/src/ttt/presentation/tasks/unkillable_tasks.py index 33567d3..7994601 100644 --- a/src/ttt/presentation/tasks/unkillable_tasks.py +++ b/src/ttt/presentation/tasks/unkillable_tasks.py @@ -2,8 +2,9 @@ from dataclasses import dataclass from functools import partial -from ttt.presentation.tasks.task import NextContainer, Task +from ttt.presentation.tasks.task import Task from ttt.presentation.unkillable_task_group import UnkillableTaskGroup +from ttt.infrastructure.dishka.next_container import NextContainer @dataclass(frozen=True, unsafe_hash=False) From c9e076dd6276365a1e223b8ea02781ee9ef9f7f4 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Thu, 25 Sep 2025 18:12:29 +0700 Subject: [PATCH 32/45] fix: remove `ruff` errors (#62) --- src/ttt/application/game/game/make_ai_move_in_game.py | 1 - src/ttt/application/game/game/ports/game_views.py | 1 - src/ttt/main/tg_bot/di.py | 1 - src/ttt/main/tg_bot/start_tg_bot.py | 2 +- src/ttt/presentation/tasks/matchmake_tasks.py | 2 +- src/ttt/presentation/tasks/task.py | 3 --- src/ttt/presentation/tasks/unkillable_tasks.py | 2 +- 7 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/ttt/application/game/game/make_ai_move_in_game.py b/src/ttt/application/game/game/make_ai_move_in_game.py index 2c17fc0..117dbc8 100644 --- a/src/ttt/application/game/game/make_ai_move_in_game.py +++ b/src/ttt/application/game/game/make_ai_move_in_game.py @@ -14,7 +14,6 @@ from ttt.application.game.game.ports.game_views import GameViews from ttt.application.game.game.ports.games import Games from ttt.application.user.common.ports.user_locks import UserLocks -from ttt.application.user.common.ports.users import Users from ttt.entities.core.game.game import ( AlreadyCompletedGameError, NotAiCurrentMoveError, diff --git a/src/ttt/application/game/game/ports/game_views.py b/src/ttt/application/game/game/ports/game_views.py index a67678d..52213a6 100644 --- a/src/ttt/application/game/game/ports/game_views.py +++ b/src/ttt/application/game/game/ports/game_views.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from uuid import UUID from ttt.entities.core.game.game import Game diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index b3f44d8..df85a01 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -3,7 +3,6 @@ from typing import Annotated, cast from aiogram import Bot, Dispatcher -from aiogram.fsm.context import FSMContext from aiogram.fsm.storage.base import BaseStorage, DefaultKeyBuilder from aiogram.fsm.storage.redis import RedisStorage from aiogram.types import ( diff --git a/src/ttt/main/tg_bot/start_tg_bot.py b/src/ttt/main/tg_bot/start_tg_bot.py index 70eefcd..df036f7 100644 --- a/src/ttt/main/tg_bot/start_tg_bot.py +++ b/src/ttt/main/tg_bot/start_tg_bot.py @@ -39,7 +39,7 @@ async def start_tg_bot(container: AsyncContainer) -> None: logging.basicConfig(level=logging.INFO) try: - # await tasks(next_container) + await tasks(next_container) await taskiq_bg_worker() await dp.start_polling(bot) finally: diff --git a/src/ttt/presentation/tasks/matchmake_tasks.py b/src/ttt/presentation/tasks/matchmake_tasks.py index 2ee4448..112f255 100644 --- a/src/ttt/presentation/tasks/matchmake_tasks.py +++ b/src/ttt/presentation/tasks/matchmake_tasks.py @@ -4,10 +4,10 @@ from structlog.types import FilteringBoundLogger from ttt.application.user.game.matchmake import Matchmake +from ttt.infrastructure.dishka.next_container import NextContainer from ttt.infrastructure.retrier import Retrier from ttt.infrastructure.structlog.logger import unexpected_error_log from ttt.presentation.tasks.task import Task -from ttt.infrastructure.dishka.next_container import NextContainer @dataclass diff --git a/src/ttt/presentation/tasks/task.py b/src/ttt/presentation/tasks/task.py index c1b5708..f667319 100644 --- a/src/ttt/presentation/tasks/task.py +++ b/src/ttt/presentation/tasks/task.py @@ -1,8 +1,5 @@ -from collections.abc import Callable from typing import Any, Protocol -from dishka.async_container import AsyncContextWrapper - from ttt.infrastructure.dishka.next_container import NextContainer diff --git a/src/ttt/presentation/tasks/unkillable_tasks.py b/src/ttt/presentation/tasks/unkillable_tasks.py index 7994601..7822336 100644 --- a/src/ttt/presentation/tasks/unkillable_tasks.py +++ b/src/ttt/presentation/tasks/unkillable_tasks.py @@ -2,9 +2,9 @@ from dataclasses import dataclass from functools import partial +from ttt.infrastructure.dishka.next_container import NextContainer from ttt.presentation.tasks.task import Task from ttt.presentation.unkillable_task_group import UnkillableTaskGroup -from ttt.infrastructure.dishka.next_container import NextContainer @dataclass(frozen=True, unsafe_hash=False) From ecd4795520563d99e9d87fa081cf824c9dd3197d Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Thu, 25 Sep 2025 19:49:46 +0700 Subject: [PATCH 33/45] ref: make `processors` from `tasks` (#62) --- .../processors}/__init__.py | 0 ...to_cancel_invitations_to_game_processor.py | 29 +++++++++ .../processors/matchmake_processor.py} | 4 +- .../processors/processor.py} | 2 +- .../infrastructure/processors/processors.py | 14 ++++ src/ttt/infrastructure/structlog/logger.py | 12 ++++ src/ttt/main/common/di.py | 49 +++++++++++++- src/ttt/main/tg_bot/di.py | 55 +--------------- src/ttt/main/tg_bot/start_tg_bot.py | 19 +++--- .../auto_cancel_invitations_to_game_task.py | 24 ------- .../presentation/tasks/unkillable_tasks.py | 17 ----- src/ttt/presentation/unkillable_task_group.py | 64 ------------------- 12 files changed, 118 insertions(+), 171 deletions(-) rename src/ttt/{presentation/tasks => infrastructure/processors}/__init__.py (100%) create mode 100644 src/ttt/infrastructure/processors/auto_cancel_invitations_to_game_processor.py rename src/ttt/{presentation/tasks/matchmake_tasks.py => infrastructure/processors/matchmake_processor.py} (93%) rename src/ttt/{presentation/tasks/task.py => infrastructure/processors/processor.py} (87%) create mode 100644 src/ttt/infrastructure/processors/processors.py delete mode 100644 src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py delete mode 100644 src/ttt/presentation/tasks/unkillable_tasks.py delete mode 100644 src/ttt/presentation/unkillable_task_group.py diff --git a/src/ttt/presentation/tasks/__init__.py b/src/ttt/infrastructure/processors/__init__.py similarity index 100% rename from src/ttt/presentation/tasks/__init__.py rename to src/ttt/infrastructure/processors/__init__.py diff --git a/src/ttt/infrastructure/processors/auto_cancel_invitations_to_game_processor.py b/src/ttt/infrastructure/processors/auto_cancel_invitations_to_game_processor.py new file mode 100644 index 0000000..1319a07 --- /dev/null +++ b/src/ttt/infrastructure/processors/auto_cancel_invitations_to_game_processor.py @@ -0,0 +1,29 @@ +from asyncio import sleep +from dataclasses import dataclass + +from structlog.types import FilteringBoundLogger + +from ttt.application.invitation_to_game.game.auto_cancel_invitations_to_game import ( # noqa: E501 + AutoCancelInvitationsToGame, +) +from ttt.infrastructure.dishka.next_container import NextContainer +from ttt.infrastructure.processors.processor import Processor +from ttt.infrastructure.retrier import Retrier +from ttt.infrastructure.structlog.logger import unexpected_error_logging + + +@dataclass(frozen=True) +class AutoCancelInvitationsToGameProcessor(Processor): + _interval_seconds: float + _logger: FilteringBoundLogger + + async def __call__(self, container: NextContainer) -> None: + while True: + await sleep(self._interval_seconds) + async with unexpected_error_logging(self._logger): # noqa: SIM117 + async with container() as request: + retrier = await request.get(Retrier) + cancel_invitations = await request.get( + AutoCancelInvitationsToGame, + ) + await retrier(cancel_invitations) diff --git a/src/ttt/presentation/tasks/matchmake_tasks.py b/src/ttt/infrastructure/processors/matchmake_processor.py similarity index 93% rename from src/ttt/presentation/tasks/matchmake_tasks.py rename to src/ttt/infrastructure/processors/matchmake_processor.py index 112f255..c1a3d50 100644 --- a/src/ttt/presentation/tasks/matchmake_tasks.py +++ b/src/ttt/infrastructure/processors/matchmake_processor.py @@ -5,13 +5,13 @@ from ttt.application.user.game.matchmake import Matchmake from ttt.infrastructure.dishka.next_container import NextContainer +from ttt.infrastructure.processors.processor import Processor from ttt.infrastructure.retrier import Retrier from ttt.infrastructure.structlog.logger import unexpected_error_log -from ttt.presentation.tasks.task import Task @dataclass -class MatchmakeTasks(Task): +class MatchmakeProcessor(Processor): _max_workers: int _worker_creation_interval_seconds: float _logger: FilteringBoundLogger diff --git a/src/ttt/presentation/tasks/task.py b/src/ttt/infrastructure/processors/processor.py similarity index 87% rename from src/ttt/presentation/tasks/task.py rename to src/ttt/infrastructure/processors/processor.py index f667319..895de82 100644 --- a/src/ttt/presentation/tasks/task.py +++ b/src/ttt/infrastructure/processors/processor.py @@ -3,5 +3,5 @@ from ttt.infrastructure.dishka.next_container import NextContainer -class Task(Protocol): +class Processor(Protocol): async def __call__(self, container: NextContainer, /) -> Any: ... # noqa: ANN401 diff --git a/src/ttt/infrastructure/processors/processors.py b/src/ttt/infrastructure/processors/processors.py new file mode 100644 index 0000000..86fc078 --- /dev/null +++ b/src/ttt/infrastructure/processors/processors.py @@ -0,0 +1,14 @@ +from asyncio import gather +from collections.abc import Sequence +from dataclasses import dataclass + +from ttt.infrastructure.dishka.next_container import NextContainer +from ttt.infrastructure.processors.processor import Processor + + +@dataclass(frozen=True, unsafe_hash=False) +class Processors(Processor): + _processors: Sequence[Processor] + + async def __call__(self, container: NextContainer) -> None: + await gather(*(processor(container) for processor in self._processors)) diff --git a/src/ttt/infrastructure/structlog/logger.py b/src/ttt/infrastructure/structlog/logger.py index 87fc39f..2601bf5 100644 --- a/src/ttt/infrastructure/structlog/logger.py +++ b/src/ttt/infrastructure/structlog/logger.py @@ -1,4 +1,6 @@ import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from typing import cast import structlog @@ -49,3 +51,13 @@ async def unexpected_error_log( error: Exception, ) -> None: await logger.aexception("unexpected_error", exc_info=error) + + +@asynccontextmanager +async def unexpected_error_logging( + logger: FilteringBoundLogger, +) -> AsyncIterator[None]: + try: + yield + except Exception as error: # noqa: BLE001 + await unexpected_error_log(logger, error) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 3270f7c..98294bb 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -1,7 +1,8 @@ from asyncio import Queue from collections.abc import AsyncIterator +from typing import Annotated -from dishka import Provider, Scope, provide +from dishka import FromComponent, Provider, Scope, provide from nats import connect as connect_to_nats from nats.aio.client import Client as Nats from nats.js import JetStreamContext @@ -11,6 +12,7 @@ AsyncSession, create_async_engine, ) +from structlog.types import FilteringBoundLogger from taskiq.receiver import Receiver from ttt.application.common.errors.serialization_error import SerializationError @@ -105,6 +107,11 @@ from ttt.infrastructure.adapters.users import InPostgresUsers from ttt.infrastructure.adapters.uuids import UUIDv4s from ttt.infrastructure.openai.gemini import Gemini, gemini +from ttt.infrastructure.processors.auto_cancel_invitations_to_game_processor import ( # noqa: E501 + AutoCancelInvitationsToGameProcessor, +) +from ttt.infrastructure.processors.matchmake_processor import MatchmakeProcessor +from ttt.infrastructure.processors.processors import Processors from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets from ttt.infrastructure.retrier import Retrier @@ -358,3 +365,43 @@ def provide_retrier(self, envs: Envs) -> Retrier: }) provide_retry = provide(RetrierRetry, provides=Retry, scope=Scope.REQUEST) + + @provide(scope=Scope.APP) + def provide_auto_cancel_invitations_to_game_task( + self, + envs: Envs, + logger: Annotated[FilteringBoundLogger, FromComponent("app")], + ) -> AutoCancelInvitationsToGameProcessor: + return AutoCancelInvitationsToGameProcessor( + _interval_seconds=( + envs.auto_cancel_invitations_to_game_interval_seconds + ), + _logger=logger, + ) + + @provide(scope=Scope.APP) + def provide_matchmake_processor( + self, + envs: Envs, + logger: Annotated[FilteringBoundLogger, FromComponent("app")], + ) -> MatchmakeProcessor: + return MatchmakeProcessor( + _max_workers=envs.matchmaking_max_workers, + _worker_creation_interval_seconds=( + envs.matchmaking_worker_creation_interval_seconds + ), + _logger=logger, + ) + + @provide(scope=Scope.APP) + async def processors( + self, + auto_cancel_invitations_to_game_processor: ( + AutoCancelInvitationsToGameProcessor + ), + matchmake_processor: MatchmakeProcessor, + ) -> Processors: + return Processors(( + auto_cancel_invitations_to_game_processor, + matchmake_processor, + )) diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index df85a01..1db073a 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -1,6 +1,6 @@ from collections.abc import AsyncIterator from dataclasses import dataclass -from typing import Annotated, cast +from typing import cast from aiogram import Bot, Dispatcher from aiogram.fsm.storage.base import BaseStorage, DefaultKeyBuilder @@ -15,7 +15,6 @@ from aiogram_dialog.manager.bg_manager import BgManagerFactoryImpl from aiogram_dialog.manager.manager import ManagerImpl from dishka import ( - FromComponent, Provider, Scope, from_context, @@ -23,7 +22,6 @@ ) from dishka.integrations.aiogram import AiogramMiddlewareData from redis.asyncio import Redis -from structlog.types import FilteringBoundLogger from ttt.application.common.ports.emojis import Emojis from ttt.application.game.game.cancel_game import CancelGame @@ -120,7 +118,6 @@ from ttt.application.user.view_other_user import ViewOtherUser from ttt.application.user.view_user import ViewUser from ttt.application.user.view_user_emojis import ViewUserEmojis -from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets from ttt.presentation.adapters.emojis import PictographsAsEmojis from ttt.presentation.adapters.game_views import ( @@ -151,12 +148,6 @@ ) from ttt.presentation.aiogram_dialog.main_dialog import main_dialog from ttt.presentation.result_buffer import ResultBuffer -from ttt.presentation.tasks.auto_cancel_invitations_to_game_task import ( - AutoCancelInvitationsToGameTask, -) -from ttt.presentation.tasks.matchmake_tasks import MatchmakeTasks -from ttt.presentation.tasks.unkillable_tasks import UnkillableTasks -from ttt.presentation.unkillable_task_group import UnkillableTaskGroup @dataclass @@ -253,50 +244,6 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: def provide_result_buffer(self) -> ResultBuffer: return ResultBuffer() - @provide(scope=Scope.APP) - def provide_auto_cancel_invitations_to_game_task( - self, envs: Envs, - ) -> AutoCancelInvitationsToGameTask: - return AutoCancelInvitationsToGameTask( - _interval_seconds=( - envs.auto_cancel_invitations_to_game_interval_seconds - ), - ) - - @provide(scope=Scope.APP) - def provide_matchmake_tasks( - self, - envs: Envs, - logger: Annotated[FilteringBoundLogger, FromComponent("app")], - ) -> MatchmakeTasks: - return MatchmakeTasks( - _max_workers=envs.matchmaking_max_workers, - _worker_creation_interval_seconds=( - envs.matchmaking_worker_creation_interval_seconds - ), - _logger=logger, - ) - - @provide(scope=Scope.APP) - async def unkillable_task_group( - self, logger: Annotated[FilteringBoundLogger, FromComponent("app")], - ) -> AsyncIterator[UnkillableTaskGroup]: - async with UnkillableTaskGroup(logger) as group: - yield group - - @provide(scope=Scope.APP) - async def unkillable_tasks( - self, - task_group: UnkillableTaskGroup, - auto_cancel_invitations_to_game_task: AutoCancelInvitationsToGameTask, - matchmake_tasks: MatchmakeTasks, - ) -> UnkillableTasks: - tasks = ( - auto_cancel_invitations_to_game_task, - matchmake_tasks, - ) - return UnkillableTasks(tasks, task_group) - @provide(scope=Scope.APP) def provide_dp(self, storage: BaseStorage) -> Dispatcher: dp = Dispatcher(name="main", storage=storage) diff --git a/src/ttt/main/tg_bot/start_tg_bot.py b/src/ttt/main/tg_bot/start_tg_bot.py index df036f7..a1835e1 100644 --- a/src/ttt/main/tg_bot/start_tg_bot.py +++ b/src/ttt/main/tg_bot/start_tg_bot.py @@ -1,13 +1,14 @@ import logging +from asyncio import CancelledError, TaskGroup +from contextlib import suppress from aiogram import Bot, Dispatcher from aiogram.types import TelegramObject from dishka import AsyncContainer -from dishka.integrations.aiogram import ( - AiogramMiddlewareData, -) +from dishka.integrations.aiogram import AiogramMiddlewareData from taskiq import TaskiqMessage +from ttt.infrastructure.processors.processors import Processors from ttt.infrastructure.taskiq.broker import NatsBroker from ttt.infrastructure.taskiq.middlewares import TaskiqNextContainerMiddleware from ttt.infrastructure.taskiq.worker import TaskiqBgWorker @@ -15,7 +16,6 @@ from ttt.presentation.aiogram.common.middlewares import ( AiogramNextContainerMiddleware, ) -from ttt.presentation.tasks.unkillable_tasks import UnkillableTasks async def start_tg_bot(container: AsyncContainer) -> None: @@ -33,14 +33,17 @@ async def start_tg_bot(container: AsyncContainer) -> None: nats_broker.add_middlewares(TaskiqNextContainerMiddleware(next_container)) taskiq_bg_worker = await container.get(TaskiqBgWorker) - tasks = await container.get(UnkillableTasks) + processors = await container.get(Processors) bot = await container.get(Bot) logging.basicConfig(level=logging.INFO) try: - await tasks(next_container) - await taskiq_bg_worker() - await dp.start_polling(bot) + with suppress(CancelledError): + async with TaskGroup() as tasks: + tasks.create_task(processors(next_container)) + await taskiq_bg_worker() + await dp.start_polling(bot) + raise CancelledError finally: await container.close() diff --git a/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py b/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py deleted file mode 100644 index dc4321d..0000000 --- a/src/ttt/presentation/tasks/auto_cancel_invitations_to_game_task.py +++ /dev/null @@ -1,24 +0,0 @@ -from asyncio import sleep -from dataclasses import dataclass - -from ttt.application.invitation_to_game.game.auto_cancel_invitations_to_game import ( # noqa: E501 - AutoCancelInvitationsToGame, -) -from ttt.infrastructure.dishka.next_container import NextContainer -from ttt.infrastructure.retrier import Retrier -from ttt.presentation.tasks.task import Task - - -@dataclass(frozen=True) -class AutoCancelInvitationsToGameTask(Task): - _interval_seconds: float - - async def __call__(self, container: NextContainer) -> None: - while True: - await sleep(self._interval_seconds) - async with container() as request: - retrier = await request.get(Retrier) - cancel_invitations = await request.get( - AutoCancelInvitationsToGame, - ) - await retrier(cancel_invitations) diff --git a/src/ttt/presentation/tasks/unkillable_tasks.py b/src/ttt/presentation/tasks/unkillable_tasks.py deleted file mode 100644 index 7822336..0000000 --- a/src/ttt/presentation/tasks/unkillable_tasks.py +++ /dev/null @@ -1,17 +0,0 @@ -from collections.abc import Sequence -from dataclasses import dataclass -from functools import partial - -from ttt.infrastructure.dishka.next_container import NextContainer -from ttt.presentation.tasks.task import Task -from ttt.presentation.unkillable_task_group import UnkillableTaskGroup - - -@dataclass(frozen=True, unsafe_hash=False) -class UnkillableTasks(Task): - _tasks: Sequence[Task] - _group: UnkillableTaskGroup - - async def __call__(self, container: NextContainer) -> None: - for task in self._tasks: - self._group.add(partial(task, container)) diff --git a/src/ttt/presentation/unkillable_task_group.py b/src/ttt/presentation/unkillable_task_group.py deleted file mode 100644 index 90de6f4..0000000 --- a/src/ttt/presentation/unkillable_task_group.py +++ /dev/null @@ -1,64 +0,0 @@ -import asyncio -from collections.abc import Callable, Coroutine -from dataclasses import dataclass, field -from types import TracebackType -from typing import Any, Self - -from structlog.types import FilteringBoundLogger - -from ttt.infrastructure.structlog.logger import unexpected_error_log - - -@dataclass(frozen=True, unsafe_hash=False) -class UnkillableTaskGroup: - _logger: FilteringBoundLogger - _loop: asyncio.AbstractEventLoop = field( - init=False, - default_factory=asyncio.get_running_loop, - ) - _tasks: set[asyncio.Task[Any]] = field(init=False, default_factory=set) - - async def __aenter__(self) -> Self: - return self - - async def __aexit__( - self, - error_type: type[BaseException] | None, - error: BaseException | None, - traceback: TracebackType | None, - ) -> None: - for task in self._tasks: - task.cancel() - - errors = await asyncio.gather(*self._tasks, return_exceptions=True) - errors.append(error) - - errors = [error for error in errors if isinstance(error, Exception)] - - if errors: - raise ExceptionGroup("unhandled errors", errors) # noqa: TRY003 - - def add( - self, - func: Callable[[], Coroutine[Any, Any, Any]], - ) -> None: - decorated_func = self._decorator(func) - self._create_task(decorated_func()) - - def _decorator( - self, - func: Callable[[], Coroutine[Any, Any, Any]], - ) -> Callable[[], Coroutine[Any, Any, Any]]: - async def decorated_func() -> None: - try: - await func() - except Exception as error: # noqa: BLE001 - self._create_task(decorated_func()) - await unexpected_error_log(self._logger, error) - - return decorated_func - - def _create_task(self, coro: Coroutine[Any, Any, Any]) -> None: - task = self._loop.create_task(coro) - self._tasks.add(task) - task.add_done_callback(self._tasks.discard) From 4bde5a669d8f30815b9210504855c831fed0e584 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Fri, 26 Sep 2025 19:49:22 +0700 Subject: [PATCH 34/45] fix(`infrastructure`): replace `taskiq` with `remote_funcs` (#62) --- pyproject.toml | 2 - src/ttt/infrastructure/adapters/game_log.py | 2 +- src/ttt/infrastructure/adapters/game_tasks.py | 10 +- .../adapters/stars_purchase_tasks.py | 14 +- .../multi_asynccontextmanager.py | 23 +++ .../infrastructure/processors/processors.py | 14 -- .../{taskiq => remote_funcs}/__init__.py | 0 .../complete_stars_purchase_payment.py | 40 ++++ .../remote_funcs/make_ai_move_in_game.py | 30 +++ .../remote_funcs/nats_remote_func.py | 161 ++++++++++++++++ src/ttt/infrastructure/taskiq/broker.py | 174 ------------------ src/ttt/infrastructure/taskiq/middlewares.py | 41 ----- .../infrastructure/taskiq/tasks/__init__.py | 7 - src/ttt/infrastructure/taskiq/tasks/common.py | 4 - .../complete_stars_purchase_payment_task.py | 34 ---- .../taskiq/tasks/make_ai_move_in_game_task.py | 28 --- src/ttt/infrastructure/taskiq/worker.py | 36 ---- src/ttt/main/common/di.py | 60 +++--- src/ttt/main/tg_bot/start_tg_bot.py | 15 +- uv.lock | 79 -------- 20 files changed, 304 insertions(+), 470 deletions(-) create mode 100644 src/ttt/infrastructure/multi_asynccontextmanager.py delete mode 100644 src/ttt/infrastructure/processors/processors.py rename src/ttt/infrastructure/{taskiq => remote_funcs}/__init__.py (100%) create mode 100644 src/ttt/infrastructure/remote_funcs/complete_stars_purchase_payment.py create mode 100644 src/ttt/infrastructure/remote_funcs/make_ai_move_in_game.py create mode 100644 src/ttt/infrastructure/remote_funcs/nats_remote_func.py delete mode 100644 src/ttt/infrastructure/taskiq/broker.py delete mode 100644 src/ttt/infrastructure/taskiq/middlewares.py delete mode 100644 src/ttt/infrastructure/taskiq/tasks/__init__.py delete mode 100644 src/ttt/infrastructure/taskiq/tasks/common.py delete mode 100644 src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py delete mode 100644 src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py delete mode 100644 src/ttt/infrastructure/taskiq/worker.py diff --git a/pyproject.toml b/pyproject.toml index d6bbee5..2f18ede 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,6 @@ dependencies = [ "openai==1.97.0", "structlog==25.4.0", "structlog-sentry==2.2.1", - "taskiq==0.11.18", ] [dependency-groups] @@ -117,7 +116,6 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "src/ttt/infrastructure/sqlalchemy/tables/__init__.py" = ["F401"] -"src/ttt/infrastructure/taskiq/tasks/__init__.py" = ["F401"] "src/ttt/infrastructure/adapters/*" = ["ARG002"] "src/ttt/presentation/adapters/*" = ["ARG002"] "src/ttt/presentation/*" = ["RUF001"] diff --git a/src/ttt/infrastructure/adapters/game_log.py b/src/ttt/infrastructure/adapters/game_log.py index 84fd524..d552a57 100644 --- a/src/ttt/infrastructure/adapters/game_log.py +++ b/src/ttt/infrastructure/adapters/game_log.py @@ -88,7 +88,7 @@ async def already_completed_game_to_make_ai_move( /, ) -> None: await self._logger.ainfo( - "already_completed_game_to_make_move", + "already_completed_game_to_make_ai_move", ai_id=ai_id.hex, game_id=game.id.hex, ) diff --git a/src/ttt/infrastructure/adapters/game_tasks.py b/src/ttt/infrastructure/adapters/game_tasks.py index ec2a8c3..86d6569 100644 --- a/src/ttt/infrastructure/adapters/game_tasks.py +++ b/src/ttt/infrastructure/adapters/game_tasks.py @@ -2,13 +2,13 @@ from uuid import UUID from ttt.application.game.game.ports.game_tasks import GameTasks -from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import ( - make_ai_move_in_game_task, +from ttt.infrastructure.remote_funcs.make_ai_move_in_game import ( + make_ai_move_in_game_remotely, ) @dataclass -class TaskiqGameTasks(GameTasks): +class NatsRemoteFuncGameTasks(GameTasks): async def make_ai_move( self, user_id: int, @@ -16,4 +16,6 @@ async def make_ai_move( ai_id: UUID, /, ) -> None: - await make_ai_move_in_game_task.kiq(user_id, game_id, ai_id) + await make_ai_move_in_game_remotely( + user_id=user_id, game_id=game_id.hex, ai_id=ai_id.hex, + ) diff --git a/src/ttt/infrastructure/adapters/stars_purchase_tasks.py b/src/ttt/infrastructure/adapters/stars_purchase_tasks.py index 1732e52..eb14ed3 100644 --- a/src/ttt/infrastructure/adapters/stars_purchase_tasks.py +++ b/src/ttt/infrastructure/adapters/stars_purchase_tasks.py @@ -5,21 +5,21 @@ StarsPurchaseTasks, ) from ttt.entities.finance.payment.success import PaymentSuccess -from ttt.infrastructure.taskiq.tasks.complete_stars_purchase_payment_task import ( # noqa: E501 - complete_stars_purchase_payment_task, +from ttt.infrastructure.remote_funcs.complete_stars_purchase_payment import ( + complete_stars_purchase_payment_remotely, ) @dataclass -class TaskiqStarsPurchaseTasks(StarsPurchaseTasks): +class NatsRemoteFuncStarsPurchaseTasks(StarsPurchaseTasks): async def complete_stars_purchase_payment( self, purchase_id: UUID, success: PaymentSuccess, /, ) -> None: - await complete_stars_purchase_payment_task.kiq( - purchase_id, - success.id, - success.gateway_id, + await complete_stars_purchase_payment_remotely( + purchase_id=purchase_id.hex, + payment_success_id=success.id, + payment_success_gateway_id=success.gateway_id, ) diff --git a/src/ttt/infrastructure/multi_asynccontextmanager.py b/src/ttt/infrastructure/multi_asynccontextmanager.py new file mode 100644 index 0000000..086b98d --- /dev/null +++ b/src/ttt/infrastructure/multi_asynccontextmanager.py @@ -0,0 +1,23 @@ +from asyncio import gather +from collections.abc import AsyncIterator +from contextlib import AbstractAsyncContextManager, asynccontextmanager + + +@asynccontextmanager +async def multi_asynccontextmanager[T]( + *managers: AbstractAsyncContextManager[T], +) -> AsyncIterator[list[T]]: + result = await gather(*(manager.__aenter__() for manager in managers)) # noqa: PLC2801 + + try: + yield result + except BaseException as error: # noqa: BLE001 + await gather(*( + manager.__aexit__(type(error), error, error.__traceback__) + for manager in managers + )) + else: + await gather(*( + manager.__aexit__(None, None, None) + for manager in managers + )) diff --git a/src/ttt/infrastructure/processors/processors.py b/src/ttt/infrastructure/processors/processors.py deleted file mode 100644 index 86fc078..0000000 --- a/src/ttt/infrastructure/processors/processors.py +++ /dev/null @@ -1,14 +0,0 @@ -from asyncio import gather -from collections.abc import Sequence -from dataclasses import dataclass - -from ttt.infrastructure.dishka.next_container import NextContainer -from ttt.infrastructure.processors.processor import Processor - - -@dataclass(frozen=True, unsafe_hash=False) -class Processors(Processor): - _processors: Sequence[Processor] - - async def __call__(self, container: NextContainer) -> None: - await gather(*(processor(container) for processor in self._processors)) diff --git a/src/ttt/infrastructure/taskiq/__init__.py b/src/ttt/infrastructure/remote_funcs/__init__.py similarity index 100% rename from src/ttt/infrastructure/taskiq/__init__.py rename to src/ttt/infrastructure/remote_funcs/__init__.py diff --git a/src/ttt/infrastructure/remote_funcs/complete_stars_purchase_payment.py b/src/ttt/infrastructure/remote_funcs/complete_stars_purchase_payment.py new file mode 100644 index 0000000..3533bfc --- /dev/null +++ b/src/ttt/infrastructure/remote_funcs/complete_stars_purchase_payment.py @@ -0,0 +1,40 @@ +from uuid import UUID + +from dishka import AsyncContainer + +from ttt.application.stars_purchase.complete_stars_purchase_payment import ( + CompleteStarsPurchasePayment, +) +from ttt.entities.finance.payment.success import PaymentSuccess +from ttt.infrastructure.remote_funcs.nats_remote_func import nats_remote +from ttt.infrastructure.retrier import Retrier + + +@nats_remote( + subject="stars_purchase.stars_purchase.complete_stars_purchase_payment", + pull_subscribe=lambda js, subject: js.pull_subscribe( + subject, + "ttt-stars_purchase-stars_purchase-complete_stars_purchase_payment", + stream="STARS_PURCHASE", + ), +) +async def complete_stars_purchase_payment_remotely( + container: AsyncContainer, + *, + purchase_id: str, + payment_success_id: str, + payment_success_gateway_id: str, +) -> None: + payment_success = PaymentSuccess( + payment_success_id, payment_success_gateway_id, + ) + + retrier = await container.get(Retrier) + complete_stars_purchase_payment = await container.get( + CompleteStarsPurchasePayment, + ) + await retrier( + complete_stars_purchase_payment, + UUID(hex=purchase_id), + payment_success, + ) diff --git a/src/ttt/infrastructure/remote_funcs/make_ai_move_in_game.py b/src/ttt/infrastructure/remote_funcs/make_ai_move_in_game.py new file mode 100644 index 0000000..a4184da --- /dev/null +++ b/src/ttt/infrastructure/remote_funcs/make_ai_move_in_game.py @@ -0,0 +1,30 @@ +from uuid import UUID + +from dishka import AsyncContainer + +from ttt.application.game.game.make_ai_move_in_game import MakeAiMoveInGame +from ttt.infrastructure.remote_funcs.nats_remote_func import nats_remote +from ttt.infrastructure.retrier import Retrier + + +@nats_remote( + subject="game.game.make_ai_move_in_game", + pull_subscribe=lambda js, subject: js.pull_subscribe( + subject, + durable="ttt-game-game-make_ai_move_in_game", + stream="GAME", + ), +) +async def make_ai_move_in_game_remotely( + container: AsyncContainer, + *, + user_id: int, + game_id: str, + ai_id: str, +) -> None: + retrier = await container.get(Retrier) + make_ai_move_in_game = await container.get(MakeAiMoveInGame) + + await retrier( + make_ai_move_in_game, user_id, UUID(hex=game_id), UUID(hex=ai_id), + ) diff --git a/src/ttt/infrastructure/remote_funcs/nats_remote_func.py b/src/ttt/infrastructure/remote_funcs/nats_remote_func.py new file mode 100644 index 0000000..5e4faeb --- /dev/null +++ b/src/ttt/infrastructure/remote_funcs/nats_remote_func.py @@ -0,0 +1,161 @@ +import json +from asyncio import ( + AbstractEventLoop, + Semaphore, + Task, + gather, + get_event_loop, +) +from collections.abc import ( + AsyncIterator, + Callable, +) +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any, Protocol, Self + +from dishka import AsyncContainer +from nats.aio.msg import Msg as NatsMessage +from nats.errors import TimeoutError as NatsTimeoutError +from nats.js import JetStreamContext +from structlog.types import FilteringBoundLogger + +from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.dishka.next_container import NextContainer +from ttt.infrastructure.structlog.logger import ( + unexpected_error_log, +) + + +class PullSubscribe(Protocol): + async def __call__( + self, js: JetStreamContext, subject: str, + ) -> JetStreamContext.PullSubscription: ... + + +class NatsRemoteFuncBody[**PmT](Protocol): + async def __call__( + self, + container: AsyncContainer, + *args: PmT.args, + **kwargs: PmT.kwargs, + ) -> Any: ... # noqa: ANN401 + + +@dataclass +class NatsRemoteFunc[**PmT = ...]: + body: NatsRemoteFuncBody[PmT] + _pull_subscribe: PullSubscribe + _subject: str + + _max_workers: int | None = None + _pull_consume_batch: int | None = None + _pull_consume_timeout: float | None = None + + _js: JetStreamContext = field(init=False) + _pull_subscription: JetStreamContext.PullSubscription = field(init=False) + _workers: set[Task[None]] = field(init=False, default_factory=set) + _semaphore: Semaphore = field(init=False, default_factory=Semaphore) + _container: AsyncContainer = field(init=False) + _loop: AbstractEventLoop = field(init=False) + + @asynccontextmanager + async def startup( + self, + js: JetStreamContext, + max_workers: int | None = None, + pull_consume_batch: int | None = None, + pull_consume_timeout: float | None = None, + ) -> AsyncIterator[Self]: + self._loop = get_event_loop() + self._max_workers = max_workers or self._max_workers or 1000 + self._pull_consume_batch = ( + pull_consume_batch or self._pull_consume_batch or 1 + ) + self._pull_consume_timeout = ( + pull_consume_timeout or self._pull_consume_timeout or 5 + ) + + self._js = js + self._pull_subscription = await self._pull_subscribe( + self._js, + self._subject, + ) + self._semaphore = Semaphore(self._max_workers) + + try: + yield self + finally: + await gather(*self._workers) + + async def __call__(self, *args: PmT.args, **kwargs: PmT.kwargs) -> None: + payload = {"args": args, "kwargs": kwargs} + + await not_none(self._js).publish( + self._subject, + payload=json.dumps(payload).encode(), + ) + + async def processor(self, next_container: NextContainer) -> None: + while True: + try: + messages = await self._pull_subscription.fetch( + batch=not_none(self._pull_consume_batch), + timeout=self._pull_consume_timeout, + ) + except NatsTimeoutError: + continue + + for message in messages: + await self._create_worker(message, next_container) + + async def _create_worker( + self, message: NatsMessage, next_container: NextContainer, + ) -> None: + await self._semaphore.acquire() + task = self._loop.create_task(self._worker(message, next_container)) + self._workers.add(task) + task.add_done_callback(self._workers.discard) + + async def _worker( + self, message: NatsMessage, next_container: NextContainer, + ) -> None: + async with next_container() as container: + try: + json_str = message.data.decode() + json_ = json.loads(json_str) + await self.body(container, *json_["args"], **json_["kwargs"]) + except Exception as error: # noqa: BLE001 + await message.nak() + + logger = await container.get(FilteringBoundLogger) + await unexpected_error_log(logger, error) + + self._semaphore.release() + except BaseException as error: + await message.nak() + self._semaphore.release() + raise error from error + else: + await message.ack() + self._semaphore.release() + + +def nats_remote[**PmT]( + subject: str, + pull_subscribe: PullSubscribe, + max_workers: int | None = None, + pull_consume_batch: int | None = None, + pull_consume_timeout: float | None = None, +) -> Callable[[NatsRemoteFuncBody[PmT]], NatsRemoteFunc[PmT]]: + def decorator(body: NatsRemoteFuncBody[PmT]) -> NatsRemoteFunc[PmT]: + return NatsRemoteFunc( + body, + pull_subscribe, + subject, + max_workers, + pull_consume_batch, + pull_consume_timeout, + ) + + return decorator diff --git a/src/ttt/infrastructure/taskiq/broker.py b/src/ttt/infrastructure/taskiq/broker.py deleted file mode 100644 index 9013db2..0000000 --- a/src/ttt/infrastructure/taskiq/broker.py +++ /dev/null @@ -1,174 +0,0 @@ -from asyncio import Queue, TaskGroup, gather -from collections.abc import AsyncGenerator, Awaitable, Callable -from dataclasses import dataclass, field -from typing import Any, overload - -from nats.aio.msg import Msg as NatsMessage -from nats.errors import TimeoutError as NatsTimeoutError -from nats.js import JetStreamContext -from taskiq import ( - AckableMessage, - AsyncBroker, - AsyncTaskiqDecoratedTask, - BrokerMessage, -) - - -@dataclass(frozen=True) -class PullSubscribe: - _func: Callable[ - [JetStreamContext, str], Awaitable[JetStreamContext.PullSubscription], - ] - - async def __call__( - self, js: JetStreamContext, subject: str, - ) -> JetStreamContext.PullSubscription: - return await self._func(js, subject) - - -@dataclass -class NatsQueue: - _subject: str - _pull_subscribe: PullSubscribe - _pull_consume_batch: int - _pull_consume_timeout: float | None - - _js: JetStreamContext = field(init=False) - _pull_subscription: JetStreamContext.PullSubscription = field(init=False) - - async def startup(self, js: JetStreamContext) -> None: - self._js = js - self._pull_subscription = await self._pull_subscribe(js, self._subject) - - async def push(self, message: BrokerMessage) -> None: - await self._js.publish( - self._subject, - payload=message.message, - headers=message.labels, - ) - - async def pull_to(self, output: Queue[AckableMessage]) -> None: - nats_messages: list[NatsMessage] - - while True: - try: - nats_messages = await self._pull_subscription.fetch( - batch=self._pull_consume_batch, - timeout=self._pull_consume_timeout, - ) - except NatsTimeoutError: - continue - - ackable_messages = ( - AckableMessage( - data=nats_message.data, - ack=nats_message.ack, - ) - for nats_message in nats_messages - ) - await gather(*( - output.put(ackable_message) - for ackable_message in ackable_messages - )) - - -class NatsBroker(AsyncBroker): - js: JetStreamContext - pulling_queue: Queue[AckableMessage] - - def __init__( - self, - default_subject: str = "taskiq.>", - default_pull_subscribe: PullSubscribe | None = None, - default_pull_consume_batch: int = 1, - default_pull_consume_timeout: float | None = 5, - ) -> None: - super().__init__() - self._default_subject = default_subject - self._default_pull_subscribe = ( - default_pull_subscribe - or (lambda js, sub: js.pull_subscribe( - sub, - "taskiq", - "TASKIQ", - )) - ) - self._default_pull_consume_batch = default_pull_consume_batch - self._default_pull_consume_timeout = default_pull_consume_timeout - self._queue_by_task_name = dict[str, NatsQueue]() - - @overload - def task[**PmT, RT]( - self, - task_name: Callable[PmT, RT], - **labels: Any, # noqa: ANN401 - ) -> AsyncTaskiqDecoratedTask[PmT, RT]: - ... - - @overload - def task[**PmT, RT]( - self, - task_name: str | None = None, - **labels: Any, # noqa: ANN401 - ) -> Callable[ - [Callable[PmT, RT]], - AsyncTaskiqDecoratedTask[PmT, RT], - ]: - ... - - def task[**PmT, RT]( - self, - task_name: str | Callable[PmT, RT] | None = None, - **labels: Any, - ) -> Any: - subject = labels.pop("subject", self._default_subject) - pull_subscribe = labels.pop( - "pull_subscribe", self._default_pull_subscribe, - ) - pull_consume_batch = labels.pop( - "pull_consume_batch", self._default_pull_consume_batch, - ) - pull_consume_timeout = labels.pop( - "pull_consume_timeout", self._default_pull_consume_timeout, - ) - queue = NatsQueue( - subject, - pull_subscribe, - pull_consume_batch, - pull_consume_timeout, - ) - - result = super().task(task_name, **labels) - - if isinstance(result, AsyncTaskiqDecoratedTask): - self._queue_by_task_name[result.task_name] = queue - return result - - def decorator( - func: Callable[PmT, RT], - ) -> AsyncTaskiqDecoratedTask[PmT, RT]: - task = result(func) - self._queue_by_task_name[task.task_name] = queue - return task - - return decorator - - async def startup(self) -> None: - await super().startup() - await gather(*( - queue.startup(self.js) - for queue in self._queue_by_task_name.values() - )) - - async def kick(self, message: BrokerMessage) -> None: - await self._queue_by_task_name[message.task_name].push(message) - - async def listen(self) -> AsyncGenerator[AckableMessage]: - async with TaskGroup() as pulling_tasks: - for nats_queue in self._queue_by_task_name.values(): - pulling_tasks.create_task( - nats_queue.pull_to(self.pulling_queue), - ) - - while True: - yield await self.pulling_queue.get() diff --git a/src/ttt/infrastructure/taskiq/middlewares.py b/src/ttt/infrastructure/taskiq/middlewares.py deleted file mode 100644 index 4005d05..0000000 --- a/src/ttt/infrastructure/taskiq/middlewares.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import Any - -from dishka.integrations.taskiq import CONTAINER_NAME -from taskiq import TaskiqMessage, TaskiqMiddleware, TaskiqResult - -from ttt.infrastructure.dishka.next_container import NextContainer - - -class TaskiqNextContainerMiddleware(TaskiqMiddleware): - def __init__(self, next_container: NextContainer) -> None: - super().__init__() - self._next_container = next_container - - async def pre_execute( - self, - message: TaskiqMessage, - ) -> TaskiqMessage: - next_container = self._next_container({TaskiqMessage: message}) - - container = await next_container.__aenter__() # noqa: PLC2801 - message.labels[CONTAINER_NAME] = container - return message - - async def on_error( - self, - message: TaskiqMessage, # noqa: ARG002 - result: TaskiqResult[Any], - exception: BaseException, # noqa: ARG002 - ) -> None: - if CONTAINER_NAME in result.labels: - await result.labels[CONTAINER_NAME].close() - del result.labels[CONTAINER_NAME] - - async def post_execute( - self, - message: TaskiqMessage, # noqa: ARG002 - result: TaskiqResult[Any], - ) -> None: - if CONTAINER_NAME in result.labels: - await result.labels[CONTAINER_NAME].close() - del result.labels[CONTAINER_NAME] diff --git a/src/ttt/infrastructure/taskiq/tasks/__init__.py b/src/ttt/infrastructure/taskiq/tasks/__init__.py deleted file mode 100644 index 155670e..0000000 --- a/src/ttt/infrastructure/taskiq/tasks/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from ttt.infrastructure.taskiq.tasks.common import nats_tasks -from ttt.infrastructure.taskiq.tasks.complete_stars_purchase_payment_task import ( # noqa: E501 - complete_stars_purchase_payment_task, -) -from ttt.infrastructure.taskiq.tasks.make_ai_move_in_game_task import ( - make_ai_move_in_game_task, -) diff --git a/src/ttt/infrastructure/taskiq/tasks/common.py b/src/ttt/infrastructure/taskiq/tasks/common.py deleted file mode 100644 index 581166d..0000000 --- a/src/ttt/infrastructure/taskiq/tasks/common.py +++ /dev/null @@ -1,4 +0,0 @@ -from ttt.infrastructure.taskiq.broker import NatsBroker - - -nats_tasks = NatsBroker() diff --git a/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py b/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py deleted file mode 100644 index f9b0dc0..0000000 --- a/src/ttt/infrastructure/taskiq/tasks/complete_stars_purchase_payment_task.py +++ /dev/null @@ -1,34 +0,0 @@ -from uuid import UUID - -from dishka.integrations.taskiq import FromDishka, inject - -from ttt.application.stars_purchase.complete_stars_purchase_payment import ( - CompleteStarsPurchasePayment, -) -from ttt.entities.finance.payment.success import PaymentSuccess -from ttt.infrastructure.retrier import Retrier -from ttt.infrastructure.taskiq.broker import PullSubscribe -from ttt.infrastructure.taskiq.tasks.common import nats_tasks - - -@nats_tasks.task( - task_name="stars_purchase-stars_purchase-complete_stars_purchase_payment", - subject="stars_purchase.stars_purchase.complete_stars_purchase_payment", - pull_subscribe=PullSubscribe(lambda js, subject: js.pull_subscribe( - subject, - "ttt-stars_purchase-stars_purchase-complete_stars_purchase_payment", - stream="STARS_PURCHASE", - )), -) -@inject(patch_module=True) -async def complete_stars_purchase_payment_task( - purchase_id: UUID, - payment_success_id: str, - payment_success_gateway_id: str, - complete_stars_purchase_payment: FromDishka[CompleteStarsPurchasePayment], - retrier: FromDishka[Retrier], -) -> None: - payment_success = PaymentSuccess( - payment_success_id, payment_success_gateway_id, - ) - await retrier(complete_stars_purchase_payment, purchase_id, payment_success) diff --git a/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py b/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py deleted file mode 100644 index 596d0b1..0000000 --- a/src/ttt/infrastructure/taskiq/tasks/make_ai_move_in_game_task.py +++ /dev/null @@ -1,28 +0,0 @@ -from uuid import UUID - -from dishka.integrations.taskiq import FromDishka, inject - -from ttt.application.game.game.make_ai_move_in_game import MakeAiMoveInGame -from ttt.infrastructure.retrier import Retrier -from ttt.infrastructure.taskiq.broker import PullSubscribe -from ttt.infrastructure.taskiq.tasks.common import nats_tasks - - -@nats_tasks.task( - task_name="game-game-make_ai_move_in_game", - subject="game.game.make_ai_move_in_game", - pull_subscribe=PullSubscribe(lambda js, subject: js.pull_subscribe( - subject, - durable="ttt-game-game-make_ai_move_in_game", - stream="GAME", - )), -) -@inject(patch_module=True) -async def make_ai_move_in_game_task( - user_id: int, - game_id: UUID, - ai_id: UUID, - make_ai_move_in_game: FromDishka[MakeAiMoveInGame], - retrier: FromDishka[Retrier], -) -> None: - await retrier(make_ai_move_in_game, user_id, game_id, ai_id) diff --git a/src/ttt/infrastructure/taskiq/worker.py b/src/ttt/infrastructure/taskiq/worker.py deleted file mode 100644 index b6e157b..0000000 --- a/src/ttt/infrastructure/taskiq/worker.py +++ /dev/null @@ -1,36 +0,0 @@ -from asyncio import Event, TaskGroup, gather -from dataclasses import dataclass, field -from types import TracebackType -from typing import Self - -from taskiq.receiver import Receiver - - -@dataclass -class TaskiqBgWorker: - _receivers: tuple[Receiver, ...] - - _task_group: TaskGroup = field(init=False, default_factory=TaskGroup) - _is_finished: Event = field(init=False, default_factory=Event) - - async def __aenter__(self) -> Self: - await self._task_group.__aenter__() - return self - - async def __call__(self) -> None: - await gather(*( - receiver.broker.startup() - for receiver in self._receivers - )) - - for receiver in self._receivers: - self._task_group.create_task(receiver.listen(self._is_finished)) - - async def __aexit__( - self, - error_type: type[BaseException] | None, - error: BaseException | None, - traceback: TracebackType | None, - ) -> None: - self._is_finished.set() - await self._task_group.__aexit__(error_type, error, traceback) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 98294bb..9d2d8b8 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -67,7 +67,7 @@ from ttt.infrastructure.adapters.game_ai_gateway import GeminiGameAiGateway from ttt.infrastructure.adapters.game_dao import PostgresGameDao from ttt.infrastructure.adapters.game_log import StructlogGameLog -from ttt.infrastructure.adapters.game_tasks import TaskiqGameTasks +from ttt.infrastructure.adapters.game_tasks import NatsRemoteFuncGameTasks from ttt.infrastructure.adapters.games import InPostgresGames from ttt.infrastructure.adapters.invitation_to_game_dao import ( PostgresInvitationToGameDao, @@ -88,7 +88,7 @@ StructlogStarsPurchaseLog, ) from ttt.infrastructure.adapters.stars_purchase_tasks import ( - TaskiqStarsPurchaseTasks, + NatsRemoteFuncStarsPurchaseTasks, ) from ttt.infrastructure.adapters.stars_purchases import PostgresStarsPurchases from ttt.infrastructure.adapters.transaction import ( @@ -106,18 +106,24 @@ ) from ttt.infrastructure.adapters.users import InPostgresUsers from ttt.infrastructure.adapters.uuids import UUIDv4s +from ttt.infrastructure.multi_asynccontextmanager import ( + multi_asynccontextmanager, +) from ttt.infrastructure.openai.gemini import Gemini, gemini from ttt.infrastructure.processors.auto_cancel_invitations_to_game_processor import ( # noqa: E501 AutoCancelInvitationsToGameProcessor, ) from ttt.infrastructure.processors.matchmake_processor import MatchmakeProcessor -from ttt.infrastructure.processors.processors import Processors +from ttt.infrastructure.processors.processor import Processor from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets +from ttt.infrastructure.remote_funcs.complete_stars_purchase_payment import ( + complete_stars_purchase_payment_remotely, +) +from ttt.infrastructure.remote_funcs.make_ai_move_in_game import ( + make_ai_move_in_game_remotely, +) from ttt.infrastructure.retrier import Retrier -from ttt.infrastructure.taskiq.broker import NatsBroker -from ttt.infrastructure.taskiq.tasks.common import nats_tasks -from ttt.infrastructure.taskiq.worker import TaskiqBgWorker class InfrastructureProvider(Provider): @@ -189,22 +195,6 @@ async def provide_nats( async def provide_jetstream(self, nats: Nats) -> JetStreamContext: return nats.jetstream() - @provide(scope=Scope.APP) - async def provide_nats_broker( - self, js: JetStreamContext, - ) -> NatsBroker: - nats_tasks.js = js - nats_tasks.pulling_queue = Queue() - - return nats_tasks - - @provide(scope=Scope.APP) - async def provide_taskiq_bg_worker( - self, nats_broker: NatsBroker, - ) -> AsyncIterator[TaskiqBgWorker]: - async with TaskiqBgWorker((Receiver(nats_broker), )) as worker: - yield worker - @provide(scope=Scope.APP) def provide_gemini(self, secrets: Secrets, envs: Envs) -> Gemini: return gemini(secrets.gemini_api_key, envs.gemini_url) @@ -348,12 +338,12 @@ def provide_randoms(self) -> Randoms: ) provide_game_tasks = provide( - TaskiqGameTasks, + NatsRemoteFuncGameTasks, provides=GameTasks, scope=Scope.APP, ) provide_stars_purchase_tasks = provide( - TaskiqStarsPurchaseTasks, + NatsRemoteFuncStarsPurchaseTasks, provides=StarsPurchaseTasks, scope=Scope.APP, ) @@ -396,12 +386,26 @@ def provide_matchmake_processor( @provide(scope=Scope.APP) async def processors( self, + js: JetStreamContext, auto_cancel_invitations_to_game_processor: ( AutoCancelInvitationsToGameProcessor ), matchmake_processor: MatchmakeProcessor, - ) -> Processors: - return Processors(( - auto_cancel_invitations_to_game_processor, - matchmake_processor, + ) -> AsyncIterator[tuple[Processor, ...]]: + nats_remote_funcs = ( + make_ai_move_in_game_remotely, + complete_stars_purchase_payment_remotely, + ) + multi_startup = multi_asynccontextmanager(*( + func.startup(js) for func in nats_remote_funcs )) + async with multi_startup: + nats_remote_func_processors = ( + func.processor + for func in nats_remote_funcs + ) + yield ( + auto_cancel_invitations_to_game_processor, + matchmake_processor, + *nats_remote_func_processors, + ) diff --git a/src/ttt/main/tg_bot/start_tg_bot.py b/src/ttt/main/tg_bot/start_tg_bot.py index a1835e1..65a2a2d 100644 --- a/src/ttt/main/tg_bot/start_tg_bot.py +++ b/src/ttt/main/tg_bot/start_tg_bot.py @@ -8,10 +8,7 @@ from dishka.integrations.aiogram import AiogramMiddlewareData from taskiq import TaskiqMessage -from ttt.infrastructure.processors.processors import Processors -from ttt.infrastructure.taskiq.broker import NatsBroker -from ttt.infrastructure.taskiq.middlewares import TaskiqNextContainerMiddleware -from ttt.infrastructure.taskiq.worker import TaskiqBgWorker +from ttt.infrastructure.processors.processor import Processor from ttt.main.common.next_container import NextContainerWithFilledContext from ttt.presentation.aiogram.common.middlewares import ( AiogramNextContainerMiddleware, @@ -29,11 +26,7 @@ async def start_tg_bot(container: AsyncContainer) -> None: for observer in dp.observers.values(): observer.middleware(middleware) - nats_broker = await container.get(NatsBroker) - nats_broker.add_middlewares(TaskiqNextContainerMiddleware(next_container)) - - taskiq_bg_worker = await container.get(TaskiqBgWorker) - processors = await container.get(Processors) + processors = await container.get(tuple[Processor, ...]) bot = await container.get(Bot) logging.basicConfig(level=logging.INFO) @@ -41,8 +34,8 @@ async def start_tg_bot(container: AsyncContainer) -> None: try: with suppress(CancelledError): async with TaskGroup() as tasks: - tasks.create_task(processors(next_container)) - await taskiq_bg_worker() + for processor in processors: + tasks.create_task(processor(next_container)) await dp.start_polling(bot) raise CancelledError finally: diff --git a/uv.lock b/uv.lock index 0816c7e..e59404d 100644 --- a/uv.lock +++ b/uv.lock @@ -395,18 +395,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, -] - [[package]] name = "in-memory-db" version = "0.3.0" @@ -425,15 +413,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, ] -[[package]] -name = "izulu" -version = "0.50.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/58/6d6335c78b7ade54d8a6c6dbaa589e5c21b3fd916341d5a16f774c72652a/izulu-0.50.0.tar.gz", hash = "sha256:cc8e252d5e8560c70b95380295008eeb0786f7b745a405a40d3556ab3252d5f5", size = 48558, upload-time = "2025-03-24T15:52:21.51Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/9f/bf9d33546bbb6e5e80ebafe46f90b7d8b4a77410b7b05160b0ca8978c15a/izulu-0.50.0-py3-none-any.whl", hash = "sha256:4e9ae2508844e7c5f62c468a8b9e2deba2f60325ef63f01e65b39fd9a6b3fab4", size = 18095, upload-time = "2025-03-24T15:52:19.667Z" }, -] - [[package]] name = "jinja2" version = "3.1.6" @@ -788,15 +767,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] -[[package]] -name = "pycron" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/5d/340be12ae4a69c33102dfb6ddc1dc6e53e69b2d504fa26b5d34a472c3057/pycron-3.2.0.tar.gz", hash = "sha256:e125a28aca0295769541a40633f70b602579df48c9cb357c36c28d2628ba2b13", size = 4248, upload-time = "2025-06-05T13:24:12.636Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/76/caf316909f4545e7158e0e1defd8956a1da49f4af04f5d16b18c358dfeac/pycron-3.2.0-py3-none-any.whl", hash = "sha256:6d2349746270bd642b71b9f7187cf13f4d9ee2412b4710396a507b5fe4f60dac", size = 4904, upload-time = "2025-06-05T13:24:11.477Z" }, -] - [[package]] name = "pydantic" version = "2.10.6" @@ -934,15 +904,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, ] -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, -] - [[package]] name = "pyyaml" version = "6.0.2" @@ -1078,35 +1039,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e3/ba/1b1226db704c16426d59e1fcbeaf5138dbc5a50d424aab92b453d33add47/structlog_sentry-2.2.1-py3-none-any.whl", hash = "sha256:868c82348bc18b7d51ef8f878c57e82835c1758dba38f7589fa02a267096a95d", size = 11218, upload-time = "2024-11-12T14:31:50.594Z" }, ] -[[package]] -name = "taskiq" -version = "0.11.18" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "importlib-metadata" }, - { name = "izulu" }, - { name = "packaging" }, - { name = "pycron" }, - { name = "pydantic" }, - { name = "pytz" }, - { name = "taskiq-dependencies" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/91/4d/0d1b3b6c77a45d7a8c685a9c916b2532cca36a26771831949b874f6d15c3/taskiq-0.11.18.tar.gz", hash = "sha256:b83e1b70aee74d0a197d4a4a5ba165b8ba85b12a2b3b7ebfa3c6fdcc9e3128a7", size = 54323, upload-time = "2025-07-15T16:25:54.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/d5/46505f57c140d10d4c36f6bd2f2047fb0460e4d5b9b841dc3b93ab8c893d/taskiq-0.11.18-py3-none-any.whl", hash = "sha256:0df58be24e4ef5d19c8ef02581d35d392b0d780d3fe37950e0478022b85ce288", size = 79608, upload-time = "2025-07-15T16:25:52.707Z" }, -] - -[[package]] -name = "taskiq-dependencies" -version = "1.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/90/47a627696e53bfdcacabc3e8c05b73bf1424685bcb5f17209cb8b12da1bf/taskiq_dependencies-1.5.7.tar.gz", hash = "sha256:0d3b240872ef152b719153b9526d866d2be978aeeaea6600e878414babc2dcb4", size = 14875, upload-time = "2025-02-26T22:07:39.876Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/6d/4a012f2de002c2e93273f5e7d3e3feea02f7fdbb7b75ca2ca1dd10703091/taskiq_dependencies-1.5.7-py3-none-any.whl", hash = "sha256:6fcee5d159bdb035ef915d4d848826169b6f06fe57cc2297a39b62ea3e76036f", size = 13801, upload-time = "2025-02-26T22:07:38.622Z" }, -] - [[package]] name = "tqdm" version = "4.67.1" @@ -1139,7 +1071,6 @@ dependencies = [ { name = "sqlalchemy" }, { name = "structlog" }, { name = "structlog-sentry" }, - { name = "taskiq" }, ] [package.dev-dependencies] @@ -1170,7 +1101,6 @@ requires-dist = [ { name = "sqlalchemy", specifier = "==2.0.41" }, { name = "structlog", specifier = "==25.4.0" }, { name = "structlog-sentry", specifier = "==2.2.1" }, - { name = "taskiq", specifier = "==0.11.18" }, ] [package.metadata.requires-dev] @@ -1270,12 +1200,3 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ] - -[[package]] -name = "zipp" -version = "3.23.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, -] From fb067ecdccb66b1de015d12dcd8b0fbc0586ab13 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Fri, 26 Sep 2025 20:06:16 +0700 Subject: [PATCH 35/45] fix(`di`): fix `ruff` errors (#62) --- src/ttt/main/common/di.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 9d2d8b8..4032f47 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -1,4 +1,3 @@ -from asyncio import Queue from collections.abc import AsyncIterator from typing import Annotated @@ -13,7 +12,6 @@ create_async_engine, ) from structlog.types import FilteringBoundLogger -from taskiq.receiver import Receiver from ttt.application.common.errors.serialization_error import SerializationError from ttt.application.common.ports.clock import Clock From 3d7f34539e8e3fe7bee3b93c6573a7fd6d160129 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Fri, 26 Sep 2025 20:14:18 +0700 Subject: [PATCH 36/45] fix(`start_tg_bot`): fix `mypy` errors (#62) --- src/ttt/main/tg_bot/start_tg_bot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ttt/main/tg_bot/start_tg_bot.py b/src/ttt/main/tg_bot/start_tg_bot.py index 65a2a2d..72a76f1 100644 --- a/src/ttt/main/tg_bot/start_tg_bot.py +++ b/src/ttt/main/tg_bot/start_tg_bot.py @@ -6,7 +6,6 @@ from aiogram.types import TelegramObject from dishka import AsyncContainer from dishka.integrations.aiogram import AiogramMiddlewareData -from taskiq import TaskiqMessage from ttt.infrastructure.processors.processor import Processor from ttt.main.common.next_container import NextContainerWithFilledContext @@ -18,7 +17,7 @@ async def start_tg_bot(container: AsyncContainer) -> None: next_container = NextContainerWithFilledContext( container, - (TelegramObject, AiogramMiddlewareData, TaskiqMessage), + (TelegramObject, AiogramMiddlewareData), ) dp = await container.get(Dispatcher) From 28d6546e257e1aae6e76163709e84c4c749b8e49 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Sun, 5 Oct 2025 19:05:19 +0700 Subject: [PATCH 37/45] feat(`invitation_to_game`): use `username`s (#53) --- .../invitation_to_game/game/invite_to_game.py | 10 +- .../invitation_to_game/invitation_to_game.py | 6 + ...add_username_columns_to_invitations_to_.py | 39 ++++++ .../sqlalchemy/tables/invitation_to_game.py | 6 + .../adapters/invitation_to_game_views.py | 45 +++++-- .../common/wigets/users_request.py | 61 +++++++++ .../incoming_invitation_to_game_window.py | 13 +- .../incoming_invitations_to_game_window.py | 39 +++--- .../outcoming_invitations_to_game_window.py | 122 +++++++++++------- 9 files changed, 261 insertions(+), 80 deletions(-) create mode 100644 src/ttt/infrastructure/alembic/versions/dc1b9f720a1d_add_username_columns_to_invitations_to_.py create mode 100644 src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py diff --git a/src/ttt/application/invitation_to_game/game/invite_to_game.py b/src/ttt/application/invitation_to_game/game/invite_to_game.py index e53fd1d..363be5b 100644 --- a/src/ttt/application/invitation_to_game/game/invite_to_game.py +++ b/src/ttt/application/invitation_to_game/game/invite_to_game.py @@ -34,7 +34,13 @@ class InviteToGame: views: InvitationToGameViews log: InvitationToGameLog - async def __call__(self, user_id: int, invited_user_id: int) -> None: + async def __call__( + self, + user_id: int, + user_username: str | None, + invited_user_id: int, + invited_user_username: str | None, + ) -> None: """ :raises ttt.application.common.errors.serialization_error.SerializationError: """ # noqa: E501 @@ -61,8 +67,10 @@ async def __call__(self, user_id: int, invited_user_id: int) -> None: tracking = Tracking() invitation_to_game = invite_to_game( user, + user_username, invited_user, invited_user_id, + invited_user_username, invitation_to_game_id, current_datetime, tracking, diff --git a/src/ttt/entities/core/invitation_to_game/invitation_to_game.py b/src/ttt/entities/core/invitation_to_game/invitation_to_game.py index 8462c75..74d2d84 100644 --- a/src/ttt/entities/core/invitation_to_game/invitation_to_game.py +++ b/src/ttt/entities/core/invitation_to_game/invitation_to_game.py @@ -41,7 +41,9 @@ class InvitationToGame: id_: UUID inviting_user: User + inviting_user_username: str | None invited_user: User + invited_user_username: str | None invitation_datetime: datetime state: InvitationToGameState @@ -155,8 +157,10 @@ def invitation_to_game_datetime(expiration_datetime: datetime) -> datetime: def invite_to_game( # noqa: PLR0913, PLR0917 user: User, + user_username: str | None, invited_user: User | None, invited_user_id: int, + invited_user_username: str | None, invitation_to_game_id: UUID, current_datetime: datetime, tracking: Tracking, @@ -170,7 +174,9 @@ def invite_to_game( # noqa: PLR0913, PLR0917 invitation_to_game = InvitationToGame( id_=invitation_to_game_id, + inviting_user_username=user_username, inviting_user=user, + invited_user_username=invited_user_username, invited_user=invited_user, invitation_datetime=current_datetime, state=InvitationToGameState.active, diff --git a/src/ttt/infrastructure/alembic/versions/dc1b9f720a1d_add_username_columns_to_invitations_to_.py b/src/ttt/infrastructure/alembic/versions/dc1b9f720a1d_add_username_columns_to_invitations_to_.py new file mode 100644 index 0000000..0bad67a --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/dc1b9f720a1d_add_username_columns_to_invitations_to_.py @@ -0,0 +1,39 @@ +""" +add `username` columns to `invitations_to_game`. + +Revision ID: dc1b9f720a1d +Revises: 2dcb2be9e277 +Create Date: 2025-10-05 09:44:49.062023 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = "dc1b9f720a1d" +down_revision: str | None = "2dcb2be9e277" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "invitations_to_game", + sa.Column("inviting_user_username", sa.String(), nullable=True), + ) + op.add_column( + "invitations_to_game", + sa.Column("invited_user_username", sa.String(), nullable=True), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("invitations_to_game", "invited_user_username") + op.drop_column("invitations_to_game", "inviting_user_username") + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py b/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py index ab30e4f..034e318 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py @@ -75,6 +75,7 @@ class TableInvitationToGame(Base[InvitationToGame]): ), index=True, ) + inviting_user_username: Mapped[str | None] invited_user_id: Mapped[int] = mapped_column( ForeignKey( "users.id", @@ -83,6 +84,7 @@ class TableInvitationToGame(Base[InvitationToGame]): ), index=True, ) + invited_user_username: Mapped[str | None] invitation_datetime: Mapped[datetime] = mapped_column() state: Mapped[TableInvitationToGameState] = mapped_column( invitation_to_game_state, @@ -117,6 +119,8 @@ def __entity__(self) -> InvitationToGame: invited_user=self.invited_user.entity(), invitation_datetime=self.invitation_datetime, state=self.state.entity(), + inviting_user_username=self.inviting_user_username, + invited_user_username=self.invited_user_username, ) @classmethod @@ -127,6 +131,8 @@ def of(cls, it: InvitationToGame) -> "TableInvitationToGame": invited_user_id=it.invited_user.id, invitation_datetime=it.invitation_datetime, state=TableInvitationToGameState.of(it.state), + inviting_user_username=it.inviting_user_username, + invited_user_username=it.invited_user_username, ) diff --git a/src/ttt/presentation/adapters/invitation_to_game_views.py b/src/ttt/presentation/adapters/invitation_to_game_views.py index 7c3cb6d..609b596 100644 --- a/src/ttt/presentation/adapters/invitation_to_game_views.py +++ b/src/ttt/presentation/adapters/invitation_to_game_views.py @@ -61,6 +61,7 @@ async def incoming_user_invitations_to_game_view( select( TableInvitationToGame.id, TableInvitationToGame.inviting_user_id, + TableInvitationToGame.inviting_user_username, ) .where( (TableInvitationToGame.invited_user_id == user_id) @@ -74,7 +75,11 @@ async def incoming_user_invitations_to_game_view( rows = result.all() invitations = [ - IncomingInvitationToGameData(row.id, row.inviting_user_id) + IncomingInvitationToGameData( + row.id, + row.inviting_user_id, + row.inviting_user_username, + ) for row in rows ] self._result_buffer.result = IncomingInvitationsToGameView.of( @@ -90,6 +95,7 @@ async def outcoming_user_invitations_to_game_view( select( TableInvitationToGame.id, TableInvitationToGame.invited_user_id, + TableInvitationToGame.invited_user_username, ) .where( (TableInvitationToGame.inviting_user_id == user_id) @@ -103,7 +109,11 @@ async def outcoming_user_invitations_to_game_view( rows = result.all() invitations = [ - OutcomingInvitationToGameData(row.id.hex, row.invited_user_id) + OutcomingInvitationToGameData( + row.id.hex, + row.invited_user_id, + row.invited_user_username, + ) for row in rows ] self._result_buffer.result = OutcomingInvitationsToGameView.of( @@ -117,6 +127,7 @@ async def one_incoming_invitation_to_game_view( select( TableInvitationToGame.id, TableInvitationToGame.inviting_user_id, + TableInvitationToGame.inviting_user_username, ) .where( (TableInvitationToGame.invited_user_id == user_id) @@ -137,13 +148,17 @@ async def one_incoming_invitation_to_game_view( self._result_buffer.result = IncomingInvitationToGameView( id_hex=row.id.hex, inviting_user_id=row.inviting_user_id, + inviting_user_username=row.inviting_user_username, ) async def incoming_invitation_to_game_view( self, user_id: int, invitation_to_game_id: UUID, /, ) -> None: stmt = ( - select(TableInvitationToGame.inviting_user_id) + select( + TableInvitationToGame.inviting_user_id, + TableInvitationToGame.inviting_user_username, + ) .where( (TableInvitationToGame.id == invitation_to_game_id) & ( @@ -152,15 +167,17 @@ async def incoming_invitation_to_game_view( ), ) ) - inviting_user_id = await self._session.scalar(stmt) + result = await self._session.execute(stmt) + row = result.first() - if inviting_user_id is None: + if row is None: self._result_buffer.result = None return self._result_buffer.result = IncomingInvitationToGameView( id_hex=invitation_to_game_id.hex, - inviting_user_id=inviting_user_id, + inviting_user_id=row.inviting_user_id, + inviting_user_username=row.inviting_user_username, ) async def invitation_to_game_view( @@ -195,6 +212,7 @@ async def _invited_user_to_game_view( view = IncomingInvitationToGameView( id_hex=invitation_to_game.id_.hex, inviting_user_id=invitation_to_game.inviting_user.id, + inviting_user_username=invitation_to_game.inviting_user_username, ) start_data = view.window_data() await manager.start( @@ -230,17 +248,20 @@ async def rejected_invitation_to_game_view( invitation_to_game.inviting_user.id, ) - inviting_user_hint = Text( - "👤 Пользователь ", - Code(invitation_to_game.invited_user.id), - " отклонил ваше приглашение к игре", - ).as_html() + if invitation_to_game.invited_user_username is None: + public_user_id = Code(invitation_to_game.invited_user.id).as_html() + else: + public_user_id = f"@{invitation_to_game.invited_user_username}" + + hint = ( + f"👤 Пользователь {public_user_id} отклонил ваше приглашение к игре" + ) await gather( invited_user_manager.done(), inviting_user_manager.start( MainDialogState.notification, - {"hint": inviting_user_hint}, + {"hint": hint}, ), ) diff --git a/src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py b/src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py new file mode 100644 index 0000000..9677376 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py @@ -0,0 +1,61 @@ +from collections.abc import Awaitable, Callable +from typing import Any, Optional, Union + +from aiogram.types import ( + CallbackQuery, + InlineKeyboardButton, + KeyboardButton, + KeyboardButtonRequestUsers, + LoginUrl, + SwitchInlineQueryChosenChat, + WebAppInfo, +) +from aiogram_dialog.api.internal import RawKeyboard +from aiogram_dialog.api.protocols import DialogManager, DialogProtocol +from aiogram_dialog.widgets.common import WhenCondition +from aiogram_dialog.widgets.kbd import Keyboard +from aiogram_dialog.widgets.kbd.button import OnClick +from aiogram_dialog.widgets.text import Text +from aiogram_dialog.widgets.widget_event import ( + WidgetEventProcessor, +) + + +UsersRequestOnClick = Callable[ + [CallbackQuery, "UsersRequest", DialogManager], Awaitable[Any], +] + + +class UsersRequest(Keyboard): + def __init__( + self, + text: Text, + id: str, # noqa: A002 + criteria: KeyboardButtonRequestUsers, + when: WhenCondition = None, + ) -> None: + super().__init__(id=id, when=when) + self.text = text + self.criteria = criteria + + async def _process_own_callback( + self, + callback: CallbackQuery, + dialog: DialogProtocol, # noqa: ARG002 + manager: DialogManager, + ) -> bool: + return True + + async def _render_keyboard( + self, + data: dict[Any, Any], + manager: DialogManager, + ) -> RawKeyboard: + return [ + [ + KeyboardButton( + text=await self.text.render_text(data, manager), + request_users=self.criteria, + ), + ], + ] diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py index 0c4c70a..623e983 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py @@ -36,6 +36,7 @@ class IncomingInvitationToGameView(EncodableToWindowData): id_hex: str inviting_user_id: int + inviting_user_username: str | None @inject @@ -80,10 +81,14 @@ async def incoming_invitation_to_game_html( # noqa: RUF029 raise TypeError invitation = manager.start_data["main"] - text = Text( - "👤 Приглашение к игре от ", Code(invitation["inviting_user_id"]), - ) - return text.as_html() + + if invitation.get("inviting_user_username") is None: + text = Text( + "👤 Приглашение к игре от ", Code(invitation["inviting_user_id"]), + ) + return text.as_html() + + return f"👤 Приглашение к игре от @{invitation["inviting_user_username"]}" incoming_invitation_to_game_window = Window( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py index c01113b..11587ea 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py @@ -34,21 +34,18 @@ class IncomingInvitationToGameData: id_hex: str inviting_user_id: int + inviting_user_username: str | None @dataclass(frozen=True) class IncomingInvitationsToGameView(EncodableToWindowData): invitations: list[IncomingInvitationToGameData] - need_to_paginate: bool @classmethod def of( cls, invitations: list[IncomingInvitationToGameData], ) -> "IncomingInvitationsToGameView": - return IncomingInvitationsToGameView( - invitations=invitations, - need_to_paginate=len(invitations) > 7, # noqa: PLR2004 - ) + return IncomingInvitationsToGameView(invitations=invitations) @inject @@ -97,35 +94,39 @@ async def on_invitation_selected( # noqa: PLR0913, PLR0917 await manager.start(MainDialogState.incoming_invitation_to_game, start_data) -async def incoming_invitations_to_game_html( # noqa: RUF029 +async def incoming_invitations_to_game_text( # noqa: RUF029 data: dict[str, Any], _: DialogManager, ) -> str: return f"👥 У вас {len(data["main"]["invitations"])} приглашений к игре" +async def incoming_invitation_to_game_text( # noqa: RUF029 + data: dict[str, Any], + _: DialogManager, +) -> str: + invitation = data["item"] + + if invitation.get("inviting_user_username") is None: + return f"От {invitation["inviting_user_id"]}" + + return f"От @{invitation["inviting_user_username"]}" + + incoming_invitations_to_game_window = Window( - FuncText(incoming_invitations_to_game_html), - Select( - Format("От {item[inviting_user_id]}"), - id="n", - items=F["main"]["invitations"], - item_id_getter=lambda it: it["id_hex"], - on_click=on_invitation_selected, - when=~F["main"]["need_to_paginate"], - ), + FuncText(incoming_invitations_to_game_text), ScrollingGroup( Select( - Format("От {item[inviting_user_id]}"), + FuncText(incoming_invitation_to_game_text), id="n", items=F["main"]["invitations"], item_id_getter=lambda it: it["id_hex"], on_click=on_invitation_selected, ), - width=4, - height=4, + width=2, + height=2, + hide_on_single_page=True, id="y", - when=F["main"]["need_to_paginate"], ), SwitchTo( diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py index f92ee97..e431581 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py @@ -1,16 +1,27 @@ from dataclasses import dataclass -from typing import Any +from typing import Any, cast from uuid import UUID +from aiogram import Bot from aiogram.enums import ContentType -from aiogram.types import CallbackQuery, Message, User +from aiogram.types import ( + CallbackQuery, + InlineKeyboardButton, + KeyboardButtonRequestUsers, + Message, + User, +) from aiogram_dialog import DialogManager, ShowMode, StartMode, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import ( + Button, + Group, + ListGroup, ScrollingGroup, Select, SwitchTo, ) +from aiogram_dialog.widgets.markup.reply_keyboard import ReplyKeyboardFactory from aiogram_dialog.widgets.text import Const, Format, Multi from alembic.util import not_none from dishka import FromDishka @@ -26,9 +37,14 @@ ) from ttt.infrastructure.retrier import Retrier from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData +from ttt.presentation.aiogram_dialog.common.wigets.func_text import FuncText +from ttt.presentation.aiogram_dialog.common.wigets.hint import hint from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( OneTimekey, ) +from ttt.presentation.aiogram_dialog.common.wigets.users_request import ( + UsersRequest, +) from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState from ttt.presentation.result_buffer import ResultBuffer @@ -37,21 +53,18 @@ class OutcomingInvitationToGameData: id_hex: str invited_user_id: int + invited_user_username: str | None @dataclass(frozen=True) class OutcomingInvitationsToGameView(EncodableToWindowData): invitations: list[OutcomingInvitationToGameData] - need_to_paginate: bool @classmethod def of( cls, invitations: list[OutcomingInvitationToGameData], ) -> "OutcomingInvitationsToGameView": - return OutcomingInvitationsToGameView( - invitations=invitations, - need_to_paginate=len(invitations) > 7, # noqa: PLR2004 - ) + return OutcomingInvitationsToGameView(invitations=invitations) @inject @@ -85,58 +98,78 @@ async def on_invitation_selected( @inject -async def input_user_id( +async def input_user( message: Message, _: MessageInput, - manager: DialogManager, + __: DialogManager, invite_to_game: FromDishka[InviteToGame], retrier: FromDishka[Retrier], ) -> None: - try: - invited_user_id = int(message.text) # type: ignore[arg-type] - except (ValueError, TypeError): - await manager.start( - MainDialogState.outcoming_invitations_to_game, - {"hint": "👎 ID должен быть целочисленым числом"}, - StartMode.RESET_STACK, - ShowMode.DELETE_AND_SEND, - ) - else: - await retrier( - invite_to_game, not_none(message.from_user).id, invited_user_id, - ) + if message.users_shared is None: + return + + user = not_none(message.from_user) + shared_user = message.users_shared.users[0] + + await retrier( + invite_to_game, + user.id, + user.username, + shared_user.user_id, + shared_user.username, + ) + + +async def title_text( # noqa: RUF029 + data: dict[str, Any], + _: DialogManager, +) -> str: + if data["main"]["invitations"]: + return f"👤 Приглашено {len(data["main"]["invitations"])}" + + return "👤 Никто не приглашён" + + +async def invitation_text( # noqa: RUF029 + data: dict[str, Any], + _: DialogManager, +) -> str: + invited_user_id = data["item"]["invited_user_id"] + invited_user_username = data["item"].get("invited_user_username") + + if invited_user_username is None: + return f"➖ {invited_user_id}" + + return f"➖ @{invited_user_username}" outcoming_invitations_to_game_window = Window( - Multi( - Format("{start_data[hint]}"), - Const(" "), - when=F["start_data"]["hint"], - ), - Const("👤 Введите ID пользователя:"), - MessageInput(input_user_id, content_types=[ContentType.ANY]), - - Select( - Format("❌ {item[invited_user_id]}"), - id="n", - items=F["main"]["invitations"], - item_id_getter=lambda it: it["id_hex"], - on_click=on_invitation_selected, - when=~F["main"]["need_to_paginate"], - ), - ScrollingGroup( + hint(key="hint"), + FuncText(title_text, when=~F["start_data"]["hint"]), + + Group( Select( - Format("❌ {item[invited_user_id]}"), + FuncText(invitation_text), id="n", items=F["main"]["invitations"], item_id_getter=lambda it: it["id_hex"], on_click=on_invitation_selected, ), - width=4, - height=4, - id="y", - when=F["main"]["need_to_paginate"], + id="l", + width=1, + ), + UsersRequest( + Const("➕ Пригласить"), + id="invite_to_game", + criteria=KeyboardButtonRequestUsers( + request_id=4, + user_is_bot=False, + request_name=False, + request_username=True, + request_photo=False, + ), ), + MessageInput(input_user, content_types=[ContentType.ANY]), SwitchTo( Const("Назад"), @@ -144,6 +177,7 @@ async def input_user_id( state=MainDialogState.game_mode_to_start_game, ), OneTimekey("hint"), + markup_factory=ReplyKeyboardFactory(resize_keyboard=True), state=MainDialogState.outcoming_invitations_to_game, getter=getter, ) From e3adf715170e988e69f8a15614276e589561846c Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Sun, 5 Oct 2025 19:08:07 +0700 Subject: [PATCH 38/45] fix(`transaction`): rollback broken transactions on `commit` (#53) --- src/ttt/infrastructure/adapters/transaction.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ttt/infrastructure/adapters/transaction.py b/src/ttt/infrastructure/adapters/transaction.py index 3aa220f..4613203 100644 --- a/src/ttt/infrastructure/adapters/transaction.py +++ b/src/ttt/infrastructure/adapters/transaction.py @@ -45,6 +45,11 @@ async def __aexit__( async def commit(self) -> None: transaction = not_none(self._session.get_transaction()) + + if not transaction.is_active: + await transaction.rollback() + return + with reraise_serialization_error(): await transaction.commit() @@ -78,7 +83,11 @@ async def __aexit__( async def commit(self) -> None: transaction = not_none(self._session.get_transaction()) - await transaction.commit() + + if transaction.is_active: + await transaction.commit() + else: + await transaction.rollback() @dataclass From 6833beb4d96be1deae8456da2e65bd1c27290eb6 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Sun, 5 Oct 2025 19:10:57 +0700 Subject: [PATCH 39/45] style(`presentation`): fix `ruff` errors (#53) --- .../adapters/invitation_to_game_views.py | 2 +- .../aiogram_dialog/common/wigets/users_request.py | 14 +++----------- .../incoming_invitations_to_game_window.py | 2 +- .../outcoming_invitations_to_game_window.py | 11 +++-------- 4 files changed, 8 insertions(+), 21 deletions(-) diff --git a/src/ttt/presentation/adapters/invitation_to_game_views.py b/src/ttt/presentation/adapters/invitation_to_game_views.py index 609b596..b7e096b 100644 --- a/src/ttt/presentation/adapters/invitation_to_game_views.py +++ b/src/ttt/presentation/adapters/invitation_to_game_views.py @@ -5,7 +5,7 @@ from uuid import UUID from aiogram.types import CallbackQuery -from aiogram.utils.formatting import Code, Text +from aiogram.utils.formatting import Code from aiogram_dialog import DialogManager, ShowMode, StartMode from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession diff --git a/src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py b/src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py index 9677376..cd6b415 100644 --- a/src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py +++ b/src/ttt/presentation/aiogram_dialog/common/wigets/users_request.py @@ -1,24 +1,16 @@ from collections.abc import Awaitable, Callable -from typing import Any, Optional, Union +from typing import Any from aiogram.types import ( CallbackQuery, - InlineKeyboardButton, KeyboardButton, KeyboardButtonRequestUsers, - LoginUrl, - SwitchInlineQueryChosenChat, - WebAppInfo, ) from aiogram_dialog.api.internal import RawKeyboard from aiogram_dialog.api.protocols import DialogManager, DialogProtocol from aiogram_dialog.widgets.common import WhenCondition from aiogram_dialog.widgets.kbd import Keyboard -from aiogram_dialog.widgets.kbd.button import OnClick from aiogram_dialog.widgets.text import Text -from aiogram_dialog.widgets.widget_event import ( - WidgetEventProcessor, -) UsersRequestOnClick = Callable[ @@ -40,9 +32,9 @@ def __init__( async def _process_own_callback( self, - callback: CallbackQuery, + callback: CallbackQuery, # noqa: ARG002 dialog: DialogProtocol, # noqa: ARG002 - manager: DialogManager, + manager: DialogManager, # noqa: ARG002 ) -> bool: return True diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py index 11587ea..8cb3eb0 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py @@ -9,7 +9,7 @@ Select, SwitchTo, ) -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from magic_filter import F diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py index e431581..438cd75 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py @@ -1,28 +1,23 @@ from dataclasses import dataclass -from typing import Any, cast +from typing import Any from uuid import UUID -from aiogram import Bot from aiogram.enums import ContentType from aiogram.types import ( CallbackQuery, - InlineKeyboardButton, KeyboardButtonRequestUsers, Message, User, ) -from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.input import MessageInput from aiogram_dialog.widgets.kbd import ( - Button, Group, - ListGroup, - ScrollingGroup, Select, SwitchTo, ) from aiogram_dialog.widgets.markup.reply_keyboard import ReplyKeyboardFactory -from aiogram_dialog.widgets.text import Const, Format, Multi +from aiogram_dialog.widgets.text import Const from alembic.util import not_none from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject From 3af4b8c85c6cd44aff31d4425ca9cba4848b343f Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:12:14 +0700 Subject: [PATCH 40/45] fix(`deploy`): use custom `natscli` image --- deploy/dev/docker-compose.yaml | 2 +- deploy/prod/docker-compose.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/dev/docker-compose.yaml b/deploy/dev/docker-compose.yaml index 9d4ba40..dec933b 100644 --- a/deploy/dev/docker-compose.yaml +++ b/deploy/dev/docker-compose.yaml @@ -92,7 +92,7 @@ services: interval: 3s nats_streams: - image: bitnami/natscli:0.2.3-debian-12-r4 + image: n255/natscli:0.3.0-bookworm-slim container_name: ttt-nats-streams depends_on: nats: diff --git a/deploy/prod/docker-compose.yaml b/deploy/prod/docker-compose.yaml index 9212405..e010bf4 100644 --- a/deploy/prod/docker-compose.yaml +++ b/deploy/prod/docker-compose.yaml @@ -146,7 +146,7 @@ services: interval: 3s nats_streams: - image: bitnami/natscli:0.2.3-debian-12-r4 + image: n255/natscli:0.3.0-bookworm-slim container_name: ttt-nats-streams depends_on: nats: From 673a3a08b9c6d613d47dc231e06d06ac014b87cd Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:36:18 +0700 Subject: [PATCH 41/45] ref(`nats_streams`): remove the unused `entrypoint` --- deploy/dev/docker-compose.yaml | 1 - deploy/prod/docker-compose.yaml | 1 - 2 files changed, 2 deletions(-) diff --git a/deploy/dev/docker-compose.yaml b/deploy/dev/docker-compose.yaml index dec933b..2890e45 100644 --- a/deploy/dev/docker-compose.yaml +++ b/deploy/dev/docker-compose.yaml @@ -99,7 +99,6 @@ services: condition: service_healthy volumes: - ./nats:/mnt - entrypoint: [""] command: ["bash", "/mnt/add_streams.sh"] environment: NATS_URL: nats://nats:4222 diff --git a/deploy/prod/docker-compose.yaml b/deploy/prod/docker-compose.yaml index e010bf4..ced22be 100644 --- a/deploy/prod/docker-compose.yaml +++ b/deploy/prod/docker-compose.yaml @@ -158,7 +158,6 @@ services: environment: NATS_URL: nats://nats:4222 NATS_TOKEN: ${NATS_TOKEN} - entrypoint: [""] command: ["bash", "/mnt/add_streams.sh"] volumes: From 6b460ff6ac1fcd7f4fa1191b4b7b46edb218eeaf Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:38:49 +0700 Subject: [PATCH 42/45] chore(`di`): add dialog ttl (#63) --- deploy/dev/docker-compose.yaml | 2 ++ deploy/prod/docker-compose.yaml | 2 ++ src/ttt/infrastructure/pydantic_settings/envs.py | 2 ++ src/ttt/main/tg_bot/di.py | 10 ++++++++-- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/deploy/dev/docker-compose.yaml b/deploy/dev/docker-compose.yaml index 2890e45..1235935 100644 --- a/deploy/dev/docker-compose.yaml +++ b/deploy/dev/docker-compose.yaml @@ -36,6 +36,8 @@ services: TTT_GEMINI_URL: https://my-openai-gemini-sigma-sandy.vercel.app + TTT_DIALOG_TTL: 259200 # 3 days + TTT_MATCHMAKING_MAX_WORKERS: 4 TTT_MATCHMAKING_WORKER_MAX_USERS: 100 TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5 diff --git a/deploy/prod/docker-compose.yaml b/deploy/prod/docker-compose.yaml index ced22be..ac5ba94 100644 --- a/deploy/prod/docker-compose.yaml +++ b/deploy/prod/docker-compose.yaml @@ -36,6 +36,8 @@ services: TTT_GEMINI_URL: ${GEMINI_URL} + TTT_DIALOG_TTL: 259200 # 3 days + TTT_MATCHMAKING_MAX_WORKERS: 4 TTT_MATCHMAKING_WORKER_MAX_USERS: 100 TTT_MATCHMAKING_WORKER_CREATION_INTERVAL_SECONDS: 0.5 diff --git a/src/ttt/infrastructure/pydantic_settings/envs.py b/src/ttt/infrastructure/pydantic_settings/envs.py index 889b2c4..3f489aa 100644 --- a/src/ttt/infrastructure/pydantic_settings/envs.py +++ b/src/ttt/infrastructure/pydantic_settings/envs.py @@ -23,6 +23,8 @@ class Envs(BaseSettings): gemini_url: str + dialog_ttl: int + matchmaking_max_workers: int matchmaking_worker_max_users: int matchmaking_worker_creation_interval_seconds: float diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index 1db073a..75663cd 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -118,6 +118,7 @@ from ttt.application.user.view_other_user import ViewOtherUser from ttt.application.user.view_user import ViewUser from ttt.application.user.view_user_emojis import ViewUserEmojis +from ttt.infrastructure.pydantic_settings.envs import Envs from ttt.infrastructure.pydantic_settings.secrets import Secrets from ttt.presentation.adapters.emojis import PictographsAsEmojis from ttt.presentation.adapters.game_views import ( @@ -163,8 +164,13 @@ class PresentationProvider(Provider): provide_event = from_context(TelegramObject | None, scope=Scope.REQUEST) @provide(scope=Scope.APP) - def provide_strage(self, redis: Redis) -> BaseStorage: - return RedisStorage(redis, DefaultKeyBuilder(with_destiny=True)) + def provide_strage(self, redis: Redis, envs: Envs) -> BaseStorage: + return RedisStorage( + redis, + DefaultKeyBuilder(with_destiny=True), + state_ttl=envs.dialog_ttl, + data_ttl=envs.dialog_ttl, + ) @provide(scope=Scope.APP) def provide_bg_manager_factory(self, dp: Dispatcher) -> BgManagerFactory: From 3408ec72cd2de14cbac61f76b92a8a814cfa943d Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:40:08 +0700 Subject: [PATCH 43/45] feat(`error_handling`): handle intent and chat errors (#63) --- .../aiogram/common/routes/error_handling.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/ttt/presentation/aiogram/common/routes/error_handling.py b/src/ttt/presentation/aiogram/common/routes/error_handling.py index 7790edd..76c365c 100644 --- a/src/ttt/presentation/aiogram/common/routes/error_handling.py +++ b/src/ttt/presentation/aiogram/common/routes/error_handling.py @@ -1,14 +1,47 @@ +from typing import cast + from aiogram import Router +from aiogram.exceptions import TelegramBadRequest +from aiogram.filters import ExceptionTypeFilter from aiogram.types import ErrorEvent +from aiogram_dialog import DialogManager, StartMode +from aiogram_dialog.api.exceptions import OutdatedIntent, UnknownIntent from dishka.integrations.aiogram import FromDishka, inject from structlog.types import FilteringBoundLogger from ttt.infrastructure.structlog.logger import unexpected_error_log +from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState error_handling_router = Router(name=__name__) +@error_handling_router.error( + ExceptionTypeFilter(UnknownIntent, OutdatedIntent), +) +async def _(_: ErrorEvent, dialog_manager: DialogManager) -> None: + await dialog_manager.start( + MainDialogState.main, + {"hint": "Сессия устарела"}, + StartMode.RESET_STACK, + ) + + +@error_handling_router.error(ExceptionTypeFilter(TelegramBadRequest)) +@inject +async def _( + event: ErrorEvent, + logger: FromDishka[FilteringBoundLogger], +) -> None: + error = cast(TelegramBadRequest, event.exception) + + if error.message not in { + "Bad Request: chat not found", + "Forbidden: bot was blocked by the user", + }: + await unexpected_error_log(logger, error) + + @error_handling_router.error() @inject async def _( From 44a5374ab8495b3b70eb820b852ec48fb63fdde7 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:55:39 +0700 Subject: [PATCH 44/45] chore(`pyproject`): `0.5.0`v --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2f18ede..942dfa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ttt" -version = "0.4.2" +version = "0.5.0" description = "Tic-Tac-Toe Telegram Bot" authors = [ {name = "Alexander Smolin", email = "88573504+emptybutton@users.noreply.github.com"} From 3e246a9e767f0c5018e8d086a76b3afb84f15773 Mon Sep 17 00:00:00 2001 From: Alexander Smolin <88573504+emptybutton@users.noreply.github.com> Date: Mon, 6 Oct 2025 17:56:25 +0700 Subject: [PATCH 45/45] fix(`add_streams.sh`): add from `streams` dir --- deploy/prod/nats/add_streams.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/prod/nats/add_streams.sh b/deploy/prod/nats/add_streams.sh index 2058059..12c14a7 100644 --- a/deploy/prod/nats/add_streams.sh +++ b/deploy/prod/nats/add_streams.sh @@ -1,3 +1,5 @@ #!/bin/bash -nats stream add --config /mnt/streams/user.json +for stream_config_file in `ls -lx /mnt/streams`; do + nats stream add --config /mnt/streams/$stream_config_file +done