diff --git a/deploy/dev/docker-compose.yaml b/deploy/dev/docker-compose.yaml index f04d16e..b7c39e4 100644 --- a/deploy/dev/docker-compose.yaml +++ b/deploy/dev/docker-compose.yaml @@ -35,9 +35,6 @@ services: TTT_NATS_URL: nats://nats:4222 TTT_GEMINI_URL: https://my-openai-gemini-sigma-sandy.vercel.app - - TTT_GAME_WAITING_QUEUE_PULLING_TIMEOUT_MIN_MS: 100 - TTT_GAME_WAITING_QUEUE_PULLING_TIMEOUT_SALT_MS: 100 secrets: - secrets command: ttt-dev diff --git a/deploy/prod/docker-compose.yaml b/deploy/prod/docker-compose.yaml index 3815fa5..eb6f70a 100644 --- a/deploy/prod/docker-compose.yaml +++ b/deploy/prod/docker-compose.yaml @@ -35,9 +35,6 @@ services: TTT_NATS_URL: nats://${NATS_TOKEN}@nats:4222 TTT_GEMINI_URL: ${GEMINI_URL} - - TTT_GAME_WAITING_QUEUE_PULLING_TIMEOUT_MIN_MS: 100 - TTT_GAME_WAITING_QUEUE_PULLING_TIMEOUT_SALT_MS: 200 secrets: - secrets networks: diff --git a/pyproject.toml b/pyproject.toml index 5fb6f6f..0fb8793 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ttt" -version = "0.3.0" +version = "0.4.0" description = "Tic-Tac-Toe Telegram Bot" authors = [ {name = "Alexander Smolin", email = "88573504+emptybutton@users.noreply.github.com"} @@ -75,6 +75,9 @@ indent-width = 4 [tool.ruff.lint.isort] lines-after-imports = 2 +[tool.ruff.lint.pylint] +allow-dunder-method-names = ["__entity__"] + [tool.ruff.lint] select = ["ALL"] ignore = [ diff --git a/src/ttt/application/common/ports/map.py b/src/ttt/application/common/ports/map.py index 11c7889..6a46f8a 100644 --- a/src/ttt/application/common/ports/map.py +++ b/src/ttt/application/common/ports/map.py @@ -7,6 +7,9 @@ class NotUniqueUserIdError(Exception): ... +class NotUniqueActiveInvitationToGameUserIdsError(Exception): ... + + type MappableTracking = Tracking[Atomic] @@ -19,4 +22,5 @@ async def __call__( ) -> None: """ :raises ttt.application.common.ports.map.NotUniqueUserIdError: - """ + :raises ttt.application.common.ports.map.NotUniqueActiveInvitationToGameUserIdsError: + """ # noqa: E501 diff --git a/src/ttt/application/game/game/ports/game_log.py b/src/ttt/application/game/game/ports/game_log.py index 0a4b6da..6cf067f 100644 --- a/src/ttt/application/game/game/ports/game_log.py +++ b/src/ttt/application/game/game/ports/game_log.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from collections.abc import Sequence from ttt.entities.core.game.game import Game from ttt.entities.core.game.move import AiMove, UserMove @@ -7,20 +6,6 @@ class GameLog(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 game_against_user_started( self, @@ -70,7 +55,9 @@ async def game_completed( ) -> None: ... @abstractmethod - async def user_already_in_game_to_start_game(self, user: User, /) -> None: + async def user_already_in_game_to_start_game_against_ai( + self, user: User, /, + ) -> None: ... @abstractmethod @@ -116,17 +103,3 @@ async def already_completed_game_to_cancel( user_id: int, /, ) -> None: ... - - @abstractmethod - async def users_already_in_game_to_start_game_via_game_starting_queue( - self, - user_ids: Sequence[int], - /, - ) -> None: ... - - @abstractmethod - async def bad_attempt_to_start_game_via_game_starting_queue( - self, - user_ids: Sequence[int], - /, - ) -> None: ... diff --git a/src/ttt/application/game/game/ports/game_starting_queue.py b/src/ttt/application/game/game/ports/game_starting_queue.py deleted file mode 100644 index 6a27596..0000000 --- a/src/ttt/application/game/game/ports/game_starting_queue.py +++ /dev/null @@ -1,29 +0,0 @@ -from abc import ABC, abstractmethod -from collections.abc import AsyncIterator, Sequence -from dataclasses import dataclass - - -@dataclass(frozen=True) -class GameStartingQueuePush: - was_location_dedublicated: bool - - -class GameStartingQueue(ABC): - @abstractmethod - async def push( - self, - user_id: int, - /, - ) -> GameStartingQueuePush: ... - - @abstractmethod - async def push_many( - self, - user_ids: Sequence[int], - /, - ) -> None: ... - - @abstractmethod - def __aiter__( - self, - ) -> AsyncIterator[tuple[int, int]]: ... diff --git a/src/ttt/application/game/game/ports/game_views.py b/src/ttt/application/game/game/ports/game_views.py index 38da320..a06fb88 100644 --- a/src/ttt/application/game/game/ports/game_views.py +++ b/src/ttt/application/game/game/ports/game_views.py @@ -6,9 +6,6 @@ class GameViews(ABC): - @abstractmethod - async def waiting_for_game_view(self, user_id: int, /) -> None: ... - @abstractmethod async def current_game_view_with_user_id(self, user_id: int, /) -> None: ... @@ -68,8 +65,4 @@ async def already_filled_cell_error( ) -> None: ... @abstractmethod - async def users_already_in_game_views( - self, - user_ids: Sequence[int], - /, - ) -> None: ... + async def user_already_in_game_view(self, user_id: int, /) -> None: ... diff --git a/src/ttt/application/game/game/start_game.py b/src/ttt/application/game/game/start_game.py deleted file mode 100644 index 5158a3d..0000000 --- a/src/ttt/application/game/game/start_game.py +++ /dev/null @@ -1,120 +0,0 @@ -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.game.game.ports.game_log import GameLog -from ttt.application.game.game.ports.game_starting_queue import ( - GameStartingQueue, -) -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 -from ttt.application.user.common.ports.users import Users -from ttt.entities.core.game.game import UsersAlreadyInGameError, start_game -from ttt.entities.tools.assertion import not_none -from ttt.entities.tools.tracking import Tracking - - -@dataclass(frozen=True, unsafe_hash=False) -class StartGame: - map_: Map - uuids: UUIDs - emojis: Emojis - users: Users - user_views: CommonUserViews - games: Games - game_views: GameViews - game_starting_queue: GameStartingQueue - transaction: Transaction - log: GameLog - - async def __call__(self) -> None: - async for user1_id, user2_id in self.game_starting_queue: - async with self.transaction, self.emojis: - user1, user2 = await self.users.users_with_ids( - (user1_id, user2_id), - ) - ( - game_id, - cell_id_matrix, - user1_emoji, - user2_emoji, - ) = await gather( - self.uuids.random_uuid(), - self.uuids.random_uuid_matrix((3, 3)), - self.emojis.random_emoji(), - self.emojis.random_emoji(), - ) - - if user1 is None: - await self.user_views.user_is_not_registered_view(user1_id) - if user2 is None: - await self.user_views.user_is_not_registered_view(user2_id) - if user1 is None or user2 is None: - await self.game_starting_queue.push_many( - tuple( - user.id - for user in (user1, user2) - if user is not None - ), - ) - continue - - tracking = Tracking() - try: - game = start_game( - cell_id_matrix, - game_id, - user1, - user1_emoji, - user2, - user2_emoji, - tracking, - ) - - except UsersAlreadyInGameError as error: - ids_of_users_not_in_game = list[int]() - ids_of_users_in_game = list[int]() - - for user in (user1, user2): - if user in error.users: - ids_of_users_in_game.append(user.id) - else: - ids_of_users_not_in_game.append(user.id) - - await ( - self.log - .users_already_in_game_to_start_game_via_game_starting_queue( - ids_of_users_in_game, - ) - ) - await ( - self.log - .bad_attempt_to_start_game_via_game_starting_queue( - ids_of_users_not_in_game, - ) - ) - - await self.game_starting_queue.push_many( - ids_of_users_not_in_game, - ) - await self.game_views.users_already_in_game_views( - ids_of_users_in_game, - ) - continue - - else: - await self.log.game_against_user_started(game) - await self.map_(tracking) - - game_locations = ( - not_none(user1.game_location), - not_none(user2.game_location), - ) - await self.game_views.started_game_view_with_locations( - game_locations, - game, - ) 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 cebb3b1..d700576 100644 --- a/src/ttt/application/game/game/start_game_with_ai.py +++ b/src/ttt/application/game/game/start_game_with_ai.py @@ -8,9 +8,6 @@ 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_starting_queue import ( - GameStartingQueue, -) 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 @@ -32,7 +29,6 @@ class StartGameWithAi: user_views: CommonUserViews games: Games game_views: GameViews - game_starting_queue: GameStartingQueue transaction: Transaction ai_gateway: GameAiGateway log: GameLog @@ -66,8 +62,10 @@ async def __call__(self, user_id: int, ai_type: AiType) -> None: tracking, ) except UserAlreadyInGameError: - await self.log.user_already_in_game_to_start_game(user) - await self.game_views.users_already_in_game_views([user_id]) + await self.log.user_already_in_game_to_start_game_against_ai( + user, + ) + await self.game_views.user_already_in_game_view(user_id) else: await self.log.game_against_ai_started(started_game.game) diff --git a/src/ttt/application/game/game/wait_game.py b/src/ttt/application/game/game/wait_game.py deleted file mode 100644 index 494afaa..0000000 --- a/src/ttt/application/game/game/wait_game.py +++ /dev/null @@ -1,35 +0,0 @@ -from dataclasses import dataclass - -from ttt.application.common.ports.transaction import Transaction -from ttt.application.game.game.ports.game_log import GameLog -from ttt.application.game.game.ports.game_starting_queue import ( - GameStartingQueue, -) -from ttt.application.game.game.ports.game_views import GameViews -from ttt.application.user.common.ports.user_views import CommonUserViews -from ttt.application.user.common.ports.users import Users - - -@dataclass(frozen=True, unsafe_hash=False) -class WaitGame: - users: Users - game_starting_queue: GameStartingQueue - user_views: CommonUserViews - game_views: GameViews - transaction: Transaction - log: GameLog - - async def __call__(self, user_id: int) -> None: - async with self.transaction: - if not await self.users.contains_user_with_id(user_id): - await self.user_views.user_is_not_registered_view(user_id) - return - - push = await self.game_starting_queue.push(user_id) - - if push.was_location_dedublicated: - await self.log.double_waiting_for_game_start(user_id) - else: - await self.log.waiting_for_game_start(user_id) - - await self.game_views.waiting_for_game_view(user_id) diff --git a/src/ttt/infrastructure/redis/__init__.py b/src/ttt/application/invitation_to_game/__init__.py similarity index 100% rename from src/ttt/infrastructure/redis/__init__.py rename to src/ttt/application/invitation_to_game/__init__.py diff --git a/src/ttt/application/invitation_to_game/game/__init__.py b/src/ttt/application/invitation_to_game/game/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..f4093a2 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/accpet_invitation_to_game.py @@ -0,0 +1,123 @@ +from asyncio import gather +from dataclasses import dataclass +from uuid import UUID + +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.uuids import UUIDs +from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 + InvitationToGameLog, +) +from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 + InvitationToGameViews, +) +from ttt.application.invitation_to_game.game.ports.invitations_to_game import ( + InvitationsToGame, +) +from ttt.entities.core.game.game import UsersAlreadyInGameError +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationToGameStateIsNotActiveError, + UserIsNotInvitedUserError, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class AcceptInvitationToGame: + map_: Map + transaction: Transaction + views: InvitationToGameViews + log: InvitationToGameLog + invitations_to_game: InvitationsToGame + emojis: Emojis + uuids: UUIDs + randoms: Randoms + + async def __call__( + self, + user_id: int, + invitation_to_game_id: UUID, + ) -> None: + async with self.transaction: + ( + invitation_to_game, + user_random_emoji, + inviting_player_random_emoji, + player_order_random, + cell_id_matrix, + game_id, + ) = await gather( + self.invitations_to_game.invitation_to_game_with_id( + invitation_to_game_id, + ), + self.emojis.random_emoji(), + self.emojis.random_emoji(), + self.randoms.random(), + self.uuids.random_uuid_matrix((3, 3)), + self.uuids.random_uuid(), + ) + + if invitation_to_game is None: + await self.log.no_invitation_to_game_to_accept( + user_id, invitation_to_game_id, + ) + await self.views.no_invitation_to_game_to_accept_view( + user_id, invitation_to_game_id, + ) + return + + try: + tracking = Tracking() + game = invitation_to_game.accept( + user_id, + user_random_emoji, + inviting_player_random_emoji, + player_order_random, + cell_id_matrix, + game_id, + tracking, + ) + except UserIsNotInvitedUserError: + await ( + self.log + .user_is_not_invited_user_to_accept_invitation_to_game( + invitation_to_game, user_id, + ) + ) + await ( + self.views + .user_is_not_invited_user_to_accept_invitation_to_game_view( + invitation_to_game, user_id, + ) + ) + except InvitationToGameStateIsNotActiveError: + await self.log.invitation_to_game_is_not_active_to_accept( + invitation_to_game, user_id, + ) + await ( + self.views.invitation_to_game_is_not_active_to_accept_view( + invitation_to_game, user_id, + ) + ) + except UsersAlreadyInGameError as error: + await ( + self.log.users_already_in_game_to_accept_invitation_to_game( + invitation_to_game, error.users, + ) + ) + await ( + self.views + .users_already_in_game_to_accept_invitation_to_game_view( + invitation_to_game, error.users, + ) + ) + else: + await self.log.user_accepted_invitation_to_game( + invitation_to_game, game, + ) + await self.map_(tracking) + 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 new file mode 100644 index 0000000..d2de8a4 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/auto_cancel_invitations_to_game.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.clock import Clock +from ttt.application.common.ports.transaction import Transaction +from ttt.application.invitation_to_game.game.ports.invitation_to_game_dao import ( # noqa: E501 + InvitationToGameDao, +) +from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 + InvitationToGameLog, +) +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + invitation_to_game_datetime, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class AutoCancelInvitationsToGame: + transaction: Transaction + log: InvitationToGameLog + clock: Clock + invitation_to_game_dao: InvitationToGameDao + + async def __call__(self) -> None: + async with self.transaction: + expiration_datetime = await self.clock.current_datetime() + auto_cancelled_invitations_to_game_ids = await ( + self.invitation_to_game_dao + .set_auto_cancelled_where_invitation_datetime_le_and_active( + invitation_to_game_datetime(expiration_datetime), + ) + ) + await self.log.invitations_to_game_auto_cancelled( + auto_cancelled_invitations_to_game_ids, + ) 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 new file mode 100644 index 0000000..7586559 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/cancel_invitation_to_game.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from uuid import UUID + +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import Transaction +from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 + InvitationToGameLog, +) +from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 + InvitationToGameViews, +) +from ttt.application.invitation_to_game.game.ports.invitations_to_game import ( + InvitationsToGame, +) +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationToGameStateIsNotActiveError, + UserIsNotInvitingUserError, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class CancelInvitationToGame: + map_: Map + transaction: Transaction + views: InvitationToGameViews + log: InvitationToGameLog + invitations_to_game: InvitationsToGame + + async def __call__( + self, + user_id: int, + invitation_to_game_id: UUID, + ) -> None: + async with self.transaction: + invitation_to_game = await ( + self.invitations_to_game.invitation_to_game_with_id( + invitation_to_game_id, + ) + ) + + if invitation_to_game is None: + await self.log.no_invitation_to_game_to_cancel( + user_id, invitation_to_game_id, + ) + await self.views.no_invitation_to_game_to_cancel_view( + user_id, invitation_to_game_id, + ) + return + + try: + tracking = Tracking() + invitation_to_game.cancel(user_id, tracking) + except UserIsNotInvitingUserError: + await ( + self.log + .user_is_not_inviting_user_to_cancel_invitation_to_game( + invitation_to_game, user_id, + ) + ) + await ( + self.views + .user_is_not_inviting_user_to_cancel_invitation_to_game_view( + invitation_to_game, user_id, + ) + ) + except InvitationToGameStateIsNotActiveError: + await self.log.invitation_to_game_is_not_active_to_cancel( + invitation_to_game, user_id, + ) + await ( + self.views.invitation_to_game_is_not_active_to_cancel_view( + invitation_to_game, user_id, + ) + ) + else: + await self.log.user_cancelled_invitation_to_game( + invitation_to_game, + ) + await self.map_(tracking) + 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 new file mode 100644 index 0000000..494b932 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/invite_to_game.py @@ -0,0 +1,81 @@ +from asyncio import gather +from dataclasses import dataclass + +from ttt.application.common.ports.clock import Clock +from ttt.application.common.ports.map import ( + Map, + NotUniqueActiveInvitationToGameUserIdsError, +) +from ttt.application.common.ports.transaction import Transaction +from ttt.application.common.ports.uuids import UUIDs +from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 + InvitationToGameLog, +) +from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 + InvitationToGameViews, +) +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationSelfToGameError, + invite_to_game, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class InviteToGame: + map_: Map + uuids: UUIDs + transaction: Transaction + clock: Clock + users: Users + user_views: CommonUserViews + views: InvitationToGameViews + log: InvitationToGameLog + + async def __call__(self, user_id: int, invited_user_id: int) -> None: + async with self.transaction: + user, invited_user = await self.users.users_with_ids( + (user_id, invited_user_id), + ) + + if user is None: + await self.user_views.user_is_not_registered_view(user_id) + return + + ( + invitation_to_game_id, + current_datetime, + ) = await gather( + self.uuids.random_uuid(), + self.clock.current_datetime(), + ) + + try: + tracking = Tracking() + invitation_to_game = invite_to_game( + user, + invited_user, + invited_user_id, + invitation_to_game_id, + current_datetime, + tracking, + ) + except InvitationSelfToGameError: + await self.log.invitation_self_to_game(user) + await self.views.invitation_self_to_game_view(user) + return + + try: + await self.map_(tracking) + except NotUniqueActiveInvitationToGameUserIdsError: + await self.log.double_invitation_to_game(invitation_to_game) + await self.views.double_invitation_to_game_view( + invitation_to_game, + ) + else: + await self.log.user_invited_other_user_to_game( + invitation_to_game, + ) + await self.views.invitation_to_game_view(invitation_to_game) diff --git a/src/ttt/application/invitation_to_game/game/ports/__init__.py b/src/ttt/application/invitation_to_game/game/ports/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..7c770c8 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_dao.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod +from collections.abc import Sequence +from datetime import datetime +from uuid import UUID + + +class InvitationToGameDao(ABC): + @abstractmethod + async def set_auto_cancelled_where_invitation_datetime_le_and_active( + self, + datetime: datetime, + /, + ) -> Sequence[UUID]: ... diff --git a/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_log.py b/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_log.py new file mode 100644 index 0000000..89086d5 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_log.py @@ -0,0 +1,147 @@ +from abc import ABC, abstractmethod +from collections.abc import Sequence +from uuid import UUID + +from ttt.entities.core.game.game import Game +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationToGame, +) +from ttt.entities.core.user.user import User + + +class InvitationToGameLog(ABC): + @abstractmethod + async def invitation_self_to_game( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_invited_other_user_to_game( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: ... + + @abstractmethod + async def double_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: ... + + @abstractmethod + async def invitation_to_game_is_not_active_to_cancel( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def user_is_not_inviting_user_to_cancel_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def invitation_to_game_is_not_active_to_reject( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def user_is_not_invited_user_to_reject_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def invitation_to_game_is_not_active_to_accept( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def user_is_not_invited_user_to_accept_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def users_already_in_game_to_accept_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + users_in_game: Sequence[User], + /, + ) -> None: ... + + @abstractmethod + async def user_cancelled_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: ... + + @abstractmethod + async def user_rejected_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: ... + + @abstractmethod + async def user_accepted_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + game: Game, + /, + ) -> None: ... + + @abstractmethod + async def invitations_to_game_auto_cancelled( + self, + ids: Sequence[UUID], + /, + ) -> None: ... + + @abstractmethod + async def no_invitation_to_game_to_accept( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: ... + + @abstractmethod + async def no_invitation_to_game_to_reject( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: ... + + @abstractmethod + async def no_invitation_to_game_to_cancel( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: ... + + @abstractmethod + async def no_invitation_to_game_to_auto_cancel( + self, invitation_to_game_id: UUID, /, + ) -> None: ... + + @abstractmethod + async def not_expired_invitation_to_game_to_auto_cancel( + self, invitation_to_game: InvitationToGame, /, + ) -> None: ... + + @abstractmethod + async def invitation_to_game_state_is_not_active_to_game_to_auto_cancel( + self, invitation_to_game: InvitationToGame, /, + ) -> None: ... diff --git a/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_views.py b/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_views.py new file mode 100644 index 0000000..c526c03 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/ports/invitation_to_game_views.py @@ -0,0 +1,149 @@ +from abc import ABC, abstractmethod +from collections.abc import Sequence +from uuid import UUID + +from ttt.entities.core.game.game import Game +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationToGame, +) +from ttt.entities.core.user.user import User + + +class InvitationToGameViews(ABC): + @abstractmethod + async def invitation_self_to_game_view( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def incoming_user_invitations_to_game_view( + self, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def outcoming_user_invitations_to_game_view( + self, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: ... + + @abstractmethod + async def cancelled_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: ... + + @abstractmethod + async def rejected_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: ... + + @abstractmethod + async def accepted_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + game: Game, + /, + ) -> None: ... + + @abstractmethod + async def double_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: ... + + @abstractmethod + async def invitation_to_game_is_not_active_to_cancel_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def user_is_not_inviting_user_to_cancel_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def invitation_to_game_is_not_active_to_reject_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def user_is_not_invited_user_to_reject_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def invitation_to_game_is_not_active_to_accept_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def users_already_in_game_to_accept_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + users_in_game: Sequence[User], + /, + ) -> None: ... + + @abstractmethod + async def user_is_not_invited_user_to_accept_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: ... + + @abstractmethod + async def no_invitation_to_game_to_accept_view( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: ... + + @abstractmethod + async def no_invitation_to_game_to_reject_view( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: ... + + @abstractmethod + async def no_invitation_to_game_to_cancel_view( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: ... + + @abstractmethod + async def incoming_invitation_to_game_view( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: ... + + @abstractmethod + async def one_incoming_invitation_to_game_view( + self, user_id: int, /, + ) -> None: ... diff --git a/src/ttt/application/invitation_to_game/game/ports/invitations_to_game.py b/src/ttt/application/invitation_to_game/game/ports/invitations_to_game.py new file mode 100644 index 0000000..797650a --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/ports/invitations_to_game.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod +from uuid import UUID + +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationToGame, +) + + +class InvitationsToGame(ABC): + @abstractmethod + async def invitation_to_game_with_id( + self, id_: UUID, /, + ) -> InvitationToGame | None: ... 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 new file mode 100644 index 0000000..7117aa7 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/reject_invitation_to_game.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from uuid import UUID + +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import Transaction +from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 + InvitationToGameLog, +) +from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 + InvitationToGameViews, +) +from ttt.application.invitation_to_game.game.ports.invitations_to_game import ( + InvitationsToGame, +) +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationToGameStateIsNotActiveError, + UserIsNotInvitedUserError, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class RejectInvitationToGame: + map_: Map + transaction: Transaction + views: InvitationToGameViews + log: InvitationToGameLog + invitations_to_game: InvitationsToGame + + async def __call__( + self, + user_id: int, + invitation_to_game_id: UUID, + ) -> None: + async with self.transaction: + invitation_to_game = await ( + self.invitations_to_game.invitation_to_game_with_id( + invitation_to_game_id, + ) + ) + + if invitation_to_game is None: + await self.log.no_invitation_to_game_to_reject( + user_id, invitation_to_game_id, + ) + await self.views.no_invitation_to_game_to_reject_view( + user_id, invitation_to_game_id, + ) + return + + try: + tracking = Tracking() + invitation_to_game.reject(user_id, tracking) + except UserIsNotInvitedUserError: + await ( + self.log + .user_is_not_invited_user_to_reject_invitation_to_game( + invitation_to_game, user_id, + ) + ) + await ( + self.views + .user_is_not_invited_user_to_reject_invitation_to_game_view( + invitation_to_game, user_id, + ) + ) + except InvitationToGameStateIsNotActiveError: + await self.log.invitation_to_game_is_not_active_to_reject( + invitation_to_game, user_id, + ) + await ( + self.views.invitation_to_game_is_not_active_to_reject_view( + invitation_to_game, user_id, + ) + ) + else: + await self.log.user_rejected_invitation_to_game( + invitation_to_game, + ) + await self.map_(tracking) + 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 new file mode 100644 index 0000000..927ed49 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/view_incoming_invitation_to_game.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from uuid import UUID + +from ttt.application.common.ports.transaction import Transaction +from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 + InvitationToGameViews, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class ViewIncomingInvitationToGame: + views: InvitationToGameViews + transaction: Transaction + + async def __call__(self, user_id: int, invitation_to_game_id: UUID) -> None: + async with self.transaction: + return await self.views.incoming_invitation_to_game_view( + user_id, invitation_to_game_id, + ) 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 new file mode 100644 index 0000000..1a53067 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/view_incoming_invitations_to_game.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.transaction import Transaction +from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 + InvitationToGameViews, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class ViewIncomingInvitationsToGame: + views: InvitationToGameViews + transaction: Transaction + + async def __call__(self, user_id: int) -> None: + async with self.transaction: + return await self.views.incoming_user_invitations_to_game_view( + user_id, + ) 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 new file mode 100644 index 0000000..28d21f5 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/view_one_incoming_invitation_to_game.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.transaction import Transaction +from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 + InvitationToGameViews, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class ViewOneIncomingInvitationToGame: + views: InvitationToGameViews + transaction: Transaction + + async def __call__(self, user_id: int) -> None: + async with self.transaction: + return await self.views.one_incoming_invitation_to_game_view( + user_id, + ) 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 new file mode 100644 index 0000000..86cc1d3 --- /dev/null +++ b/src/ttt/application/invitation_to_game/game/view_outcoming_invitations_to_game.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.transaction import Transaction +from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 + InvitationToGameViews, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class ViewOutcomingInvitationsToGame: + views: InvitationToGameViews + transaction: Transaction + + async def __call__(self, user_id: int) -> None: + async with self.transaction: + return await self.views.outcoming_user_invitations_to_game_view( + user_id, + ) diff --git a/src/ttt/application/matchmaking_queue/__init__.py b/src/ttt/application/matchmaking_queue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ttt/application/matchmaking_queue/common/__init__.py b/src/ttt/application/matchmaking_queue/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ttt/application/matchmaking_queue/common/matchmaking_queue_log.py b/src/ttt/application/matchmaking_queue/common/matchmaking_queue_log.py new file mode 100644 index 0000000..d1c76c1 --- /dev/null +++ b/src/ttt/application/matchmaking_queue/common/matchmaking_queue_log.py @@ -0,0 +1,22 @@ +from abc import ABC, abstractmethod + + +class CommonMatchmakingQueueLog(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_add_to_matchmaking_queue( + self, user_id: int, /, + ) -> None: ... diff --git a/src/ttt/application/matchmaking_queue/common/matchmaking_queue_views.py b/src/ttt/application/matchmaking_queue/common/matchmaking_queue_views.py new file mode 100644 index 0000000..b525bde --- /dev/null +++ b/src/ttt/application/matchmaking_queue/common/matchmaking_queue_views.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + + +class CommonMatchmakingQueueViews(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_queue/common/shared_matchmaking_queue.py b/src/ttt/application/matchmaking_queue/common/shared_matchmaking_queue.py new file mode 100644 index 0000000..cf60682 --- /dev/null +++ b/src/ttt/application/matchmaking_queue/common/shared_matchmaking_queue.py @@ -0,0 +1,9 @@ +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/application/matchmaking_queue/game/__init__.py b/src/ttt/application/matchmaking_queue/game/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ttt/application/matchmaking_queue/game/wait_game.py b/src/ttt/application/matchmaking_queue/game/wait_game.py new file mode 100644 index 0000000..436455e --- /dev/null +++ b/src/ttt/application/matchmaking_queue/game/wait_game.py @@ -0,0 +1,103 @@ +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_queue.common.matchmaking_queue_log import ( + CommonMatchmakingQueueLog, +) +from ttt.application.matchmaking_queue.common.matchmaking_queue_views import ( + CommonMatchmakingQueueViews, +) +from ttt.application.matchmaking_queue.common.shared_matchmaking_queue import ( + SharedMatchmakingQueue, +) +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 ( + 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_queue: SharedMatchmakingQueue + matchmaking_queue_views: CommonMatchmakingQueueViews + matchmaking_queue_log: CommonMatchmakingQueueLog + + 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_queue = await self.shared_matchmaking_queue + 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_queue.add_user( + user, + user_waiting_id, + cell_id_matrix, + game_id, + user1_emoji, + user2_emoji, + current_datetime, + tracking, + ) + except UserAlreadyWaitingForGameError: + await self.matchmaking_queue_log.double_waiting_for_game_start( + user_id, + ) + await self.matchmaking_queue_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) + ) + 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( + user_id, + ) + await self.matchmaking_queue_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_with_locations( + game.locations(), + game, + ) diff --git a/src/ttt/application/user/authorize_as_admin.py b/src/ttt/application/user/authorize_as_admin.py new file mode 100644 index 0000000..f98e4e0 --- /dev/null +++ b/src/ttt/application/user/authorize_as_admin.py @@ -0,0 +1,58 @@ +from asyncio import gather +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.original_admin_token import ( + OriginalAdminToken, +) +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 +from ttt.entities.core.user.user import ( + AdminTokenMismatchError, + UserAlreadyAdminError, +) +from ttt.entities.text.token import Token +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class AuthorizeAsAdmin: + transaction: Transaction + users: Users + map_: Map + log: CommonUserLog + original_admin_token: OriginalAdminToken + views: CommonUserViews + + async def __call__(self, user_id: int, admin_token: Token) -> None: + async with self.transaction: + user, original_admin_token = await gather( + self.users.user_with_id(user_id), + self.original_admin_token, + ) + + if user is None: + await self.views.user_is_not_registered_view(user_id) + return + + try: + tracking = Tracking() + user.authorize_as_admin( + admin_token, original_admin_token, tracking, + ) + except UserAlreadyAdminError: + await self.log.user_already_admin_to_get_admin_rights(user) + 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.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.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 new file mode 100644 index 0000000..17bce3a --- /dev/null +++ b/src/ttt/application/user/authorize_other_user_as_admin.py @@ -0,0 +1,71 @@ +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_log import CommonUserLog +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users +from ttt.entities.core.user.user import ( + NotAuthorizedAsAdminViaAdminTokenError, + OtherUserAlreadyAdminError, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class AuthorizeOtherUserAsAdmin: + transaction: Transaction + users: Users + map_: Map + log: CommonUserLog + views: CommonUserViews + + async def __call__(self, user_id: int, other_user_id: int) -> None: + async with self.transaction: + user, other_user = await self.users.users_with_ids( + (user_id, other_user_id), + ) + + if user is None: + await self.views.user_is_not_registered_view(user_id) + return + + try: + tracking = Tracking() + user.authorize_user_as_admin( + other_user, other_user_id, tracking, + ) + except NotAuthorizedAsAdminViaAdminTokenError: + await ( + self.log + .not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_admin( + user, other_user, + ) + ) + await ( + self.views + .not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_admin_view( + user, other_user, + ) + ) + except OtherUserAlreadyAdminError: + await ( + self.log + .other_user_already_admin_to_authorize_other_user_as_admin( + user, other_user, + ) + ) + await ( + self.views + .other_user_already_admin_to_authorize_other_user_as_admin_view( + user, other_user, + ) + ) + else: + await self.log.user_authorized_other_user_as_admin( + user, other_user, + ) + await self.map_(tracking) + await self.views.user_authorized_other_user_as_admin_view( + user, other_user, + ) diff --git a/src/ttt/application/user/change_other_user_account/__init__.py b/src/ttt/application/user/change_other_user_account/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..367a8e9 --- /dev/null +++ b/src/ttt/application/user/change_other_user_account/change_other_user_account.py @@ -0,0 +1,84 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import Transaction +from ttt.application.user.change_other_user_account.ports.user_log import ( + ChangeOtherUserAccountLog, +) +from ttt.application.user.change_other_user_account.ports.user_views import ( + ChangeOtherUserAccountViews, +) +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.account import NegativeAccountError +from ttt.entities.core.user.user import ( + NotAdminError, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class ChangeOtherUserAccount: + transaction: Transaction + users: Users + map_: Map + log: ChangeOtherUserAccountLog + common_views: CommonUserViews + views: ChangeOtherUserAccountViews + + async def __call__( + self, + user_id: int, + other_user_id: int, + other_user_account_stars_vector: Stars, + ) -> None: + async with self.transaction: + user, other_user = await self.users.users_with_ids( + (user_id, other_user_id), + ) + + if user is None: + await self.common_views.user_is_not_registered_view(user_id) + return + + try: + tracking = Tracking() + other_user = user.change_user_account( + other_user, + other_user_id, + other_user_account_stars_vector, + tracking, + ) + except NotAdminError: + await self.log.user_is_not_admin_to_change_other_user_account( + user, + other_user, + other_user_id, + other_user_account_stars_vector, + ) + await self.common_views.user_is_not_admin_view(user) + except NegativeAccountError: + await self.log.negative_account_on_change_other_user_account( + user, + other_user, + other_user_id, + other_user_account_stars_vector, + ) + await ( + self.views + .negative_account_on_change_other_user_account_view( + user, + other_user, + other_user_id, + other_user_account_stars_vector, + ) + ) + else: + await self.log.user_changed_other_user_account( + user, other_user, other_user_account_stars_vector, + ) + await self.map_(tracking) + 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/ports/__init__.py b/src/ttt/application/user/change_other_user_account/ports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ttt/application/user/change_other_user_account/ports/user_log.py b/src/ttt/application/user/change_other_user_account/ports/user_log.py new file mode 100644 index 0000000..556c0a8 --- /dev/null +++ b/src/ttt/application/user/change_other_user_account/ports/user_log.py @@ -0,0 +1,60 @@ +from abc import ABC, abstractmethod + +from ttt.entities.core.stars import Stars +from ttt.entities.core.user.user import User + + +class ChangeOtherUserAccountLog(ABC): + @abstractmethod + async def user_is_not_admin_to_set_other_user_account( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars: Stars, + /, + ) -> None: ... + + @abstractmethod + async def user_is_not_admin_to_change_other_user_account( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars_vector: Stars, + /, + ) -> None: ... + + @abstractmethod + async def negative_account_on_change_other_user_account( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars_vector: Stars, + /, + ) -> None: ... + + @abstractmethod + async def negative_account_on_set_other_user_account( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars: Stars, + /, + ) -> None: ... + + @abstractmethod + async def user_set_other_user_account( + self, user: User, other_user: User, /, + ) -> None: ... + + @abstractmethod + async def user_changed_other_user_account( + self, + user: User, + other_user: User, + other_user_account_stars_vector: Stars, + /, + ) -> None: ... diff --git a/src/ttt/application/user/change_other_user_account/ports/user_views.py b/src/ttt/application/user/change_other_user_account/ports/user_views.py new file mode 100644 index 0000000..5706be8 --- /dev/null +++ b/src/ttt/application/user/change_other_user_account/ports/user_views.py @@ -0,0 +1,45 @@ +from abc import ABC, abstractmethod + +from ttt.entities.core.stars import Stars +from ttt.entities.core.user.user import User + + +class ChangeOtherUserAccountViews(ABC): + @abstractmethod + async def user_account_to_change_view( + self, user_id: int, other_user_id: int, /, + ) -> None: ... + + @abstractmethod + async def negative_account_on_change_other_user_account_view( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars_vector: Stars, + /, + ) -> None: ... + + @abstractmethod + async def negative_account_on_set_other_user_account_view( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars: Stars, + /, + ) -> None: ... + + @abstractmethod + async def user_set_other_user_account_view( + self, user: User, other_user: User, /, + ) -> None: ... + + @abstractmethod + async def user_changed_other_user_account_view( + self, + user: User, + other_user: User, + other_user_account_stars_vector: Stars, + /, + ) -> None: ... 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 new file mode 100644 index 0000000..63bc1db --- /dev/null +++ b/src/ttt/application/user/change_other_user_account/set_other_user_account.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.map import Map +from ttt.application.common.ports.transaction import Transaction +from ttt.application.user.change_other_user_account.ports.user_log import ( + ChangeOtherUserAccountLog, +) +from ttt.application.user.change_other_user_account.ports.user_views import ( + ChangeOtherUserAccountViews, +) +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.account import NegativeAccountError +from ttt.entities.core.user.user import NotAdminError +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class SetOtherUserAccount: + transaction: Transaction + users: Users + map_: Map + log: ChangeOtherUserAccountLog + common_views: CommonUserViews + views: ChangeOtherUserAccountViews + + async def __call__( + self, + user_id: int, + other_user_id: int, + other_user_account_stars: Stars, + ) -> None: + async with self.transaction: + user, other_user = await self.users.users_with_ids( + (user_id, other_user_id), + ) + + if user is None: + await self.common_views.user_is_not_registered_view(user_id) + return + + try: + tracking = Tracking() + other_user = user.set_user_account( + other_user, + other_user_id, + other_user_account_stars, + tracking, + ) + except NotAdminError: + await self.log.user_is_not_admin_to_set_other_user_account( + user, + other_user, + other_user_id, + other_user_account_stars, + ) + await self.common_views.user_is_not_admin_view(user) + except NegativeAccountError: + await self.log.negative_account_on_set_other_user_account( + user, + other_user, + other_user_id, + other_user_account_stars, + ) + await ( + self.views.negative_account_on_set_other_user_account_view( + user, + other_user, + other_user_id, + other_user_account_stars, + ) + ) + else: + await self.log.user_set_other_user_account(user, other_user) + await self.map_(tracking) + 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 new file mode 100644 index 0000000..3848008 --- /dev/null +++ b/src/ttt/application/user/change_other_user_account/view_user_account_to_change.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.transaction import Transaction +from ttt.application.user.change_other_user_account.ports.user_views import ( + ChangeOtherUserAccountViews, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class ViewUserAccountToChange: + transaction: Transaction + views: ChangeOtherUserAccountViews + + async def __call__( + self, user_id: int, other_user_id: int, + ) -> None: + async with self.transaction: + await self.views.user_account_to_change_view( + user_id, other_user_id, + ) diff --git a/src/ttt/application/user/common/ports/original_admin_token.py b/src/ttt/application/user/common/ports/original_admin_token.py new file mode 100644 index 0000000..48862cc --- /dev/null +++ b/src/ttt/application/user/common/ports/original_admin_token.py @@ -0,0 +1,7 @@ +from abc import ABC +from collections.abc import Awaitable + +from ttt.entities.text.token import Token + + +class OriginalAdminToken(ABC, Awaitable[Token]): ... diff --git a/src/ttt/application/user/common/ports/user_log.py b/src/ttt/application/user/common/ports/user_log.py index ab000ba..85e2b07 100644 --- a/src/ttt/application/user/common/ports/user_log.py +++ b/src/ttt/application/user/common/ports/user_log.py @@ -17,3 +17,64 @@ async def user_double_registration( user: User, /, ) -> None: ... + + @abstractmethod + async def user_authorized_as_admin( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_already_admin_to_get_admin_rights( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def admin_token_mismatch_to_get_admin_rights( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def not_admin_to_relinquish_admin_right( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_relinquished_admin_rights(self, user: User, /) -> None: ... + + @abstractmethod + async def not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_admin( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> None: ... + + @abstractmethod + async def other_user_already_admin_to_authorize_other_user_as_admin( + self, user: User, other_user: User | None, /, + ) -> None: ... + + @abstractmethod + async def user_authorized_other_user_as_admin( + self, user: User, other_user: User | None, /, + ) -> None: ... + + @abstractmethod + async def not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_admin( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> None: ... + + @abstractmethod + async def other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> None: ... + + @abstractmethod + async def user_deauthorized_other_user_as_admin( + self, user: User, other_user: User | None, /, + ) -> None: ... diff --git a/src/ttt/application/user/common/ports/user_views.py b/src/ttt/application/user/common/ports/user_views.py index 145aa6f..19dca84 100644 --- a/src/ttt/application/user/common/ports/user_views.py +++ b/src/ttt/application/user/common/ports/user_views.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod +from ttt.entities.core.user.user import User + class CommonUserViews(ABC): @abstractmethod @@ -25,3 +27,79 @@ async def user_is_not_registered_view( @abstractmethod async def user_menu_view(self, user_id: int, /) -> None: ... + + @abstractmethod + async def user_authorized_as_admin_view( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_already_admin_to_get_admin_rights_view( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def admin_token_mismatch_to_get_admin_rights_view( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def not_admin_to_relinquish_admin_right_view( + self, + user: User, + /, + ) -> None: ... + + @abstractmethod + async def user_relinquished_admin_rights_view(self, user: User, /) -> None: + ... + + @abstractmethod + async def user_admin_view(self, user_id: int, /) -> None: + ... + + @abstractmethod + async def user_is_not_admin_view(self, user: User, /) -> None: + ... + + @abstractmethod + async def other_user_view(self, user: User, other_user_id: int, /) -> None: + ... + + @abstractmethod + async def not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_admin_view( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> None: + ... + + @abstractmethod + async def other_user_already_admin_to_authorize_other_user_as_admin_view( + self, user: User, other_user: User | None, /, + ) -> None: + ... + + @abstractmethod + async def user_authorized_other_user_as_admin_view( + self, user: User, other_user: User | None, /, + ) -> None: ... + + @abstractmethod + async def not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_admin_view( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> None: ... + + @abstractmethod + async def other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize_view( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> None: ... + + @abstractmethod + async def user_deauthorized_other_user_as_admin_view( + self, user: User, other_user: User | None, /, + ) -> None: ... diff --git a/src/ttt/application/user/deauthorize_other_user_as_admin.py b/src/ttt/application/user/deauthorize_other_user_as_admin.py new file mode 100644 index 0000000..5ce8ea7 --- /dev/null +++ b/src/ttt/application/user/deauthorize_other_user_as_admin.py @@ -0,0 +1,69 @@ +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_log import CommonUserLog +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users +from ttt.entities.core.user.user import ( + NotAuthorizedAsAdminViaAdminTokenError, + OtherUserIsNotAuthorizedAsAdminViaOtherAdminError, +) +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class DeauthorizeOtherUserAsAdmin: + transaction: Transaction + users: Users + map_: Map + log: CommonUserLog + views: CommonUserViews + + async def __call__(self, user_id: int, other_user_id: int) -> None: + async with self.transaction: + user, other_user = await self.users.users_with_ids( + (user_id, other_user_id), + ) + + if user is None: + await self.views.user_is_not_registered_view(user_id) + return + + try: + tracking = Tracking() + user.deauthorize_user_as_admin(other_user, tracking) + except NotAuthorizedAsAdminViaAdminTokenError: + await ( + self.log + .not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_admin( + user, other_user, + ) + ) + await ( + self.views + .not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_admin_view( + user, other_user, + ) + ) + except OtherUserIsNotAuthorizedAsAdminViaOtherAdminError: + await ( + self.log + .other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize( + user, other_user, + ) + ) + await ( + self.views + .other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize_view( + user, other_user, + ) + ) + else: + await self.log.user_deauthorized_other_user_as_admin( + user, other_user, + ) + await self.map_(tracking) + await self.views.user_deauthorized_other_user_as_admin_view( + user, other_user, + ) diff --git a/src/ttt/application/user/relinquish_admin_right.py b/src/ttt/application/user/relinquish_admin_right.py new file mode 100644 index 0000000..bd17fd2 --- /dev/null +++ b/src/ttt/application/user/relinquish_admin_right.py @@ -0,0 +1,37 @@ +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_log import CommonUserLog +from ttt.application.user.common.ports.user_views import CommonUserViews +from ttt.application.user.common.ports.users import Users +from ttt.entities.core.user.user import NotAdminError +from ttt.entities.tools.tracking import Tracking + + +@dataclass(frozen=True, unsafe_hash=False) +class RelinquishAdminRight: + transaction: Transaction + users: Users + map_: Map + log: CommonUserLog + views: CommonUserViews + + 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.views.user_is_not_registered_view(user_id) + return + + try: + tracking = Tracking() + user.relinquish_admin_right(tracking) + except NotAdminError: + await self.log.not_admin_to_relinquish_admin_right(user) + 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.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 new file mode 100644 index 0000000..f9e51b8 --- /dev/null +++ b/src/ttt/application/user/view_admin_menu.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.transaction import Transaction +from ttt.application.user.common.ports.user_views import CommonUserViews + + +@dataclass(frozen=True, unsafe_hash=False) +class ViewAdminMenu: + views: CommonUserViews + transaction: Transaction + + async def __call__(self, user_id: int) -> None: + async with self.transaction: + return await self.views.user_admin_view(user_id) diff --git a/src/ttt/application/user/view_other_user.py b/src/ttt/application/user/view_other_user.py new file mode 100644 index 0000000..58fbd75 --- /dev/null +++ b/src/ttt/application/user/view_other_user.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass + +from ttt.application.common.ports.transaction import Transaction +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 + + +@dataclass(frozen=True, unsafe_hash=False) +class ViewOtherUser: + views: CommonUserViews + users: Users + transaction: Transaction + log: CommonUserLog + + async def __call__(self, user_id: int, other_user_id: int) -> None: + async with self.transaction: + user = await self.users.user_with_id(user_id) + + if user is None: + await self.views.user_is_not_registered_view(user_id) + return + + if user.is_admin(): + await self.views.other_user_view(user, other_user_id) + else: + await self.views.user_is_not_admin_view(user) diff --git a/src/ttt/entities/atomic.py b/src/ttt/entities/atomic.py index ef9c253..1b87167 100644 --- a/src/ttt/entities/atomic.py +++ b/src/ttt/entities/atomic.py @@ -1,6 +1,18 @@ from ttt.entities.core.game.game import GameAtomic +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.user.user import UserAtomic from ttt.entities.finance.payment.payment import PaymentAtomic -type Atomic = GameAtomic | UserAtomic | PaymentAtomic +type Atomic = ( + GameAtomic + | UserAtomic + | MatchmakingQueueAtomic + | InvitationToGameAtomic + | PaymentAtomic +) diff --git a/src/ttt/entities/core/game/ai.py b/src/ttt/entities/core/game/ai.py index bad8854..37cb8ac 100644 --- a/src/ttt/entities/core/game/ai.py +++ b/src/ttt/entities/core/game/ai.py @@ -24,7 +24,7 @@ class AiWin: ai_id: UUID -@dataclass(frozen=True) +@dataclass class Ai: id: UUID type: AiType diff --git a/src/ttt/entities/core/game/game.py b/src/ttt/entities/core/game/game.py index 7416bf8..3fe57cf 100644 --- a/src/ttt/entities/core/game/game.py +++ b/src/ttt/entities/core/game/game.py @@ -28,6 +28,7 @@ PlayerLoss, PlayerWin, ) +from ttt.entities.core.user.location import UserGameLocation from ttt.entities.core.user.user import ( User, UserAlreadyInGameError, @@ -403,6 +404,9 @@ 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, @@ -532,7 +536,7 @@ def _complete_as_draw( GameAtomic = Game | Cell | Ai -@dataclass(frozen=True) +@dataclass class UsersAlreadyInGameError(Exception): users: Sequence[User] diff --git a/src/ttt/entities/core/invitation_to_game/__init__.py b/src/ttt/entities/core/invitation_to_game/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..8462c75 --- /dev/null +++ b/src/ttt/entities/core/invitation_to_game/invitation_to_game.py @@ -0,0 +1,179 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import Enum, auto +from typing import ClassVar +from uuid import UUID + +from ttt.entities.core.game.game import Game, start_game +from ttt.entities.core.user.user import User, register_user +from ttt.entities.math.matrix import Matrix +from ttt.entities.math.random import Random +from ttt.entities.text.emoji import Emoji +from ttt.entities.tools.assertion import assert_ +from ttt.entities.tools.tracking import Tracking + + +class InvitationToGameState(Enum): + active = auto() + auto_cancelled = auto() + cancelled_by_user = auto() + rejected = auto() + accepted = auto() + + +class InvitationSelfToGameError(Exception): ... + + +class InvitationToGameStateIsNotActiveError(Exception): ... + + +class UserIsNotInvitingUserError(Exception): ... + + +class UserIsNotInvitedUserError(Exception): ... + + +@dataclass +class InvitationToGame: + """ + :raises ttt.entities.core.invitation_to_game.InvitationSelfToGameError: + """ + + id_: UUID + inviting_user: User + invited_user: User + invitation_datetime: datetime + state: InvitationToGameState + + lifetime: ClassVar[timedelta] = timedelta(hours=4) + + def __post_init__(self) -> None: + assert_( + self.inviting_user.id != self.invited_user.id, + else_=InvitationSelfToGameError, + ) + + def expiration_datetime(self) -> datetime: + return self.invitation_datetime + InvitationToGame.lifetime + + def is_expired(self, current_datetime: datetime) -> bool: + return current_datetime >= self.expiration_datetime() + + def cancel(self, user_id: int, tracking: Tracking) -> None: + """ + :raises ttt.entities.core.invitation_to_game.UserIsNotInvitingUserError: + :raises ttt.entities.core.invitation_to_game.InvitationToGameStateIsNotActiveError: + """ # noqa: E501 + + assert_( + self.inviting_user.id == user_id, else_=UserIsNotInvitingUserError, + ) + assert_( + self.state is InvitationToGameState.active, + else_=InvitationToGameStateIsNotActiveError, + ) + + self.state = InvitationToGameState.cancelled_by_user + tracking.register_mutated(self) + + def reject(self, user_id: int, tracking: Tracking) -> None: + """ + :raises ttt.entities.core.invitation_to_game.UserIsNotInvitedUserError: + :raises ttt.entities.core.invitation_to_game.InvitationToGameStateIsNotActiveError: + """ # noqa: E501 + + assert_( + self.invited_user.id == user_id, else_=UserIsNotInvitingUserError, + ) + assert_( + self.state is InvitationToGameState.active, + else_=InvitationToGameStateIsNotActiveError, + ) + + self.state = InvitationToGameState.rejected + tracking.register_mutated(self) + + def accept( # noqa: PLR0913, PLR0917 + self, + user_id: int, + user_random_emoji: Emoji, + inviting_player_random_emoji: Emoji, + player_order_random: Random, + cell_id_matrix: Matrix[UUID], + game_id: UUID, + tracking: Tracking, + ) -> Game: + """ + :raises ttt.entities.core.invitation_to_game.UserIsNotInvitedUserError: + :raises ttt.entities.core.invitation_to_game.InvitationToGameStateIsNotActiveError: + :raises ttt.entities.core.game.game.SameRandomEmojiError: + :raises ttt.entities.core.game.game.UsersAlreadyInGameError: + :raises ttt.entities.core.game.board.InvalidCellIDMatrixError: + """ # noqa: E501 + + assert_( + self.invited_user.id == user_id, else_=UserIsNotInvitingUserError, + ) + assert_( + self.state is InvitationToGameState.active, + else_=InvitationToGameStateIsNotActiveError, + ) + + self.state = InvitationToGameState.accepted + tracking.register_mutated(self) + + if float(player_order_random) < 0.5: # noqa: PLR2004 + player1 = self.invited_user + player1_random_emoji = user_random_emoji + + player2 = self.inviting_user + player2_random_emoji = inviting_player_random_emoji + else: + player2 = self.invited_user + player2_random_emoji = user_random_emoji + + player1 = self.inviting_user + player1_random_emoji = inviting_player_random_emoji + + return start_game( + cell_id_matrix, + game_id, + player1, + player1_random_emoji, + player2, + player2_random_emoji, + tracking, + ) + + +def invitation_to_game_datetime(expiration_datetime: datetime) -> datetime: + return expiration_datetime - InvitationToGame.lifetime + + +InvitationToGameAtomic = InvitationToGame + + +def invite_to_game( # noqa: PLR0913, PLR0917 + user: User, + invited_user: User | None, + invited_user_id: int, + invitation_to_game_id: UUID, + current_datetime: datetime, + tracking: Tracking, +) -> "InvitationToGame": + """ + :raises ttt.entities.core.invitation_to_game.InvitationSelfToGameError: + """ + + if invited_user is None: + invited_user = register_user(invited_user_id, tracking) + + invitation_to_game = InvitationToGame( + id_=invitation_to_game_id, + inviting_user=user, + invited_user=invited_user, + invitation_datetime=current_datetime, + state=InvitationToGameState.active, + ) + tracking.register_new(invitation_to_game) + return invitation_to_game diff --git a/src/ttt/entities/core/matchmaking_queue/__init__.py b/src/ttt/entities/core/matchmaking_queue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ttt/entities/core/matchmaking_queue/matchmaking_queue.py b/src/ttt/entities/core/matchmaking_queue/matchmaking_queue.py new file mode 100644 index 0000000..bdcddc6 --- /dev/null +++ b/src/ttt/entities/core/matchmaking_queue/matchmaking_queue.py @@ -0,0 +1,87 @@ +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_queue.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 MatchmakingQueue: + 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 + 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_queue.matchmaking_queue.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: + return are_ranks_adjacent( + user_waiting1.user.rank(), user_waiting2.user.rank(), + ) + + +MatchmakingQueueAtomic = MatchmakingQueue | UserWaiting diff --git a/src/ttt/entities/core/matchmaking_queue/user_waiting.py b/src/ttt/entities/core/matchmaking_queue/user_waiting.py new file mode 100644 index 0000000..19bc224 --- /dev/null +++ b/src/ttt/entities/core/matchmaking_queue/user_waiting.py @@ -0,0 +1,12 @@ +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/admin_right.py b/src/ttt/entities/core/user/admin_right.py new file mode 100644 index 0000000..23d92c1 --- /dev/null +++ b/src/ttt/entities/core/user/admin_right.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AdminRightViaAdminToken: + ... + + +@dataclass(frozen=True) +class AdminRightViaOtherAdmin: + admin_id: int + + +type AdminRight = AdminRightViaAdminToken | AdminRightViaOtherAdmin diff --git a/src/ttt/entities/core/user/emoji.py b/src/ttt/entities/core/user/emoji.py index b73ccd5..b4a1a04 100644 --- a/src/ttt/entities/core/user/emoji.py +++ b/src/ttt/entities/core/user/emoji.py @@ -5,7 +5,7 @@ from ttt.entities.text.emoji import Emoji -@dataclass(frozen=True) +@dataclass class UserEmoji: id: UUID user_id: int diff --git a/src/ttt/entities/core/user/last_game.py b/src/ttt/entities/core/user/last_game.py index 16dcf55..82ada9b 100644 --- a/src/ttt/entities/core/user/last_game.py +++ b/src/ttt/entities/core/user/last_game.py @@ -4,7 +4,7 @@ from ttt.entities.tools.tracking import Tracking -@dataclass(frozen=True) +@dataclass class LastGame: id: UUID user_id: int diff --git a/src/ttt/entities/core/user/rank.py b/src/ttt/entities/core/user/rank.py new file mode 100644 index 0000000..f225931 --- /dev/null +++ b/src/ttt/entities/core/user/rank.py @@ -0,0 +1,45 @@ +import math +from dataclasses import dataclass +from typing import Literal + +from ttt.entities.elo.rating import EloRating + + +type RankTier = Literal[-1, 0, 1, 2, 3, 4] + + +@dataclass(frozen=True) +class Rank: + tier: RankTier + min_rating: EloRating + max_rating: EloRating + + +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), +) + + +def rank_with_tier(tier: RankTier) -> Rank: + for rank in ranks: + if rank.tier == tier: + return rank + + raise ValueError + + +def rank_for_rating(rating: EloRating) -> Rank: + for rank in ranks: + if rank.min_rating <= rating <= rank.max_rating: + return rank + + raise ValueError + + +def are_ranks_adjacent(rank1: Rank, rank2: Rank) -> bool: + return rank1.tier + 1 == rank2.tier or rank1.tier - 1 == rank2.tier diff --git a/src/ttt/entities/core/user/stars_purchase.py b/src/ttt/entities/core/user/stars_purchase.py index c276663..9b26e4d 100644 --- a/src/ttt/entities/core/user/stars_purchase.py +++ b/src/ttt/entities/core/user/stars_purchase.py @@ -15,7 +15,7 @@ class InvalidStarsForStarsPurchaseError(Exception): ... -@dataclass(frozen=True) +@dataclass class StarsPurchaseAlreadyCompletedError(Exception): is_cancelled: bool diff --git a/src/ttt/entities/core/user/user.py b/src/ttt/entities/core/user/user.py index 06b4dd1..8a3d40a 100644 --- a/src/ttt/entities/core/user/user.py +++ b/src/ttt/entities/core/user/user.py @@ -5,11 +5,17 @@ from ttt.entities.core.stars import Stars from ttt.entities.core.user.account import Account +from ttt.entities.core.user.admin_right import ( + AdminRight, + AdminRightViaAdminToken, + AdminRightViaOtherAdmin, +) 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 from ttt.entities.core.user.stars_purchase import StarsPurchase from ttt.entities.core.user.win import UserWin from ttt.entities.elo.rating import ( @@ -25,21 +31,22 @@ 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.tools.assertion import assert_ +from ttt.entities.text.token import Token +from ttt.entities.tools.assertion import assert_, not_none from ttt.entities.tools.tracking import Tracking -@dataclass(frozen=True) +@dataclass class UserAlreadyInGameError(Exception): user: "User" -@dataclass(frozen=True) +@dataclass class UserNotInGameError(Exception): user: "User" -@dataclass(frozen=True) +@dataclass class NotEnoughStarsError(Exception): stars_to_become_enough: Stars @@ -56,7 +63,28 @@ class NoPurchaseError(Exception): ... class UserAlreadyLeftGameError(Exception): ... -@dataclass +class UserAlreadyAdminError(Exception): ... + + +class OtherUserAlreadyAdminError(Exception): ... + + +class OtherUserIsNotAuthorizedAsAdminViaOtherAdminError(Exception): ... + + +class AdminTokenMismatchError(Exception): ... + + +class NotAdminError(Exception): ... + + +class NotAuthorizedAsAdminViaAdminTokenError(Exception): ... + + +class UserAlredyAdminToAuthorizeAsAdminError(Exception): ... + + +@dataclass # noqa: PLR0904 class User: id: int account: Account @@ -65,6 +93,7 @@ class User: last_games: list[LastGame] selected_emoji_id: UUID | None rating: EloRating + admin_right: AdminRight | None number_of_wins: int number_of_draws: int @@ -73,6 +102,139 @@ class User: emoji_cost: ClassVar[Stars] = 1000 + def rank(self) -> Rank: + return rank_for_rating(self.rating) + + def is_admin(self) -> bool: + return is_user_admin(self.admin_right) + + def authorize_as_admin( + self, + user_admin_token: Token, + original_admin_token: Token, + tracking: Tracking, + ) -> None: + """ + :raises ttt.entities.core.user.user.UserAlreadyAdminError: + :raises ttt.entities.core.user.user.AdminTokenMismatchError: + """ + + assert_(not self.is_admin(), else_=UserAlreadyAdminError) + assert_( + user_admin_token == original_admin_token, + else_=AdminTokenMismatchError, + ) + + self.admin_right = AdminRightViaAdminToken() + tracking.register_mutated(self) + + def relinquish_admin_right( + self, + tracking: Tracking, + ) -> None: + """ + :raises ttt.entities.core.user.user.NotAdminError: + """ + + assert_(self.is_admin(), else_=NotAdminError) + + self.admin_right = None + tracking.register_mutated(self) + + def authorize_user_as_admin( + self, + user: "User | None", + user_id: int, + tracking: Tracking, + ) -> None: + """ + :raises ttt.entities.core.user.user.NotAuthorizedAsAdminViaAdminTokenError: + :raises ttt.entities.core.user.user.OtherUserAlreadyAdminError: + """ # noqa: E501 + + assert_( + isinstance(self.admin_right, AdminRightViaAdminToken), + else_=NotAuthorizedAsAdminViaAdminTokenError, + ) + + if user is None: + user = register_user(user_id, tracking) + else: + assert_(not user.is_admin(), else_=OtherUserAlreadyAdminError) + + user.admin_right = AdminRightViaOtherAdmin(admin_id=self.id) + tracking.register_mutated(user) + + def deauthorize_user_as_admin( + self, user: "User | None", tracking: Tracking, + ) -> None: + """ + :raises ttt.entities.core.user.user.NotAuthorizedAsAdminViaAdminTokenError: + :raises ttt.entities.core.user.user.OtherUserIsNotAuthorizedAsAdminViaOtherAdminError: + """ # noqa: E501 + + assert_( + isinstance(self.admin_right, AdminRightViaAdminToken), + else_=NotAuthorizedAsAdminViaAdminTokenError, + ) + + user = not_none( + user, else_=OtherUserIsNotAuthorizedAsAdminViaOtherAdminError, + ) + assert_( + isinstance(user.admin_right, AdminRightViaOtherAdmin), + else_=OtherUserIsNotAuthorizedAsAdminViaOtherAdminError, + ) + + user.admin_right = None + tracking.register_mutated(user) + + def change_user_account( + self, + user: "User | None", + user_id: int, + user_account_stars_vector: Stars, + tracking: Tracking, + ) -> "User": + """ + :raises ttt.entities.core.user.user.NotAdminError: + :raises ttt.entities.user.account.NegativeAccountError: + """ + + assert_(self.is_admin(), else_=NotAdminError) + + if user is None: + user = register_user(user_id, tracking) + + user.account = user.account.map( + lambda stars: stars + user_account_stars_vector, + ) + tracking.register_mutated(user) + + return user + + def set_user_account( + self, + user: "User | None", + user_id: int, + user_account_stars: Stars, + tracking: Tracking, + ) -> "User": + """ + :raises ttt.entities.core.user.user.NotAdminError: + :raises ttt.entities.user.account.NegativeAccountError: + """ + + assert_(self.is_admin(), else_=NotAdminError) + + if user is None: + user = register_user(user_id, tracking) + + user.account = user.account.map(lambda _: user_account_stars) + tracking.register_mutated(user) + + return user + def games_played(self) -> int: return len(self.last_games) @@ -435,6 +597,7 @@ def register_user(user_id: int, tracking: Tracking) -> User: number_of_draws=0, number_of_defeats=0, game_location=None, + admin_right=None, ) tracking.register_new(user) @@ -443,3 +606,11 @@ def register_user(user_id: int, tracking: Tracking) -> User: def is_user_in_game(game_location: UserGameLocation | None) -> bool: return game_location is not None + + +def is_user_admin(admin_right: AdminRight | None) -> bool: + return admin_right is not None + + +def user_stars(stars: Stars | None) -> Stars: + return 0 if stars is None else stars diff --git a/src/ttt/entities/text/token.py b/src/ttt/entities/text/token.py new file mode 100644 index 0000000..ffc8117 --- /dev/null +++ b/src/ttt/entities/text/token.py @@ -0,0 +1 @@ +type Token = str diff --git a/src/ttt/entities/tools/tracking.py b/src/ttt/entities/tools/tracking.py index e638601..0edd117 100644 --- a/src/ttt/entities/tools/tracking.py +++ b/src/ttt/entities/tools/tracking.py @@ -35,6 +35,7 @@ def register_mutated(self, it: T) -> None: def register_unused(self, it: T) -> None: if it in self.new: self.new.remove(it) + return if it in self.mutated: self.mutated.remove(it) diff --git a/src/ttt/infrastructure/adapters/game_log.py b/src/ttt/infrastructure/adapters/game_log.py index e3bb996..190a1d8 100644 --- a/src/ttt/infrastructure/adapters/game_log.py +++ b/src/ttt/infrastructure/adapters/game_log.py @@ -1,5 +1,3 @@ -from asyncio import gather -from collections.abc import Sequence from dataclasses import dataclass from structlog.types import FilteringBoundLogger @@ -14,26 +12,6 @@ class StructlogGameLog(GameLog): _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( - "waiting_for_game_start", - user_id=user_id, - ) - async def game_against_user_started( self, game: Game, @@ -44,16 +22,6 @@ async def game_against_user_started( game_id=game.id.hex, ) - async def user_intends_to_start_game_against_ai( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "user_intends_to_start_game_against_ai", - user_id=user_id, - ) - async def game_against_ai_started( self, game: Game, @@ -117,16 +85,6 @@ async def game_completed( game_id=game.id.hex, ) - async def user_already_in_game_to_start_game( - self, - user: User, - /, - ) -> None: - await self._logger.ainfo( - "user_already_in_game_to_start_game", - user_id=user.id, - ) - async def already_completed_game_to_make_move( self, game: Game, @@ -195,34 +153,14 @@ async def already_completed_game_to_cancel( game_id=game.id.hex, ) - async def users_already_in_game_to_start_game_via_game_starting_queue( - self, - user_ids: Sequence[int], - /, - ) -> None: - await gather( - *( - self._logger.awarning( - "user_already_in_game_to_start_game_via_game_starting_queue", - user_id=user_id, - ) - for user_id in user_ids - ), - ) - - async def bad_attempt_to_start_game_via_game_starting_queue( + async def user_already_in_game_to_start_game_against_ai( self, - user_ids: Sequence[int], + user: User, /, ) -> None: - await gather( - *( - self._logger.awarning( - "bad_attempt_to_start_game_via_game_starting_queue", - user_id=user_id, - ) - for user_id in user_ids - ), + await self._logger.ainfo( + "user_already_in_game_to_start_game_against_ai", + user_id=user.id, ) async def current_game_viewed( @@ -234,13 +172,3 @@ async def current_game_viewed( "current_game_viewed", user_id=user_id, ) - - async def user_intends_to_start_game( - self, - user_id: int, - /, - ) -> None: - await self._logger.ainfo( - "user_intends_to_start_game", - user_id=user_id, - ) diff --git a/src/ttt/infrastructure/adapters/game_starting_queue.py b/src/ttt/infrastructure/adapters/game_starting_queue.py deleted file mode 100644 index 97c0906..0000000 --- a/src/ttt/infrastructure/adapters/game_starting_queue.py +++ /dev/null @@ -1,53 +0,0 @@ -from collections.abc import AsyncIterator, Sequence -from dataclasses import dataclass -from typing import cast - -from ttt.application.game.game.ports.game_starting_queue import ( - GameStartingQueue, - GameStartingQueuePush, -) -from ttt.infrastructure.redis.batches import InRedisFixedBatches - - -@dataclass(frozen=True, unsafe_hash=False) -class InRedisFixedBatchesWaitingLocations(GameStartingQueue): - _batches: InRedisFixedBatches - - async def push_many(self, user_ids: Sequence[int], /) -> None: - if user_ids: - await self._batches.add(map(self._bytes, user_ids)) - - async def push(self, user_id: int, /) -> GameStartingQueuePush: - push_code = await self._batches.add([self._bytes(user_id)]) - was_location_added_in_set = bool(push_code) - - return GameStartingQueuePush( - was_location_dedublicated=not was_location_added_in_set, - ) - - async def __aiter__( - self, - ) -> AsyncIterator[tuple[int, int]]: - async for user1_id_bytes, user2_id_bytes in self._batches.with_len(2): - user1_id = self._user_id(user1_id_bytes) - user2_id = self._user_id(user2_id_bytes) - - user_ids = (user1_id, user2_id) - ok_user_ids = tuple( - user_id for user_id in user_ids if user_id is not None - ) - - if len(user_ids) != len(ok_user_ids): - await self._batches.add(map(self._bytes, ok_user_ids)) - continue - - yield cast(tuple[int, int], ok_user_ids) - - def _bytes(self, user_id: int) -> bytes: - return str(user_id).encode() - - def _user_id(self, bytes_: bytes) -> int | None: - try: - return int(bytes_.decode()) - except ValueError: - return None diff --git a/src/ttt/infrastructure/adapters/invitation_to_game_dao.py b/src/ttt/infrastructure/adapters/invitation_to_game_dao.py new file mode 100644 index 0000000..6cb7149 --- /dev/null +++ b/src/ttt/infrastructure/adapters/invitation_to_game_dao.py @@ -0,0 +1,40 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from sqlalchemy import update +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.application.invitation_to_game.game.ports.invitation_to_game_dao import ( # noqa: E501 + InvitationToGameDao, +) +from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( + TableInvitationToGame, + TableInvitationToGameState, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class PostgresInvitationToGameDao(InvitationToGameDao): + _session: AsyncSession + + async def set_auto_cancelled_where_invitation_datetime_le_and_active( + self, + datetime: datetime, + /, + ) -> Sequence[UUID]: + stmt = ( + update(TableInvitationToGame) + .values(state=TableInvitationToGameState.auto_cancelled.value) + .where( + (TableInvitationToGame.invitation_datetime <= datetime) + & ( + TableInvitationToGame.state + == TableInvitationToGameState.active.value + ), + ) + .returning(TableInvitationToGame.id) + ) + result = await self._session.scalars(stmt) + return result.all() diff --git a/src/ttt/infrastructure/adapters/invitation_to_game_log.py b/src/ttt/infrastructure/adapters/invitation_to_game_log.py new file mode 100644 index 0000000..b217c3d --- /dev/null +++ b/src/ttt/infrastructure/adapters/invitation_to_game_log.py @@ -0,0 +1,286 @@ +from asyncio import gather +from collections.abc import Sequence +from dataclasses import dataclass +from uuid import UUID + +from structlog.types import FilteringBoundLogger + +from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 + InvitationToGameLog, +) +from ttt.entities.core.game.game import Game +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationToGame, + InvitationToGameState, +) +from ttt.entities.core.user.user import User + + +def invitation_to_game_state_in_log(state: InvitationToGameState) -> str: + match state: + case InvitationToGameState.active: + return "active" + + case InvitationToGameState.auto_cancelled: + return "auto_cancelled" + + case InvitationToGameState.cancelled_by_user: + return "cancelled_by_user" + + case InvitationToGameState.rejected: + return "rejected" + + case InvitationToGameState.accepted: + return "accepted" + + +@dataclass(frozen=True, unsafe_hash=False) +class StructlogInvitationToGameLog(InvitationToGameLog): + _logger: FilteringBoundLogger + + async def invitation_self_to_game( + self, + user: User, + /, + ) -> None: + await self._logger.ainfo( + "invitation_self_to_game", + chat_id=user.id, + user_id=user.id, + ) + + async def user_invited_other_user_to_game( + self, + invitation_to_game: InvitationToGame, + /, + ) -> 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_, + ) + + async def double_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + /, + ) -> 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, + ) + + async def invitation_to_game_is_not_active_to_cancel( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> 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_state=invitation_to_game_state_in_log( + invitation_to_game.state, + ), + ) + + async def user_is_not_inviting_user_to_cancel_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> 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_, + ) + + async def invitation_to_game_is_not_active_to_reject( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> 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_state=invitation_to_game_state_in_log( + invitation_to_game.state, + ), + ) + + async def user_is_not_invited_user_to_reject_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> 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_, + ) + + async def invitation_to_game_is_not_active_to_accept( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> 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_state=invitation_to_game_state_in_log( + invitation_to_game.state, + ), + ) + + async def user_is_not_invited_user_to_accept_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> 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_, + ) + + async def users_already_in_game_to_accept_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + users_in_game: Sequence[User], + /, + ) -> 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_, + is_invited_user_in_game=( + invitation_to_game.invited_user in users_in_game + ), + is_inviting_user_in_game=( + invitation_to_game.inviting_user in users_in_game + ), + ) + + async def user_cancelled_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + /, + ) -> 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_, + ) + + async def user_rejected_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + /, + ) -> 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_, + ) + + async def user_accepted_invitation_to_game( + self, + invitation_to_game: InvitationToGame, + game: 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_, + ) + + async def no_invitation_to_game_to_accept( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> 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, + ) + + async def no_invitation_to_game_to_reject( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> 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, + ) + + async def no_invitation_to_game_to_cancel( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> 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, + ) + + async def invitations_to_game_auto_cancelled( + self, + ids: Sequence[UUID], + /, + ) -> None: + await gather(*( + self._logger.ainfo( + "invitation_to_game_auto_cancelled", + invitation_to_game_id=invitation_to_game_id, + ) + for invitation_to_game_id in ids + )) + + async def no_invitation_to_game_to_auto_cancel( + self, invitation_to_game_id: UUID, /, + ) -> None: + await self._logger.awarning( + "no_invitation_to_game_to_auto_cancel", + invitation_to_game_id=invitation_to_game_id, + ) + + async def not_expired_invitation_to_game_to_auto_cancel( + self, invitation_to_game: InvitationToGame, /, + ) -> None: + await self._logger.aerror( + "not_expired_invitation_to_game_to_auto_cancel", + invitation_to_game_id=invitation_to_game.id_, + ) + + async def invitation_to_game_state_is_not_active_to_game_to_auto_cancel( + self, invitation_to_game: InvitationToGame, /, + ) -> 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_, + ) diff --git a/src/ttt/infrastructure/adapters/invitations_to_game.py b/src/ttt/infrastructure/adapters/invitations_to_game.py new file mode 100644 index 0000000..8b1ac4a --- /dev/null +++ b/src/ttt/infrastructure/adapters/invitations_to_game.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy.dialects.postgresql.base import ( # type: ignore[attr-defined] + select, +) +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.application.invitation_to_game.game.ports.invitations_to_game import ( + InvitationsToGame, +) +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationToGame, +) +from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( + TableInvitationToGame, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class InPostgresInvitationsToGame(InvitationsToGame): + _session: AsyncSession + + async def invitation_to_game_with_id( + self, id_: UUID, /, + ) -> InvitationToGame | None: + stmt = ( + select(TableInvitationToGame) + .where(TableInvitationToGame.id == id_) + .with_for_update() + ) + table_invitation_to_game = await self._session.scalar(stmt) + + if table_invitation_to_game is None: + return None + + return table_invitation_to_game.entity() diff --git a/src/ttt/infrastructure/adapters/map.py b/src/ttt/infrastructure/adapters/map.py index ca77f76..8ef3fcb 100644 --- a/src/ttt/infrastructure/adapters/map.py +++ b/src/ttt/infrastructure/adapters/map.py @@ -7,9 +7,13 @@ from ttt.application.common.ports.map import ( Map, MappableTracking, + NotUniqueActiveInvitationToGameUserIdsError, NotUniqueUserIdError, ) -from ttt.infrastructure.sqlalchemy.tables.atomic import table_atomic +from ttt.infrastructure.sqlalchemy.tables.atomic import ( + linked_table_atomic, + mapped_table_atomic, +) @dataclass(frozen=True, unsafe_hash=True) @@ -20,14 +24,17 @@ async def __call__( self, tracking: MappableTracking, ) -> None: - for entity in tracking.new: - self._session.add(table_atomic(entity)) + for table_entity in map(mapped_table_atomic, tracking.new): + if table_entity is not None: + self._session.add(table_entity) - for entity in tracking.mutated: - await self._session.merge(table_atomic(entity)) + for table_entity in map(mapped_table_atomic, tracking.mutated): + if table_entity is not None: + await self._session.merge(table_entity) - for entity in tracking.unused: - await self._session.delete(table_atomic(entity)) + for table_entity in map(linked_table_atomic, tracking.unused): + if table_entity is not None: + await self._session.delete(table_entity) try: await self._session.flush() @@ -41,6 +48,9 @@ def _handle_integrity_error(self, error: IntegrityError) -> None: if constraint_name == "users_pkey": raise NotUniqueUserIdError from error + + if constraint_name == "ix_invitations_to_game_user_ids": + raise NotUniqueActiveInvitationToGameUserIdsError from error case _: ... raise error from error diff --git a/src/ttt/infrastructure/adapters/matchmaking_queue_log.py b/src/ttt/infrastructure/adapters/matchmaking_queue_log.py new file mode 100644 index 0000000..221183f --- /dev/null +++ b/src/ttt/infrastructure/adapters/matchmaking_queue_log.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass + +from structlog.types import FilteringBoundLogger + +from ttt.application.matchmaking_queue.common.matchmaking_queue_log import ( + CommonMatchmakingQueueLog, +) + + +@dataclass(frozen=True, unsafe_hash=False) +class StructlogCommonMatchmakingQueueLog(CommonMatchmakingQueueLog): + _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_add_to_matchmaking_queue( + self, + user_id: int, + /, + ) -> None: + await self._logger.ainfo( + "user_already_in_game_to_add_to_matchmaking_queue", + user_id=user_id, + ) diff --git a/src/ttt/infrastructure/adapters/original_admin_token.py b/src/ttt/infrastructure/adapters/original_admin_token.py new file mode 100644 index 0000000..8deecde --- /dev/null +++ b/src/ttt/infrastructure/adapters/original_admin_token.py @@ -0,0 +1,19 @@ +from collections.abc import Generator +from dataclasses import dataclass +from typing import Any + +from ttt.application.user.common.ports.original_admin_token import ( + OriginalAdminToken, +) +from ttt.entities.text.token import Token + + +@dataclass(frozen=True) +class TokenAsOriginalAdminToken(OriginalAdminToken): + _token: Token + + def __await__(self) -> Generator[Any, None, Token]: + return self._get_token().__await__() + + async def _get_token(self) -> Token: + return self._token diff --git a/src/ttt/infrastructure/adapters/shared_matchmaking_queue.py b/src/ttt/infrastructure/adapters/shared_matchmaking_queue.py new file mode 100644 index 0000000..f91045a --- /dev/null +++ b/src/ttt/infrastructure/adapters/shared_matchmaking_queue.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_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/adapters/transaction.py b/src/ttt/infrastructure/adapters/transaction.py index 6f922d4..017bb9f 100644 --- a/src/ttt/infrastructure/adapters/transaction.py +++ b/src/ttt/infrastructure/adapters/transaction.py @@ -15,9 +15,20 @@ class InPostgresTransaction(Transaction): init=False, default=None, ) + _nesting_counter: int = field( + init=False, + default=0, + ) async def __aenter__(self) -> Self: - self._transaction = await self._session.begin() + self._nesting_counter += 1 + + 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() + return self async def __aexit__( @@ -26,5 +37,10 @@ async def __aexit__( error: BaseException | None, traceback: TracebackType | None, ) -> None: - transaction = not_none(self._transaction) - return await transaction.__aexit__(error_type, error, traceback) + self._nesting_counter -= 1 + + if self._nesting_counter == 0: + transaction = not_none(self._transaction) + await transaction.__aexit__(error_type, error, traceback) + self._transaction = None + return diff --git a/src/ttt/infrastructure/adapters/user_log.py b/src/ttt/infrastructure/adapters/user_log.py index 4097c4f..b68f517 100644 --- a/src/ttt/infrastructure/adapters/user_log.py +++ b/src/ttt/infrastructure/adapters/user_log.py @@ -3,6 +3,9 @@ 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 ( @@ -77,6 +80,117 @@ async def emoji_menu_viewed(self, user_id: int) -> None: user_id=user_id, ) + async def user_authorized_as_admin( + self, + user: User, + /, + ) -> None: + await self._logger.ainfo( + "user_authorized_as_admin", + chat_id=user.id, + user_id=user.id, + ) + + async def user_already_admin_to_get_admin_rights( + self, + user: User, + /, + ) -> None: + await self._logger.ainfo( + "user_already_admin_to_get_admin_rights", + chat_id=user.id, + user_id=user.id, + ) + + async def admin_token_mismatch_to_get_admin_rights( + self, + user: User, + /, + ) -> None: + await self._logger.ainfo( + "admin_token_mismatch_to_get_admin_rights", + chat_id=user.id, + user_id=user.id, + ) + + async def not_admin_to_relinquish_admin_right( + self, + user: User, + /, + ) -> 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, + ) + + async def not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_admin( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> 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, + ) + + async def other_user_already_admin_to_authorize_other_user_as_admin( + self, user: User, other_user: User | None, /, + ) -> 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, + ) + + async def user_authorized_other_user_as_admin( + self, user: User, other_user: User | None, /, + ) -> 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, + ) + + async def not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_admin( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> 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, + ) + + async def other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> 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, + ) + + async def user_deauthorized_other_user_as_admin( + self, user: User, other_user: User | None, /, + ) -> 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, + ) + @dataclass(frozen=True, unsafe_hash=False) class StructlogEmojiPurchaseUserLog(EmojiPurchaseUserLog): @@ -265,3 +379,99 @@ async def 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 + + async def user_is_not_admin_to_set_other_user_account( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars: Stars, + /, + ) -> None: + await self._logger.ainfo( + "user_is_not_admin_to_set_other_user_account", + user_id=user.id, + other_user_id=other_user_id, + other_user_account_stars=other_user_account_stars, + ) + + async def user_is_not_admin_to_change_other_user_account( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars_vector: Stars, + /, + ) -> None: + await self._logger.ainfo( + "user_is_not_admin_to_change_other_user_account", + user_id=user.id, + other_user_id=other_user_id, + other_user_account=( + None if other_user is None else other_user.account.stars + ), + other_user_account_stars_vector=other_user_account_stars_vector, + ) + + async def user_set_other_user_account( + self, user: User, other_user: User, /, + ) -> None: + await self._logger.ainfo( + "user_set_other_user_account", + user_id=user.id, + other_user_id=other_user.id, + other_user_account_stars=other_user.account.stars, + ) + + async def user_changed_other_user_account( + self, + user: User, + other_user: User, + other_user_account_stars_vector: Stars, + /, + ) -> None: + await self._logger.ainfo( + "user_changed_other_user_account", + user_id=user.id, + other_user_id=other_user.id, + other_user_account_stars=other_user.account.stars, + other_user_account_stars_vector=other_user_account_stars_vector, + ) + + async def negative_account_on_change_other_user_account( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars_vector: Stars, + /, + ) -> None: + await self._logger.ainfo( + "negative_account_on_change_other_user_account", + user_id=user.id, + other_user_id=other_user_id, + other_user_account_stars_vector=other_user_account_stars_vector, + other_user_account_stars=( + None if other_user is None else other_user.account.stars + ), + ) + + async def negative_account_on_set_other_user_account( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars: Stars, + /, + ) -> None: + await self._logger.ainfo( + "negative_account_on_change_other_user_account", + user_id=user.id, + other_user_id=other_user_id, + other_user_account_stars=other_user_account_stars, + ) diff --git a/src/ttt/infrastructure/alembic/versions/08f3884d27ff_use_admin_right_instead_of_role_s.py b/src/ttt/infrastructure/alembic/versions/08f3884d27ff_use_admin_right_instead_of_role_s.py new file mode 100644 index 0000000..ddc6b73 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/08f3884d27ff_use_admin_right_instead_of_role_s.py @@ -0,0 +1,166 @@ +""" +use `admin_right` instead of `role`s. + +Revision ID: 08f3884d27ff +Revises: 974e328b8043 +Create Date: 2025-09-05 17:18:21.659565 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "08f3884d27ff" +down_revision: str | None = "974e328b8043" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + sa.Enum("via_admin_token", "via_other_admin", name="admin_right").create( + op.get_bind(), + ) + op.add_column( + "users", + sa.Column( + "admin_right", + postgresql.ENUM( + "via_admin_token", + "via_other_admin", + name="admin_right", + create_type=False, + ), + nullable=True, + ), + ) + op.add_column( + "users", + sa.Column( + "admin_right_via_other_admin_admin_id", + sa.BigInteger(), + nullable=True, + ), + ) + op.drop_index( + op.f("ix_users_role_not_root_admin_root_admin_id"), + table_name="users", + postgresql_where="(role_not_root_admin_root_admin_id IS NOT NULL)", + ) + op.create_index( + "ix_users_admin_right", + "users", + ["admin_right"], + unique=False, + postgresql_where=sa.text("admin_right IS NOT NULL"), + ) + op.create_index( + "ix_users_admin_right_via_other_admin_admin_id", + "users", + ["admin_right_via_other_admin_admin_id"], + unique=False, + postgresql_where=sa.text( + "admin_right_via_other_admin_admin_id IS NOT NULL", + ), + ) + op.drop_constraint( + op.f("users_role_not_root_admin_root_admin_id_fkey"), + "users", + type_="foreignkey", + ) + op.create_foreign_key( + "users_admin_right_via_other_admin_admin_id_fkey", + "users", + "users", + ["admin_right_via_other_admin_admin_id"], + ["id"], + initially="DEFERRED", + deferrable=True, + ) + op.drop_column("users", "role_not_root_admin_root_admin_id") + op.drop_column("users", "role") + sa.Enum( + "root_admin", + "not_root_admin", + "regular_user", + name="user_role", + ).drop(op.get_bind()) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + sa.Enum( + "root_admin", + "not_root_admin", + "regular_user", + name="user_role", + ).create(op.get_bind()) + op.add_column( + "users", + sa.Column( + "role", + postgresql.ENUM( + "root_admin", + "not_root_admin", + "regular_user", + name="user_role", + create_type=False, + ), + server_default=sa.text("'regular_user'::user_role"), + autoincrement=False, + nullable=False, + ), + ) + op.add_column( + "users", + sa.Column( + "role_not_root_admin_root_admin_id", + sa.BIGINT(), + autoincrement=False, + nullable=True, + ), + ) + op.drop_constraint( + "users_admin_right_via_other_admin_admin_id_fkey", + "users", + type_="foreignkey", + ) + op.create_foreign_key( + op.f("users_role_not_root_admin_root_admin_id_fkey"), + "users", + "users", + ["role_not_root_admin_root_admin_id"], + ["id"], + initially="DEFERRED", + deferrable=True, + ) + op.drop_index( + "ix_users_admin_right_via_other_admin_admin_id", + table_name="users", + postgresql_where=sa.text( + "admin_right_via_other_admin_admin_id IS NOT NULL", + ), + ) + op.drop_index( + "ix_users_admin_right", + table_name="users", + postgresql_where=sa.text("admin_right IS NOT NULL"), + ) + op.create_index( + op.f("ix_users_role_not_root_admin_root_admin_id"), + "users", + ["role_not_root_admin_root_admin_id"], + unique=False, + postgresql_where="(role_not_root_admin_root_admin_id IS NOT NULL)", + ) + op.drop_column("users", "admin_right_via_other_admin_admin_id") + op.drop_column("users", "admin_right") + sa.Enum("via_admin_token", "via_other_admin", name="admin_right").drop( + op.get_bind(), + ) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/alembic/versions/325e2d4a600b_add_ix_invitations_to_game_invitation_.py b/src/ttt/infrastructure/alembic/versions/325e2d4a600b_add_ix_invitations_to_game_invitation_.py new file mode 100644 index 0000000..55710ff --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/325e2d4a600b_add_ix_invitations_to_game_invitation_.py @@ -0,0 +1,41 @@ +""" +add `ix_invitations_to_game_invitation_datetime`. + +Revision ID: 325e2d4a600b +Revises: 5fd844c21a61 +Create Date: 2025-09-12 06:02:02.399393 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = "325e2d4a600b" +down_revision: str | None = "5fd844c21a61" +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( + "ix_invitations_to_game_invitation_datetime", + "invitations_to_game", + ["invitation_datetime"], + unique=False, + postgresql_where=sa.text("state = 'active'"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "ix_invitations_to_game_invitation_datetime", + table_name="invitations_to_game", + postgresql_where=sa.text("state = 'active'"), + ) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/alembic/versions/5ba4cd32c047_add_matchmaking_queue_user_waitings.py b/src/ttt/infrastructure/alembic/versions/5ba4cd32c047_add_matchmaking_queue_user_waitings.py new file mode 100644 index 0000000..0a81fc7 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/5ba4cd32c047_add_matchmaking_queue_user_waitings.py @@ -0,0 +1,50 @@ +""" +add `matchmaking_queue_user_waitings`. + +Revision ID: 5ba4cd32c047 +Revises: 982aa776c214 +Create Date: 2025-08-28 11:06:51.152485 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +revision: str = "5ba4cd32c047" +down_revision: str | None = "982aa776c214" +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_table( + "matchmaking_queue_user_waitings", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("start_datetime", sa.DateTime(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], initially="DEFERRED", deferrable=True, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_matchmaking_queue_user_waitings_user_id"), + "matchmaking_queue_user_waitings", + ["user_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_matchmaking_queue_user_waitings_user_id"), + table_name="matchmaking_queue_user_waitings", + ) + op.drop_table("matchmaking_queue_user_waitings") + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/alembic/versions/5fd844c21a61_add_invitations_to_game.py b/src/ttt/infrastructure/alembic/versions/5fd844c21a61_add_invitations_to_game.py new file mode 100644 index 0000000..05448ea --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/5fd844c21a61_add_invitations_to_game.py @@ -0,0 +1,112 @@ +""" +add `invitations_to_game`. + +Revision ID: 5fd844c21a61 +Revises: 08f3884d27ff +Create Date: 2025-09-11 08:39:12.127082 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "5fd844c21a61" +down_revision: str | None = "08f3884d27ff" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + sa.Enum( + "active", + "auto_cancelled", + "cancelled_by_user", + "rejected", + "accepted", + name="invitation_to_game_state", + ).create(op.get_bind()) + op.create_table( + "invitations_to_game", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("inviting_user_id", sa.BigInteger(), nullable=False), + sa.Column("invited_user_id", sa.BigInteger(), nullable=False), + sa.Column("invitation_datetime", sa.DateTime(), nullable=False), + sa.Column( + "state", + postgresql.ENUM( + "active", + "auto_cancelled", + "cancelled_by_user", + "rejected", + "accepted", + name="invitation_to_game_state", + create_type=False, + ), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["invited_user_id"], + ["users.id"], + initially="DEFERRED", + deferrable=True, + ), + sa.ForeignKeyConstraint( + ["inviting_user_id"], + ["users.id"], + initially="DEFERRED", + deferrable=True, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_invitations_to_game_invited_user_id"), + "invitations_to_game", + ["invited_user_id"], + unique=False, + ) + op.create_index( + op.f("ix_invitations_to_game_inviting_user_id"), + "invitations_to_game", + ["inviting_user_id"], + unique=False, + ) + op.create_index( + "ix_invitations_to_game_user_ids", + "invitations_to_game", + ["inviting_user_id", "invited_user_id"], + unique=True, + postgresql_where=sa.text("state = 'active'"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "ix_invitations_to_game_user_ids", + table_name="invitations_to_game", + postgresql_where=sa.text("state = 'active'"), + ) + op.drop_index( + op.f("ix_invitations_to_game_inviting_user_id"), + table_name="invitations_to_game", + ) + op.drop_index( + op.f("ix_invitations_to_game_invited_user_id"), + table_name="invitations_to_game", + ) + op.drop_table("invitations_to_game") + sa.Enum( + "active", + "auto_cancelled", + "cancelled_by_user", + "rejected", + "accepted", + name="invitation_to_game_state", + ).drop(op.get_bind()) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/alembic/versions/6eddb11b02fa_add_role_to_users.py b/src/ttt/infrastructure/alembic/versions/6eddb11b02fa_add_role_to_users.py new file mode 100644 index 0000000..41447e1 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/6eddb11b02fa_add_role_to_users.py @@ -0,0 +1,44 @@ +""" +add `role` to `users`. + +Revision ID: 6eddb11b02fa +Revises: f969a41d60f0 +Create Date: 2025-08-21 12:36:24.446161 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision: str = "6eddb11b02fa" +down_revision: str | None = "f969a41d60f0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + sa.Enum("admin", "regular_user", name="user_role").create(op.get_bind()) + op.add_column( + "users", + sa.Column( + "role", + postgresql.ENUM( + "admin", "regular_user", name="user_role", create_type=False, + ), + server_default="regular_user", + nullable=False, + ), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("users", "role") + sa.Enum("admin", "regular_user", name="user_role").drop(op.get_bind()) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/alembic/versions/974e328b8043_add_not_root_admins.py b/src/ttt/infrastructure/alembic/versions/974e328b8043_add_not_root_admins.py new file mode 100644 index 0000000..484abc6 --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/974e328b8043_add_not_root_admins.py @@ -0,0 +1,97 @@ +""" +add not root admins. + +Revision ID: 974e328b8043 +Revises: 5ba4cd32c047 +Create Date: 2025-09-03 17:20:36.803926 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from alembic_postgresql_enum import TableReference + + +revision: str = "974e328b8043" +down_revision: str | None = "5ba4cd32c047" +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( + "users", + sa.Column( + "role_not_root_admin_root_admin_id", + sa.BigInteger(), + nullable=True, + ), + ) + op.create_index( + "ix_users_role_not_root_admin_root_admin_id", + "users", + ["role_not_root_admin_root_admin_id"], + unique=False, + postgresql_where=sa.text( + "role_not_root_admin_root_admin_id IS NOT NULL", + ), + ) + op.create_foreign_key( + "users_role_not_root_admin_root_admin_id_fkey", + "users", + "users", + ["role_not_root_admin_root_admin_id"], + ["id"], + initially="DEFERRED", + deferrable=True, + ) + op.sync_enum_values( # type: ignore[attr-defined] + enum_schema="public", + enum_name="user_role", + new_values=["root_admin", "not_root_admin", "regular_user"], + affected_columns=[ + TableReference( + table_schema="public", + table_name="users", + column_name="role", + existing_server_default="'regular_user'::user_role", + ), + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( # type: ignore[attr-defined] + enum_schema="public", + enum_name="user_role", + new_values=["root_admin", "regular_user"], + affected_columns=[ + TableReference( + table_schema="public", + table_name="users", + column_name="role", + existing_server_default="'regular_user'::user_role", + ), + ], + enum_values_to_rename=[], + ) + op.drop_constraint( + "users_role_not_root_admin_root_admin_id_fkey", + "users", + type_="foreignkey", + ) + op.drop_index( + "ix_users_role_not_root_admin_root_admin_id", + table_name="users", + postgresql_where=sa.text( + "role_not_root_admin_root_admin_id IS NOT NULL", + ), + ) + op.drop_column("users", "role_not_root_admin_root_admin_id") + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/alembic/versions/982aa776c214_rename_user_role_admin_to_user_role_.py b/src/ttt/infrastructure/alembic/versions/982aa776c214_rename_user_role_admin_to_user_role_.py new file mode 100644 index 0000000..fc22e4e --- /dev/null +++ b/src/ttt/infrastructure/alembic/versions/982aa776c214_rename_user_role_admin_to_user_role_.py @@ -0,0 +1,57 @@ +""" +rename `user_role.admin` to `user_role.root_admin`. + +Revision ID: 982aa776c214 +Revises: 6eddb11b02fa +Create Date: 2025-08-21 14:13:30.942175 + +""" + +from collections.abc import Sequence + +from alembic import op +from alembic_postgresql_enum import TableReference + + +revision: str = "982aa776c214" +down_revision: str | None = "6eddb11b02fa" +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.sync_enum_values( # type: ignore[attr-defined] + enum_schema="public", + enum_name="user_role", + new_values=["root_admin", "regular_user"], + affected_columns=[ + TableReference( + table_schema="public", + table_name="users", + column_name="role", + existing_server_default="'regular_user'::user_role", + ), + ], + enum_values_to_rename=[("admin", "root_admin")], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( # type: ignore[attr-defined] + enum_schema="public", + enum_name="user_role", + new_values=["admin", "regular_user"], + affected_columns=[ + TableReference( + table_schema="public", + table_name="users", + column_name="role", + existing_server_default="'regular_user'::user_role", + ), + ], + enum_values_to_rename=[("root_admin", "admin")], + ) + # ### end Alembic commands ### diff --git a/src/ttt/infrastructure/pydantic_settings/secrets.py b/src/ttt/infrastructure/pydantic_settings/secrets.py index b6a598a..b595b40 100644 --- a/src/ttt/infrastructure/pydantic_settings/secrets.py +++ b/src/ttt/infrastructure/pydantic_settings/secrets.py @@ -11,6 +11,7 @@ class Secrets(BaseSettings): payments_token: str = Field(repr=False) gemini_api_key: str = Field(repr=False) sentry_dsn: str = Field(repr=False) + admin_token: str = Field(repr=False) @classmethod def settings_customise_sources( diff --git a/src/ttt/infrastructure/redis/batches.py b/src/ttt/infrastructure/redis/batches.py deleted file mode 100644 index 469ef06..0000000 --- a/src/ttt/infrastructure/redis/batches.py +++ /dev/null @@ -1,79 +0,0 @@ -from asyncio import sleep -from collections.abc import AsyncIterator, Awaitable, Iterable -from dataclasses import dataclass -from secrets import randbelow -from typing import Literal, cast, overload - -from redis.asyncio import Redis - -from ttt.entities.tools.assertion import assert_ - - -@dataclass(frozen=True, unsafe_hash=False) -class InRedisFixedBatches: - _redis: Redis - _sorted_set_name: str - _pulling_timeout_min_ms: int - _pulling_timeout_salt_ms: int - - async def add(self, batch: Iterable[bytes], /) -> Literal[0, 1]: - seconds, _ = await cast(Awaitable[tuple[int, int]], self._redis.time()) - mapping = dict.fromkeys(batch, seconds) - return cast( - Literal[0, 1], - await self._redis.zadd(self._sorted_set_name, mapping), - ) - - @overload - def with_len( - self, - batch_len: Literal[2], - ) -> AsyncIterator[tuple[bytes, bytes]]: ... - - @overload - def with_len( - self, - batch_len: Literal[3], - ) -> AsyncIterator[tuple[bytes, bytes, bytes]]: ... - - @overload - def with_len( - self, - batch_len: int, - ) -> AsyncIterator[tuple[bytes, ...]]: ... - - async def with_len( - self, - batch_len: int, - ) -> AsyncIterator[tuple[bytes, ...]]: - assert_(batch_len >= 1) - - while True: - await self._sleep() - - result = await self._redis.zmpop( # type: ignore[misc] - 1, - [self._sorted_set_name], - min=True, - count=batch_len, - ) - if result is None: - continue - - result = cast(tuple[bytes, list[tuple[bytes, bytes]]], result) - _, values_with_score = result - batch = tuple( - value_and_score[0] for value_and_score in values_with_score - ) - - if len(batch) == batch_len: - yield tuple(batch) - else: - await self.add(batch) - - async def _sleep(self) -> None: - sleep_ms = self._pulling_timeout_min_ms + randbelow( - self._pulling_timeout_salt_ms, - ) - - await sleep(sleep_ms / 1000) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/__init__.py b/src/ttt/infrastructure/sqlalchemy/tables/__init__.py index 58b1195..440c596 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/__init__.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/__init__.py @@ -1,4 +1,10 @@ from ttt.infrastructure.sqlalchemy.tables.common import Base from ttt.infrastructure.sqlalchemy.tables.game import TableGame +from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( + TableInvitationToGame, +) +from ttt.infrastructure.sqlalchemy.tables.matchmaking_queue import ( + TableMatchmakingQueue, +) from ttt.infrastructure.sqlalchemy.tables.payment import TablePayment 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 d9c22e7..590e2a7 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/atomic.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/atomic.py @@ -1,7 +1,15 @@ +from typing import cast + from ttt.entities.atomic import Atomic from ttt.entities.core.game.game import ( GameAtomic, ) +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.user.user import UserAtomic from ttt.entities.finance.payment.payment import ( PaymentAtomic, @@ -10,6 +18,14 @@ TableGameAtomic, table_game_atomic, ) +from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( + TableInvitationToGameAtomic, + table_invitation_to_game_atomic, +) +from ttt.infrastructure.sqlalchemy.tables.matchmaking_queue import ( + TableMatchmakingQueueAtomic, + table_matchmaking_queue_atomic, +) from ttt.infrastructure.sqlalchemy.tables.payment import ( TablePaymentAtomic, table_payment_atomic, @@ -20,10 +36,16 @@ ) -type TableAtomic = TableUserAtomic | TableGameAtomic | TablePaymentAtomic +type TableAtomic = ( + TableUserAtomic + | TableGameAtomic + | TableMatchmakingQueueAtomic + | TableInvitationToGameAtomic + | TablePaymentAtomic +) -def table_atomic(entity: Atomic) -> TableAtomic: # noqa: RET503 +def mapped_table_atomic(entity: Atomic) -> TableAtomic: # noqa: RET503 if isinstance(entity, UserAtomic): return table_user_atomic(entity) @@ -32,3 +54,16 @@ def 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, InvitationToGameAtomic): + return table_invitation_to_game_atomic(entity) + + +def linked_table_atomic(entity: Atomic) -> TableAtomic: + if hasattr(entity, "_table_entity"): + return cast(TableAtomic, entity._table_entity) # noqa: SLF001 + + return None diff --git a/src/ttt/infrastructure/sqlalchemy/tables/common.py b/src/ttt/infrastructure/sqlalchemy/tables/common.py index 6196db8..bb1ee9a 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/common.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/common.py @@ -1,4 +1,22 @@ +from abc import abstractmethod + from sqlalchemy.orm import DeclarativeBase -class Base(DeclarativeBase): ... +class Base[EntityT](DeclarativeBase): + __abstract__ = True + _entity: EntityT | None = None + + def entity(self) -> EntityT: + if self._entity is not None: + return self._entity + + entity = self.__entity__() + + entity._table_entity = self # type: ignore[attr-defined] # noqa: SLF001 + self._entity = entity + + return entity + + @abstractmethod + def __entity__(self) -> EntityT: ... diff --git a/src/ttt/infrastructure/sqlalchemy/tables/game.py b/src/ttt/infrastructure/sqlalchemy/tables/game.py index 063858c..8f720bd 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/game.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/game.py @@ -60,13 +60,13 @@ def of(cls, it: AiType) -> "TableAiType": ai_type = postgresql.ENUM(TableAiType, name="ai_type") -class TableAi(Base): +class TableAi(Base[Ai]): __tablename__ = "ais" id: Mapped[UUID] = mapped_column(primary_key=True) type: Mapped[TableAiType] = mapped_column(ai_type) - def entity(self) -> Ai: + def __entity__(self) -> Ai: return Ai(self.id, self.type.entity()) @classmethod @@ -74,7 +74,7 @@ def of(cls, it: Ai) -> "TableAi": return TableAi(id=it.id, type=TableAiType.of(it.type)) -class TableCell(Base): +class TableCell(Base[Cell]): __tablename__ = "cells" id: Mapped[UUID] = mapped_column(primary_key=True) @@ -94,7 +94,7 @@ class TableCell(Base): index=True, ) - def entity(self) -> Cell: + def __entity__(self) -> Cell: return Cell( self.id, self.game_id, @@ -147,7 +147,7 @@ def of(cls, it: GameState) -> "TableGameState": game_state = postgresql.ENUM(TableGameState, name="game_state") -class TableGame(Base): +class TableGame(Base[Game]): __tablename__ = "games" id: Mapped[UUID] = mapped_column(primary_key=True) @@ -274,7 +274,7 @@ class TableGame(Base): ), ) - def entity(self) -> Game: + def __entity__(self) -> Game: board = self._board(it.entity() for it in self.cells) return Game( diff --git a/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py b/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py new file mode 100644 index 0000000..ab30e4f --- /dev/null +++ b/src/ttt/infrastructure/sqlalchemy/tables/invitation_to_game.py @@ -0,0 +1,141 @@ +from datetime import datetime +from enum import StrEnum +from uuid import UUID + +from sqlalchemy import ForeignKey, Index +from sqlalchemy.dialects import postgresql +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationToGame, + InvitationToGameAtomic, + InvitationToGameState, +) +from ttt.infrastructure.sqlalchemy.tables.common import Base +from ttt.infrastructure.sqlalchemy.tables.user import TableUser + + +class TableInvitationToGameState(StrEnum): + active = "active" + auto_cancelled = "auto_cancelled" + cancelled_by_user = "cancelled_by_user" + rejected = "rejected" + accepted = "accepted" + + def entity(self) -> InvitationToGameState: + match self: + case TableInvitationToGameState.active: + return InvitationToGameState.active + + case TableInvitationToGameState.auto_cancelled: + return InvitationToGameState.auto_cancelled + + case TableInvitationToGameState.cancelled_by_user: + return InvitationToGameState.cancelled_by_user + + case TableInvitationToGameState.rejected: + return InvitationToGameState.rejected + + case TableInvitationToGameState.accepted: + return InvitationToGameState.accepted + + @classmethod + def of(cls, it: InvitationToGameState) -> "TableInvitationToGameState": + match it: + case InvitationToGameState.active: + return TableInvitationToGameState.active + + case InvitationToGameState.auto_cancelled: + return TableInvitationToGameState.auto_cancelled + + case InvitationToGameState.cancelled_by_user: + return TableInvitationToGameState.cancelled_by_user + + case InvitationToGameState.rejected: + return TableInvitationToGameState.rejected + + case InvitationToGameState.accepted: + return TableInvitationToGameState.accepted + + +invitation_to_game_state = postgresql.ENUM( + TableInvitationToGameState, name="invitation_to_game_state", +) + + +class TableInvitationToGame(Base[InvitationToGame]): + __tablename__ = "invitations_to_game" + + id: Mapped[UUID] = mapped_column(primary_key=True) + inviting_user_id: Mapped[int] = mapped_column( + ForeignKey( + "users.id", + deferrable=True, + initially="DEFERRED", + ), + index=True, + ) + invited_user_id: Mapped[int] = mapped_column( + ForeignKey( + "users.id", + deferrable=True, + initially="DEFERRED", + ), + index=True, + ) + invitation_datetime: Mapped[datetime] = mapped_column() + state: Mapped[TableInvitationToGameState] = mapped_column( + invitation_to_game_state, + ) + + inviting_user: Mapped[TableUser] = relationship( + lazy="selectin", foreign_keys=[inviting_user_id], + ) + invited_user: Mapped[TableUser] = relationship( + lazy="selectin", foreign_keys=[invited_user_id], + ) + + __table_args__ = ( + Index( + "ix_invitations_to_game_user_ids", + inviting_user_id, + invited_user_id, + postgresql_where=(state == TableInvitationToGameState.active.value), + unique=True, + ), + Index( + "ix_invitations_to_game_invitation_datetime", + invitation_datetime, + postgresql_where=(state == TableInvitationToGameState.active.value), + ), + ) + + def __entity__(self) -> InvitationToGame: + return InvitationToGame( + id_=self.id, + inviting_user=self.inviting_user.entity(), + invited_user=self.invited_user.entity(), + invitation_datetime=self.invitation_datetime, + state=self.state.entity(), + ) + + @classmethod + def of(cls, it: InvitationToGame) -> "TableInvitationToGame": + return TableInvitationToGame( + id=it.id_, + inviting_user_id=it.inviting_user.id, + invited_user_id=it.invited_user.id, + invitation_datetime=it.invitation_datetime, + state=TableInvitationToGameState.of(it.state), + ) + + +type TableInvitationToGameAtomic = TableInvitationToGame + + +def table_invitation_to_game_atomic( + entity: InvitationToGameAtomic, +) -> TableInvitationToGameAtomic: + match entity: + case InvitationToGame(): + return TableInvitationToGame.of(entity) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/matchmaking_queue.py b/src/ttt/infrastructure/sqlalchemy/tables/matchmaking_queue.py new file mode 100644 index 0000000..598eb6c --- /dev/null +++ b/src/ttt/infrastructure/sqlalchemy/tables/matchmaking_queue.py @@ -0,0 +1,60 @@ +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_queue.matchmaking_queue import ( + MatchmakingQueue, + MatchmakingQueueAtomic, +) +from ttt.entities.core.matchmaking_queue.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" + + 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 TableMatchmakingQueue = None +type TableMatchmakingQueueAtomic = TableMatchmakingQueue | TableUserWaiting + + +def table_matchmaking_queue_atomic( + entity: MatchmakingQueueAtomic, +) -> TableMatchmakingQueueAtomic: + match entity: + case MatchmakingQueue(): + return None + + case UserWaiting(): + return TableUserWaiting.of(entity) diff --git a/src/ttt/infrastructure/sqlalchemy/tables/payment.py b/src/ttt/infrastructure/sqlalchemy/tables/payment.py index 50ed17e..d7a41e7 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/payment.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/payment.py @@ -44,7 +44,7 @@ def of(cls, it: PaymentState) -> "TablePaymentState": payment_state = postgresql.ENUM(TablePaymentState, name="payment_state") -class TablePayment(Base): +class TablePayment(Base[Payment]): __tablename__ = "payments" id: Mapped[UUID] = mapped_column(primary_key=True) @@ -55,7 +55,7 @@ class TablePayment(Base): success_gateway_id: Mapped[str | None] state: Mapped[TablePaymentState] = mapped_column(payment_state) - def entity(self) -> Payment: + def __entity__(self) -> Payment: if self.success_id is None or self.success_gateway_id is None: success = None else: diff --git a/src/ttt/infrastructure/sqlalchemy/tables/user.py b/src/ttt/infrastructure/sqlalchemy/tables/user.py index 2577c10..f1b4d6e 100644 --- a/src/ttt/infrastructure/sqlalchemy/tables/user.py +++ b/src/ttt/infrastructure/sqlalchemy/tables/user.py @@ -1,21 +1,29 @@ from datetime import datetime +from enum import StrEnum from uuid import UUID from sqlalchemy import CHAR, BigInteger, ForeignKey, Index +from sqlalchemy.dialects import postgresql from sqlalchemy.orm import Mapped, mapped_column, relationship from ttt.entities.core.user.account import Account +from ttt.entities.core.user.admin_right import ( + AdminRight, + AdminRightViaAdminToken, + 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 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): +class TableUserEmoji(Base[UserEmoji]): __tablename__ = "user_emojis" id: Mapped[UUID] = mapped_column(primary_key=True) @@ -26,7 +34,7 @@ class TableUserEmoji(Base): emoji_str: Mapped[str] = mapped_column(CHAR(1)) datetime_of_purchase: Mapped[datetime] - def entity(self) -> UserEmoji: + def __entity__(self) -> UserEmoji: return UserEmoji( self.id, self.user_id, @@ -44,7 +52,7 @@ def of(cls, it: UserEmoji) -> "TableUserEmoji": ) -class TableStarsPurchase(Base): +class TableStarsPurchase(Base[StarsPurchase]): __tablename__ = "stars_purchases" id: Mapped[UUID] = mapped_column(primary_key=True) @@ -70,7 +78,7 @@ class TableStarsPurchase(Base): ), ) - def entity(self) -> StarsPurchase: + def __entity__(self) -> StarsPurchase: return StarsPurchase( id_=self.id, user_id=self.user_id, @@ -88,7 +96,7 @@ def of(cls, it: StarsPurchase) -> "TableStarsPurchase": ) -class TableLastGame(Base): +class TableLastGame(Base[LastGame]): __tablename__ = "last_games" id: Mapped[UUID] = mapped_column(primary_key=True) @@ -99,7 +107,7 @@ class TableLastGame(Base): ForeignKey("games.id", deferrable=True, initially="DEFERRED"), ) - def entity(self) -> LastGame: + def __entity__(self) -> LastGame: return LastGame( id=self.id, user_id=self.user_id, @@ -115,7 +123,26 @@ def of(cls, it: LastGame) -> "TableLastGame": ) -class TableUser(Base): +class TableAdminRight(StrEnum): + via_admin_token = "via_admin_token" # noqa: S105 + via_other_admin = "via_other_admin" + + def entity( + self, admin_right_via_other_admin_admin_id: int | None, + ) -> AdminRight: + match self: + case TableAdminRight.via_admin_token: + return AdminRightViaAdminToken() + case TableAdminRight.via_other_admin: + return AdminRightViaOtherAdmin( + admin_id=not_none(admin_right_via_other_admin_admin_id), + ) + + +admin_right = postgresql.ENUM(TableAdminRight, name="admin_right") + + +class TableUser(Base[User]): __tablename__ = "users" id: Mapped[int] = mapped_column( @@ -136,6 +163,10 @@ class TableUser(Base): 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( + ForeignKey("users.id", deferrable=True, initially="DEFERRED"), + ) emojis: Mapped[list[TableUserEmoji]] = relationship( lazy="selectin", @@ -150,7 +181,20 @@ class TableUser(Base): foreign_keys=[TableLastGame.user_id], ) - def entity(self) -> User: + __table_args__ = ( + Index( + "ix_users_admin_right_via_other_admin_admin_id", + admin_right_via_other_admin_admin_id, + postgresql_where=(admin_right_via_other_admin_admin_id.is_not(None)), + ), + Index( + "ix_users_admin_right", + admin_right, + postgresql_where=(admin_right.is_not(None)), + ), + ) + + def __entity__(self) -> User: if self.game_location_game_id is not None: location = UserGameLocation( self.id, @@ -159,6 +203,13 @@ def entity(self) -> User: else: location = None + if self.admin_right is not None: + admin_right = self.admin_right.entity( + self.admin_right_via_other_admin_admin_id, + ) + else: + admin_right = None + return User( id=self.id, account=Account(self.account_stars), @@ -171,6 +222,7 @@ def entity(self) -> User: number_of_draws=self.number_of_draws, number_of_defeats=self.number_of_defeats, game_location=location, + admin_right=admin_right, ) @classmethod @@ -180,6 +232,17 @@ def of(cls, it: User) -> "TableUser": else: game_location_game_id = it.game_location.game_id + match it.admin_right: + case None: + admin_right = None + admin_right_via_other_admin_admin_id = None + case AdminRightViaAdminToken(): + admin_right = TableAdminRight.via_admin_token + admin_right_via_other_admin_admin_id = None + case AdminRightViaOtherAdmin(admin_id): + admin_right = TableAdminRight.via_other_admin + admin_right_via_other_admin_admin_id = admin_id + return TableUser( id=it.id, account_stars=it.account.stars, @@ -189,6 +252,10 @@ def of(cls, it: User) -> "TableUser": 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=( + admin_right_via_other_admin_admin_id + ), ) diff --git a/src/ttt/main/common/di.py b/src/ttt/main/common/di.py index 8131f19..8b00d42 100644 --- a/src/ttt/main/common/di.py +++ b/src/ttt/main/common/di.py @@ -19,10 +19,28 @@ 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_starting_queue import ( - GameStartingQueue, -) 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, +) +from ttt.application.invitation_to_game.game.ports.invitation_to_game_log import ( # noqa: E501 + InvitationToGameLog, +) +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_queue.common.shared_matchmaking_queue import ( + SharedMatchmakingQueue, +) +from ttt.application.user.change_other_user_account.ports.user_log import ( + ChangeOtherUserAccountLog, +) +from ttt.application.user.common.ports.original_admin_token import ( + OriginalAdminToken, +) 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 ( @@ -40,17 +58,33 @@ from ttt.infrastructure.adapters.clock import NotMonotonicUtcClock from ttt.infrastructure.adapters.game_ai_gateway import GeminiGameAiGateway from ttt.infrastructure.adapters.game_log import StructlogGameLog -from ttt.infrastructure.adapters.game_starting_queue import ( - InRedisFixedBatchesWaitingLocations, -) from ttt.infrastructure.adapters.games import InPostgresGames +from ttt.infrastructure.adapters.invitation_to_game_dao import ( + PostgresInvitationToGameDao, +) +from ttt.infrastructure.adapters.invitation_to_game_log import ( + StructlogInvitationToGameLog, +) +from ttt.infrastructure.adapters.invitations_to_game import ( + InPostgresInvitationsToGame, +) from ttt.infrastructure.adapters.map import MapToPostgres +from ttt.infrastructure.adapters.matchmaking_queue_log import ( + StructlogCommonMatchmakingQueueLog, +) +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.shared_matchmaking_queue import ( + InPostgresSharedMatchmakingQueue, +) from ttt.infrastructure.adapters.transaction import InPostgresTransaction from ttt.infrastructure.adapters.user_log import ( + StructlogChangeOtherUserAccountLog, StructlogCommonUserLog, StructlogEmojiPurchaseUserLog, StructlogEmojiSelectionUserLog, @@ -65,7 +99,6 @@ 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.redis.batches import InRedisFixedBatches from ttt.infrastructure.structlog.logger import LoggerFactory @@ -78,6 +111,12 @@ class InfrastructureProvider(Provider): provide_envs = provide(source=Envs.load, scope=Scope.APP) provide_secrets = provide(source=Secrets.load, scope=Scope.APP) + @provide(scope=Scope.APP) + def provide_original_admin_token( + self, secrets: Secrets, + ) -> OriginalAdminToken: + return TokenAsOriginalAdminToken(secrets.admin_token) + @provide(scope=Scope.APP) async def provide_background_tasks(self) -> AsyncIterator[BackgroundTasks]: async with BackgroundTasks() as tasks: @@ -195,6 +234,24 @@ def provide_logger( scope=Scope.REQUEST, ) + provide_shared_matchmaking_queue = provide( + InPostgresSharedMatchmakingQueue, + provides=SharedMatchmakingQueue, + scope=Scope.REQUEST, + ) + + provide_invitations_to_game = provide( + InPostgresInvitationsToGame, + provides=InvitationsToGame, + scope=Scope.REQUEST, + ) + + provide_invitation_to_game_dao = provide( + PostgresInvitationToGameDao, + provides=InvitationToGameDao, + scope=Scope.REQUEST, + ) + provide_map = provide( MapToPostgres, provides=Map, @@ -217,21 +274,6 @@ def provide_logger( def provide_randoms(self) -> Randoms: return MersenneTwisterRandoms() - @provide(scope=Scope.REQUEST) - def provide_game_starting_queue( - self, - redis: Redis, - envs: Envs, - ) -> GameStartingQueue: - return InRedisFixedBatchesWaitingLocations( - InRedisFixedBatches( - redis, - "game_starting_queue", - envs.game_waiting_queue_pulling_timeout_min_ms, - envs.game_waiting_queue_pulling_timeout_salt_ms, - ), - ) - provide_game_ai_gateway = provide( GeminiGameAiGateway, provides=GameAiGateway, @@ -267,3 +309,21 @@ def provide_game_starting_queue( provides=StarsPurchaseUserLog, scope=Scope.REQUEST, ) + + provide_common_matchmaking_queue_log = provide( + StructlogCommonMatchmakingQueueLog, + provides=CommonMatchmakingQueueLog, + scope=Scope.REQUEST, + ) + + provide_change_other_user_account_log = provide( + StructlogChangeOtherUserAccountLog, + provides=ChangeOtherUserAccountLog, + scope=Scope.REQUEST, + ) + + provide_invitation_to_game_log = provide( + StructlogInvitationToGameLog, + provides=InvitationToGameLog, + scope=Scope.REQUEST, + ) diff --git a/src/ttt/main/tg_bot/di.py b/src/ttt/main/tg_bot/di.py index 77922dd..0960fbc 100755 --- a/src/ttt/main/tg_bot/di.py +++ b/src/ttt/main/tg_bot/di.py @@ -14,6 +14,7 @@ ) from aiogram_dialog import BgManagerFactory, setup_dialogs from aiogram_dialog.manager.bg_manager import BgManagerFactoryImpl +from aiogram_dialog.manager.manager import ManagerImpl from dishka import ( Provider, Scope, @@ -27,12 +28,61 @@ from ttt.application.game.game.cancel_game import CancelGame 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 import StartGame from ttt.application.game.game.start_game_with_ai import StartGameWithAi from ttt.application.game.game.view_game import ViewGame -from ttt.application.game.game.wait_game import WaitGame +from ttt.application.invitation_to_game.game.accpet_invitation_to_game import ( + AcceptInvitationToGame, +) +from ttt.application.invitation_to_game.game.auto_cancel_invitations_to_game import ( # noqa: E501 + AutoCancelInvitationsToGame, +) +from ttt.application.invitation_to_game.game.cancel_invitation_to_game import ( + CancelInvitationToGame, +) +from ttt.application.invitation_to_game.game.invite_to_game import InviteToGame +from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 + InvitationToGameViews, +) +from ttt.application.invitation_to_game.game.reject_invitation_to_game import ( + RejectInvitationToGame, +) +from ttt.application.invitation_to_game.game.view_incoming_invitation_to_game import ( # noqa: E501 + ViewIncomingInvitationToGame, +) +from ttt.application.invitation_to_game.game.view_incoming_invitations_to_game import ( # noqa: E501 + ViewIncomingInvitationsToGame, +) +from ttt.application.invitation_to_game.game.view_one_incoming_invitation_to_game import ( # noqa: E501 + ViewOneIncomingInvitationToGame, +) +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_queue.game.wait_game import WaitGame +from ttt.application.user.authorize_as_admin import AuthorizeAsAdmin +from ttt.application.user.authorize_other_user_as_admin import ( + AuthorizeOtherUserAsAdmin, +) +from ttt.application.user.change_other_user_account.change_other_user_account import ( # noqa: E501 + ChangeOtherUserAccount, +) +from ttt.application.user.change_other_user_account.ports.user_views import ( + ChangeOtherUserAccountViews, +) +from ttt.application.user.change_other_user_account.set_other_user_account import ( # noqa: E501 + SetOtherUserAccount, +) +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, +) from ttt.application.user.emoji_purchase.buy_emoji import BuyEmoji from ttt.application.user.emoji_purchase.ports.user_views import ( EmojiPurchaseUserViews, @@ -42,6 +92,7 @@ ) 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, ) @@ -60,27 +111,40 @@ 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 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.secrets import Secrets from ttt.presentation.adapters.emojis import PictographsAsEmojis from ttt.presentation.adapters.game_views import ( - BackroundAiogramMessagesFromPostgresAsGameViews, + AiogramGameViews, +) +from ttt.presentation.adapters.invitation_to_game_views import ( + AiogramInvitationToGameViews, +) +from ttt.presentation.adapters.matchmaking_queue_views import ( + AiogramCommonMatchmakingQueueViews, ) from ttt.presentation.adapters.stars_purchase_payment_gateway import ( - AiogramInAndBufferOutStarsPurchasePaymentGateway, + AiogramPaymentGateway, ) from ttt.presentation.adapters.user_views import ( - AiogramMessagesAsEmojiPurchaseUserViews, - AiogramMessagesAsStarsPurchaseUserViews, - AiogramMessagesFromPostgresAsCommonUserViews, - AiogramMessagesFromPostgresAsEmojiSelectionUserViews, + AiogramChangeOtherUserAccountViews, + AiogramCommonUserViews, + AiogramEmojiPurchaseUserViews, + AiogramEmojiSelectionUserViews, + AiogramStarsPurchaseUserViews, ) from ttt.presentation.aiogram.common.bots import ttt_bot from ttt.presentation.aiogram.common.routes.all import common_routers from ttt.presentation.aiogram.user.routes.all import user_routers +from ttt.presentation.aiogram_dialog.admin_dialog import admin_dialog +from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( + DialogManagerForUser, +) from ttt.presentation.aiogram_dialog.main_dialog import main_dialog from ttt.presentation.result_buffer import ResultBuffer from ttt.presentation.unkillable_tasks import UnkillableTasks @@ -96,6 +160,12 @@ class PresentationProvider(Provider): 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(scope=Scope.APP) def provide_strage(self, redis: Redis) -> BaseStorage: return RedisStorage(redis, DefaultKeyBuilder(with_destiny=True)) @@ -104,6 +174,15 @@ def provide_strage(self, redis: Redis) -> BaseStorage: def provide_bg_manager_factory(self, dp: Dispatcher) -> BgManagerFactory: return BgManagerFactoryImpl(dp) + @provide(scope=Scope.REQUEST) + def provide_manager_impl( + self, middleware_data: AiogramMiddlewareData | None, + ) -> ManagerImpl | None: + if middleware_data is None: + return None + + return cast(ManagerImpl, middleware_data["dialog_manager"]) + @provide(scope=Scope.APP) async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: bot = Bot(secrets.bot_token) @@ -112,6 +191,10 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: await ttt_bot(bot) yield bot + provide_dialog_manager_for_user = provide( + DialogManagerForUser, scope=Scope.REQUEST, + ) + provide_emoji = provide( PictographsAsEmojis, provides=Emojis, @@ -119,30 +202,46 @@ async def provide_bot(self, secrets: Secrets) -> AsyncIterator[Bot]: ) provide_game_views = provide( - BackroundAiogramMessagesFromPostgresAsGameViews, + AiogramGameViews, provides=GameViews, scope=Scope.REQUEST, ) provide_user_views = provide( - AiogramMessagesFromPostgresAsCommonUserViews, + AiogramCommonUserViews, provides=CommonUserViews, scope=Scope.REQUEST, ) provide_stars_purchase_user_views = provide( - AiogramMessagesAsStarsPurchaseUserViews, + AiogramStarsPurchaseUserViews, provides=StarsPurchaseUserViews, - scope=Scope.APP, + scope=Scope.REQUEST, ) provide_emoji_selection_user_views = provide( - AiogramMessagesFromPostgresAsEmojiSelectionUserViews, + AiogramEmojiSelectionUserViews, provides=EmojiSelectionUserViews, scope=Scope.REQUEST, ) provide_emoji_purchase_user_views = provide( - AiogramMessagesAsEmojiPurchaseUserViews, + AiogramEmojiPurchaseUserViews, provides=EmojiPurchaseUserViews, - scope=Scope.APP, + scope=Scope.REQUEST, + ) + provide_common_matchmaking_queue_views = provide( + AiogramCommonMatchmakingQueueViews, + provides=CommonMatchmakingQueueViews, + scope=Scope.REQUEST, + ) + provide_change_other_user_account_views = provide( + AiogramChangeOtherUserAccountViews, + provides=ChangeOtherUserAccountViews, + scope=Scope.REQUEST, + ) + + provide_invitation_to_game_views = provide( + AiogramInvitationToGameViews, + provides=InvitationToGameViews, + scope=Scope.REQUEST, ) @provide(scope=Scope.REQUEST) @@ -153,14 +252,12 @@ def provide_result_buffer(self) -> ResultBuffer: async def unkillable_tasks( self, logger: FilteringBoundLogger, - start_game: StartGame, start_stars_purchase_payment_completion: ( StartStarsPurchasePaymentCompletion ), complete_stars_purchase_payment: CompleteStarsPurchasePayment, ) -> UnkillableTasks: tasks = UnkillableTasks(logger) - tasks.add(start_game) tasks.add(start_stars_purchase_payment_completion) tasks.add(complete_stars_purchase_payment) @@ -180,8 +277,8 @@ def provide_dp(self, storage: BaseStorage) -> Dispatcher: *common_routers, *user_routers, ) + dp.include_routers(main_dialog, admin_dialog) - dp.include_routers(main_dialog) setup_dialogs(dp) return dp @@ -207,6 +304,17 @@ def provide_pre_checkout_query( case _: return None + @provide(scope=Scope.REQUEST) + def provide_callback_query( + self, + event: TelegramObject | None, + ) -> CallbackQuery | None: + match event: + case CallbackQuery(): + return event + case _: + return None + @provide(scope=Scope.REQUEST) def provide_fsm_context( self, @@ -221,14 +329,14 @@ def provide_stars_purchase_payment_gateway( secrets: Secrets, bot: Bot, buffer: Buffer[PaidStarsPurchasePayment], - bg_manager_factory: BgManagerFactory, + dialog_manager_for_user: DialogManagerForUser, ) -> StarsPurchasePaymentGateway: - return AiogramInAndBufferOutStarsPurchasePaymentGateway( + return AiogramPaymentGateway( pre_checkout_query, buffer, bot, secrets.payments_token, - bg_manager_factory, + dialog_manager_for_user, ) @@ -251,7 +359,6 @@ class ApplicationProvider(Provider): ViewMainMenu, scope=Scope.REQUEST, ) - provide_view_user = provide(ViewUser, scope=Scope.REQUEST) provide_register_user = provide(RegisterUser, scope=Scope.REQUEST) probide_complete_stars_purchase_payment = provide( @@ -262,13 +369,63 @@ class ApplicationProvider(Provider): StartStarsPurchasePaymentCompletion, scope=Scope.REQUEST, ) + provide_authorize_as_admin = provide(AuthorizeAsAdmin, scope=Scope.REQUEST) + provide_relinquish_admin_right = provide( + RelinquishAdminRight, + scope=Scope.REQUEST, + ) + provide_view_admin_menu = provide(ViewAdminMenu, scope=Scope.REQUEST) + provide_view_other_user = provide(ViewOtherUser, scope=Scope.REQUEST) + provide_authorize_other_user_as_admin = provide( + AuthorizeOtherUserAsAdmin, scope=Scope.REQUEST, + ) + provide_deauthorize_other_user_as_admin = provide( + DeauthorizeOtherUserAsAdmin, scope=Scope.REQUEST, + ) + provide_set_other_user_account = provide( + SetOtherUserAccount, scope=Scope.REQUEST, + ) + provide_change_other_user_account = provide( + ChangeOtherUserAccount, scope=Scope.REQUEST, + ) + provide_view_user_account_to_change = provide( + ViewUserAccountToChange, scope=Scope.REQUEST, + ) provide_start_game_with_ai = provide( StartGameWithAi, scope=Scope.REQUEST, ) - provide_start_game = provide(StartGame, scope=Scope.REQUEST) - provide_wait_game = provide(WaitGame, scope=Scope.REQUEST) provide_cancel_game = provide(CancelGame, scope=Scope.REQUEST) 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, + ) + provide_cancel_invitation_to_game = provide( + CancelInvitationToGame, scope=Scope.REQUEST, + ) + provide_auto_cancel_invitations_to_game = provide( + AutoCancelInvitationsToGame, scope=Scope.REQUEST, + ) + provide_invite_to_game = provide( + InviteToGame, scope=Scope.REQUEST, + ) + provide_reject_invitation_to_game = provide( + RejectInvitationToGame, scope=Scope.REQUEST, + ) + provide_view_outcoming_invitations_to_game = provide( + ViewOutcomingInvitationsToGame, scope=Scope.REQUEST, + ) + provide_view_incoming_invitations_to_game = provide( + ViewIncomingInvitationsToGame, scope=Scope.REQUEST, + ) + provide_view_incoming_invitation_to_game = provide( + ViewIncomingInvitationToGame, scope=Scope.REQUEST, + ) + provide_view_one_incoming_invitation_to_game = provide( + ViewOneIncomingInvitationToGame, scope=Scope.REQUEST, + ) diff --git a/src/ttt/main/tg_bot/start_aiogram.py b/src/ttt/main/tg_bot/start_aiogram.py index 407eac2..7dead24 100644 --- a/src/ttt/main/tg_bot/start_aiogram.py +++ b/src/ttt/main/tg_bot/start_aiogram.py @@ -1,19 +1,32 @@ 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 setup_dishka - +from dishka.integrations.aiogram import ( + AiogramMiddlewareData, + ContainerMiddleware, +) + +from ttt.presentation.tasks.auto_cancel_invitation_to_game_task import ( + auto_cancel_invitation_to_game_task, +) from ttt.presentation.unkillable_tasks import UnkillableTasks async def start_aiogram(container: AsyncContainer) -> None: dp = await container.get(Dispatcher) - setup_dishka(container, dp) - async with container({TelegramObject: None}) as request: + 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)) logging.basicConfig(level=logging.INFO) diff --git a/src/ttt/main/tg_bot_dev/__main__.py b/src/ttt/main/tg_bot_dev/__main__.py index 039eed8..223015f 100644 --- a/src/ttt/main/tg_bot_dev/__main__.py +++ b/src/ttt/main/tg_bot_dev/__main__.py @@ -25,6 +25,7 @@ async def amain() -> None: LoggerFactory: DevLoggerFactory(adds_request_id=True), }, ) + await start_aiogram(container) diff --git a/src/ttt/presentation/adapters/game_views.py b/src/ttt/presentation/adapters/game_views.py index a5037c1..89bb8cd 100644 --- a/src/ttt/presentation/adapters/game_views.py +++ b/src/ttt/presentation/adapters/game_views.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from aiogram import Bot -from aiogram_dialog import BgManagerFactory, ShowMode, StartMode +from aiogram_dialog import ShowMode, StartMode from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -12,10 +12,12 @@ Game, ) from ttt.entities.core.user.location import UserGameLocation -from ttt.infrastructure.background_tasks import BackgroundTasks 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 +from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( + DialogManagerForUser, +) from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState from ttt.presentation.aiogram_dialog.main_dialog.game_window import ( ActiveGameView, @@ -25,23 +27,12 @@ @dataclass(frozen=True, unsafe_hash=False) -class BackroundAiogramMessagesFromPostgresAsGameViews(GameViews): +class AiogramGameViews(GameViews): _session: AsyncSession - _tasks: BackgroundTasks _bot: Bot - _bg_dialog_manager_factory: BgManagerFactory + _dialog_manager_for_user: DialogManagerForUser _result_buffer: ResultBuffer - async def waiting_for_game_view(self, user_id: int, /) -> None: - dialog_manager = self._bg_dialog_manager_factory.bg( - self._bot, user_id, user_id, - ) - await dialog_manager.start( - MainDialogState.game_mode_to_start_game, - {"hint": "⚔️ Поиск игры начат"}, - StartMode.RESET_STACK, - ) - async def current_game_view_with_user_id(self, user_id: int, /) -> None: join_condition = ( (TableUser.id == user_id) @@ -80,22 +71,15 @@ async def started_game_view_with_locations( game: Game, /, ) -> None: - for location in user_locations: - dialog_manager = self._bg_dialog_manager_factory.bg( - self._bot, location.user_id, location.user_id, - ) - await dialog_manager.start( - MainDialogState.game, - ActiveGameView.of(game, location.user_id).window_data(), - StartMode.RESET_STACK, - ) + await gather(*( + self._started_game_view(location, game) + for location in user_locations + )) async def no_game_view(self, user_id: int, /) -> None: - data = {"hint": "❌ Игра уже закончилась"} + dialog_manager = self._dialog_manager_for_user(user_id) - dialog_manager = self._bg_dialog_manager_factory.bg( - self._bot, user_id, user_id, - ) + data = {"hint": "❌ Игра уже закончилась"} await dialog_manager.start( MainDialogState.main, data, StartMode.RESET_STACK, ) @@ -106,9 +90,8 @@ async def game_already_complteted_view( game: Game, /, ) -> None: - dialog_manager = self._bg_dialog_manager_factory.bg( - self._bot, user_id, user_id, - ) + dialog_manager = self._dialog_manager_for_user(user_id) + data = {"hint": "❌ Игра уже закончилась"} await dialog_manager.start( MainDialogState.game, data, StartMode.RESET_STACK, @@ -120,13 +103,12 @@ async def not_current_user_view( game: Game, /, ) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) + hint_data = {"hint": "❌ Сейчас не ваш ход"} game_data = ActiveGameView.of(game, user_id).window_data() data = hint_data | game_data - dialog_manager = self._bg_dialog_manager_factory.bg( - self._bot, user_id, user_id, - ) await dialog_manager.start( MainDialogState.game, data, StartMode.RESET_STACK, ) @@ -139,11 +121,13 @@ async def no_cell_view( ) -> None: raise NotImplementedError - async def users_already_in_game_views( - self, - user_ids: Sequence[int], - /, - ) -> None: ... + async def user_already_in_game_view(self, user_id: int, /) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) + + data = {"hint": "❌ Вы уже в игре"} + await dialog_manager.start( + MainDialogState.main, data, StartMode.RESET_STACK, + ) async def already_filled_cell_error( self, @@ -151,23 +135,34 @@ async def already_filled_cell_error( game: Game, /, ) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) + hint_data = {"hint": "❌ Ячейка уже проставлена"} game_data = ActiveGameView.of(game, user_id).window_data() data = hint_data | game_data - dialog_manager = self._bg_dialog_manager_factory.bg( - self._bot, user_id, user_id, - ) await dialog_manager.start( 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) + + await dialog_manager.start( + MainDialogState.game, + ActiveGameView.of(game, user_location.user_id).window_data(), + StartMode.RESET_STACK, + ) + async def _active_game_view( self, location: UserGameLocation, game: Game, ) -> None: - dialog_manager = self._bg_dialog_manager_factory.bg( - self._bot, location.user_id, location.user_id, - ) + dialog_manager = self._dialog_manager_for_user(location.user_id) view = ActiveGameView.of(game, location.user_id) data = view.window_data() @@ -181,9 +176,7 @@ async def _completed_game_view( location: UserGameLocation, game: Game, ) -> None: - dialog_manager = self._bg_dialog_manager_factory.bg( - self._bot, location.user_id, location.user_id, - ) + dialog_manager = self._dialog_manager_for_user(location.user_id) view = CompletedGameView.of(game, location.user_id) data = view.window_data() diff --git a/src/ttt/presentation/adapters/invitation_to_game_views.py b/src/ttt/presentation/adapters/invitation_to_game_views.py new file mode 100644 index 0000000..7c3cb6d --- /dev/null +++ b/src/ttt/presentation/adapters/invitation_to_game_views.py @@ -0,0 +1,411 @@ +from asyncio import gather +from collections.abc import Sequence +from dataclasses import dataclass +from typing import cast +from uuid import UUID + +from aiogram.types import CallbackQuery +from aiogram.utils.formatting import Code, Text +from aiogram_dialog import DialogManager, ShowMode, StartMode +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from ttt.application.invitation_to_game.game.ports.invitation_to_game_views import ( # noqa: E501 + InvitationToGameViews, +) +from ttt.entities.core.game.game import Game +from ttt.entities.core.invitation_to_game.invitation_to_game import ( + InvitationToGame, + InvitationToGameState, +) +from ttt.entities.core.user.user import User +from ttt.entities.tools.assertion import not_none +from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( + TableInvitationToGame, + TableInvitationToGameState, +) +from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( + DialogManagerForUser, +) +from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState +from ttt.presentation.aiogram_dialog.main_dialog.game_window import ( + ActiveGameView, +) +from ttt.presentation.aiogram_dialog.main_dialog.incoming_invitation_to_game_window import ( # noqa: E501 + IncomingInvitationToGameView, +) +from ttt.presentation.aiogram_dialog.main_dialog.incoming_invitations_to_game_window import ( # noqa: E501 + IncomingInvitationsToGameView, + IncomingInvitationToGameData, +) +from ttt.presentation.aiogram_dialog.main_dialog.outcoming_invitations_to_game_window import ( # noqa: E501 + OutcomingInvitationsToGameView, + OutcomingInvitationToGameData, +) +from ttt.presentation.result_buffer import ResultBuffer + + +@dataclass(frozen=True, unsafe_hash=False) +class AiogramInvitationToGameViews(InvitationToGameViews): + _session: AsyncSession + _callback_query: CallbackQuery | None + _dialog_manager_for_user: DialogManagerForUser + _result_buffer: ResultBuffer + + async def incoming_user_invitations_to_game_view( + self, + user_id: int, + /, + ) -> None: + stmt = ( + select( + TableInvitationToGame.id, + TableInvitationToGame.inviting_user_id, + ) + .where( + (TableInvitationToGame.invited_user_id == user_id) + & ( + TableInvitationToGame.state + == TableInvitationToGameState.active.value + ), + ) + ) + result = await self._session.execute(stmt) + rows = result.all() + + invitations = [ + IncomingInvitationToGameData(row.id, row.inviting_user_id) + for row in rows + ] + self._result_buffer.result = IncomingInvitationsToGameView.of( + invitations, + ) + + async def outcoming_user_invitations_to_game_view( + self, + user_id: int, + /, + ) -> None: + stmt = ( + select( + TableInvitationToGame.id, + TableInvitationToGame.invited_user_id, + ) + .where( + (TableInvitationToGame.inviting_user_id == user_id) + & ( + TableInvitationToGame.state + == TableInvitationToGameState.active.value + ), + ) + ) + result = await self._session.execute(stmt) + rows = result.all() + + invitations = [ + OutcomingInvitationToGameData(row.id.hex, row.invited_user_id) + for row in rows + ] + self._result_buffer.result = OutcomingInvitationsToGameView.of( + invitations, + ) + + async def one_incoming_invitation_to_game_view( + self, user_id: int, /, + ) -> None: + stmt = ( + select( + TableInvitationToGame.id, + TableInvitationToGame.inviting_user_id, + ) + .where( + (TableInvitationToGame.invited_user_id == user_id) + & ( + TableInvitationToGame.state + == TableInvitationToGameState.active.value + ), + ) + .limit(1) + ) + result = await self._session.execute(stmt) + row = result.first() + + if row is None: + self._result_buffer.result = None + return + + self._result_buffer.result = IncomingInvitationToGameView( + id_hex=row.id.hex, + inviting_user_id=row.inviting_user_id, + ) + + async def incoming_invitation_to_game_view( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: + stmt = ( + select(TableInvitationToGame.inviting_user_id) + .where( + (TableInvitationToGame.id == invitation_to_game_id) + & ( + TableInvitationToGame.state + == TableInvitationToGameState.active.value + ), + ) + ) + inviting_user_id = await self._session.scalar(stmt) + + if inviting_user_id 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, + ) + + async def invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: + manager = self._dialog_manager_for_user( + invitation_to_game.inviting_user.id, + ) + await gather( + manager.start( + MainDialogState.outcoming_invitations_to_game, + {}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ), + self._invited_user_to_game_view(invitation_to_game), + ) + + async def _invited_user_to_game_view( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: + if invitation_to_game.invited_user.is_in_game(): + return + + manager = self._dialog_manager_for_user( + invitation_to_game.invited_user.id, + ) + view = IncomingInvitationToGameView( + id_hex=invitation_to_game.id_.hex, + inviting_user_id=invitation_to_game.inviting_user.id, + ) + start_data = view.window_data() + await manager.start( + MainDialogState.incoming_invitation_to_game, + start_data, + ) + + async def cancelled_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: + manager = self._dialog_manager_for_user( + invitation_to_game.inviting_user.id, + ) + await manager.start( + MainDialogState.outcoming_invitations_to_game, + {}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def rejected_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: + invited_user_manager = cast( + DialogManager, + self._dialog_manager_for_user(invitation_to_game.invited_user.id), + ) + inviting_user_manager = self._dialog_manager_for_user( + invitation_to_game.inviting_user.id, + ) + + inviting_user_hint = Text( + "👤 Пользователь ", + Code(invitation_to_game.invited_user.id), + " отклонил ваше приглашение к игре", + ).as_html() + + await gather( + invited_user_manager.done(), + inviting_user_manager.start( + MainDialogState.notification, + {"hint": inviting_user_hint}, + ), + ) + + async def accepted_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + game: Game, + /, + ) -> None: + await gather( + self._active_game_view(game, invitation_to_game.invited_user.id), + self._active_game_view(game, invitation_to_game.inviting_user.id), + ) + + async def invitation_self_to_game_view( + self, + user: User, + /, + ) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + MainDialogState.outcoming_invitations_to_game, + {"hint": "😭 Вы не можете пригласить самого себя"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def double_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + /, + ) -> None: + manager = self._dialog_manager_for_user( + invitation_to_game.inviting_user.id, + ) + await manager.start( + MainDialogState.outcoming_invitations_to_game, + {"hint": "👎 Пользователь уже приглашён в игру"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def invitation_to_game_is_not_active_to_cancel_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: + manager = self._dialog_manager_for_user(user_id) + await manager.start( + MainDialogState.outcoming_invitations_to_game, + {}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def user_is_not_inviting_user_to_cancel_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: + manager = self._dialog_manager_for_user(user_id) + await manager.start( + MainDialogState.outcoming_invitations_to_game, + {}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def invitation_to_game_is_not_active_to_reject_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: + manager = cast( + DialogManager, + self._dialog_manager_for_user(invitation_to_game.invited_user.id), + ) + await manager.done() + + async def user_is_not_invited_user_to_reject_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: + manager = cast(DialogManager, self._dialog_manager_for_user(user_id)) + await manager.done() + + async def invitation_to_game_is_not_active_to_accept_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: + match invitation_to_game.state: + case ( + InvitationToGameState.auto_cancelled + | InvitationToGameState.cancelled_by_user + ): + text = "Приглашение уже отменено" + case InvitationToGameState.rejected: + text = "Приглашение уже отклонено" + case InvitationToGameState.accepted: + return + case InvitationToGameState.active: + raise ValueError + + callback_query = not_none(self._callback_query) + await callback_query.answer(text, show_alert=True) + + async def users_already_in_game_to_accept_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + users_in_game: Sequence[User], + /, + ) -> None: + if invitation_to_game.invited_user in users_in_game: + text = "Закончите игру, прежде чем начинать новую" + elif invitation_to_game.inviting_user in users_in_game: + text = "Пользователь в игре, подождите пока игра закончится" + else: + raise ValueError + + callback_query = not_none(self._callback_query) + await callback_query.answer(text, show_alert=True) + + async def user_is_not_invited_user_to_accept_invitation_to_game_view( + self, + invitation_to_game: InvitationToGame, + user_id: int, + /, + ) -> None: + ... + + async def no_invitation_to_game_to_accept_view( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: + ... + + async def no_invitation_to_game_to_reject_view( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: + manager = cast(DialogManager, self._dialog_manager_for_user(user_id)) + await manager.done() + + async def no_invitation_to_game_to_cancel_view( + self, user_id: int, invitation_to_game_id: UUID, /, + ) -> None: + manager = self._dialog_manager_for_user(user_id) + await manager.start( + MainDialogState.outcoming_invitations_to_game, + {}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def _active_game_view(self, game: Game, user_id: int) -> None: + dialog_manager = self._dialog_manager_for_user(user_id) + + view = ActiveGameView.of(game, user_id) + data = view.window_data() + + await dialog_manager.start( + MainDialogState.game, data, StartMode.RESET_STACK, + ) diff --git a/src/ttt/presentation/adapters/matchmaking_queue_views.py b/src/ttt/presentation/adapters/matchmaking_queue_views.py new file mode 100644 index 0000000..f211250 --- /dev/null +++ b/src/ttt/presentation/adapters/matchmaking_queue_views.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass + +from aiogram_dialog import StartMode + +from ttt.application.matchmaking_queue.common.matchmaking_queue_views import ( + CommonMatchmakingQueueViews, +) +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 AiogramCommonMatchmakingQueueViews(CommonMatchmakingQueueViews): + _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/stars_purchase_payment_gateway.py b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py index 5f744ad..bb8257d 100644 --- a/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py +++ b/src/ttt/presentation/adapters/stars_purchase_payment_gateway.py @@ -4,7 +4,7 @@ from aiogram import Bot from aiogram.types import PreCheckoutQuery -from aiogram_dialog import BgManagerFactory, ShowMode, StartMode +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 @@ -14,26 +14,25 @@ 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, +) from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState @dataclass -class AiogramInAndBufferOutStarsPurchasePaymentGateway( - StarsPurchasePaymentGateway, -): +class AiogramPaymentGateway(StarsPurchasePaymentGateway): _pre_checkout_query: PreCheckoutQuery | None _buffer: Buffer[PaidStarsPurchasePayment] _bot: Bot _payments_token: str = field(repr=False) - _bg_dialog_manager_factory: BgManagerFactory + _dialog_manager_for_user: DialogManagerForUser async def send_invoice( self, purchase: StarsPurchase, ) -> None: - manager = self._bg_dialog_manager_factory.bg( - self._bot, purchase.user_id, 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/user_views.py b/src/ttt/presentation/adapters/user_views.py index efec891..3a11f1b 100644 --- a/src/ttt/presentation/adapters/user_views.py +++ b/src/ttt/presentation/adapters/user_views.py @@ -1,11 +1,16 @@ +from collections import OrderedDict from dataclasses import dataclass +from typing import cast from uuid import UUID from aiogram import Bot -from aiogram_dialog import BgManagerFactory, ShowMode, StartMode -from sqlalchemy import exists, select +from aiogram_dialog import ShowMode, StartMode +from sqlalchemy import exists, func, select from sqlalchemy.ext.asyncio import AsyncSession +from ttt.application.user.change_other_user_account.ports.user_views import ( + ChangeOtherUserAccountViews, +) from ttt.application.user.common.ports.user_views import CommonUserViews from ttt.application.user.emoji_purchase.ports.user_views import ( EmojiPurchaseUserViews, @@ -18,20 +23,48 @@ ) 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 +from ttt.entities.core.user.user import User, is_user_in_game, user_stars from ttt.infrastructure.sqlalchemy.stmts import ( selected_user_emoji_str_from_postgres, user_emojis_from_postgres, ) -from ttt.infrastructure.sqlalchemy.tables.user import TableUser, TableUserEmoji +from ttt.infrastructure.sqlalchemy.tables.invitation_to_game import ( + TableInvitationToGame, + TableInvitationToGameState, +) +from ttt.infrastructure.sqlalchemy.tables.user import ( + TableAdminRight, + TableUser, + TableUserEmoji, +) from ttt.presentation.aiogram.common.messages import ( need_to_start_message, ) +from ttt.presentation.aiogram_dialog.admin_dialog.change_other_user_account2_window import ( # noqa: E501 + ChangeOtherUserAccount2View, +) +from ttt.presentation.aiogram_dialog.admin_dialog.common import ( + AdminDialogState, + AdminRightName, +) +from ttt.presentation.aiogram_dialog.admin_dialog.main_window import ( + AdminMainMenuViewForAdmin, + AdminMainMenuViewForNotAdmin, +) +from ttt.presentation.aiogram_dialog.admin_dialog.other_user_profile_window import ( # noqa: E501 + OtherUserProfileView, +) +from ttt.presentation.aiogram_dialog.common.dialog_manager_for_user import ( + DialogManagerForUser, +) from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState from ttt.presentation.aiogram_dialog.main_dialog.emojis_window import ( EmojiMenuView, ) -from ttt.presentation.aiogram_dialog.main_dialog.main_window import MainMenuView +from ttt.presentation.aiogram_dialog.main_dialog.main_window import ( + AmoutOfIncomingInvitationsToGame, + MainMenuView, +) from ttt.presentation.aiogram_dialog.main_dialog.profile_window import ( UserProfileView, ) @@ -39,10 +72,11 @@ @dataclass(frozen=True, unsafe_hash=False) -class AiogramMessagesFromPostgresAsCommonUserViews(CommonUserViews): +class AiogramCommonUserViews(CommonUserViews): _bot: Bot _session: AsyncSession _result_buffer: ResultBuffer + _dialog_manager_for_user: DialogManagerForUser async def view_of_user_with_id( self, @@ -81,26 +115,62 @@ async def user_menu_view(self, user_id: int, /) -> None: .label("has_user_emojis") ) stmt = ( - select(TableUser.game_location_game_id, has_user_emojis_stmt) + select( + TableUser.game_location_game_id, + TableUser.account_stars, + TableUser.rating, + has_user_emojis_stmt, + ) .where(TableUser.id == user_id) ) result = await self._session.execute(stmt) row = result.first() if row is None: - game_location_game_id = None - has_user_emojis = False - else: - game_location_game_id = row.game_location_game_id - has_user_emojis = row.has_user_emojis + 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( + (TableInvitationToGame.invited_user_id == user_id) + & ( + TableInvitationToGame.state + == TableInvitationToGameState.active.value + ), + ) + .limit(2) + ) + incoming_invitations_to_game = await self._session.scalar( + incoming_invitations_to_game_stmt, + ) + + amout_of_incoming_invitations_to_game: AmoutOfIncomingInvitationsToGame + + if ( + incoming_invitations_to_game == 0 + or incoming_invitations_to_game is None + ): + amout_of_incoming_invitations_to_game = "no" + elif incoming_invitations_to_game == 1: + amout_of_incoming_invitations_to_game = "one" + else: + amout_of_incoming_invitations_to_game = "many" + view = MainMenuView( - is_user_in_game(game_location), has_user_emojis, + is_user_in_game=is_user_in_game(game_location), + has_user_emojis=row.has_user_emojis, + stars=row.account_stars, + rating=row.rating, + amout_of_incoming_invitations_to_game=( + amout_of_incoming_invitations_to_game + ), ) self._result_buffer.result = view @@ -124,11 +194,264 @@ async def user_is_not_registered_view( ) -> None: await need_to_start_message(self._bot, user_id) + def _admin_right_name( + self, table_admin_right: TableAdminRight, + ) -> AdminRightName: + match table_admin_right: + case TableAdminRight.via_admin_token: + return "via_admin_token" + case TableAdminRight.via_other_admin: + return "via_other_admin" + + async def user_admin_view(self, user_id: int, /) -> None: + user_stmt = select(TableUser.admin_right).where(TableUser.id == user_id) + user_table_admin_right = await self._session.scalar(user_stmt) + + if user_table_admin_right is None: + self._result_buffer.result = AdminMainMenuViewForNotAdmin() + return + + user_admin_right_name = self._admin_right_name(user_table_admin_right) + + admin_stmt = ( + select( + TableUser.id, + TableUser.admin_right, + TableUser.admin_right_via_other_admin_admin_id, + ) + .where(TableUser.admin_right.is_not(None)) + ) + admin_result = await self._session.execute(admin_stmt) + admin_rows = admin_result.all() + + admin_tree_index = dict[int, list[int]]() + admin_right_name_map = dict[int, AdminRightName]() + admins_authorized_via_admin_token_count = 0 + admins_authorized_via_other_admins_count = 0 + + for row in admin_rows: + match cast(TableAdminRight, row.admin_right): + case TableAdminRight.via_admin_token: + admins_authorized_via_admin_token_count += 1 + case TableAdminRight.via_other_admin: + admins_authorized_via_other_admins_count += 1 + + for row in admin_rows: + admin_right_name_map[row.id] = self._admin_right_name( + row.admin_right, + ) + + for row in admin_rows: + match cast(TableAdminRight, row.admin_right): + case TableAdminRight.via_admin_token: + admin_tree_index.setdefault(row.id, list()) + case TableAdminRight.via_other_admin: + childs = admin_tree_index.setdefault( + row.admin_right_via_other_admin_admin_id, list(), + ) + childs.append(row.id) + + admin_trees = OrderedDict( + sorted(admin_tree_index.items(), key=lambda item: item[0]), + ) + for childs in admin_trees.values(): + childs.sort() + + self._result_buffer.result = AdminMainMenuViewForAdmin.of( + user_id, + user_admin_right_name, + admin_trees, + admin_right_name_map, + admins_authorized_via_admin_token_count, + admins_authorized_via_other_admins_count, + ) + + async def user_authorized_as_admin_view( + self, + user: User, + /, + ) -> None: + ... + + async def user_already_admin_to_get_admin_rights_view( + self, + user: User, + /, + ) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.main, + {"hint": "🧿 Вы уже админ"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def admin_token_mismatch_to_get_admin_rights_view( + self, + user: User, + /, + ) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.main, + {"hint": "🧿 Админ-токен не верен"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def not_admin_to_relinquish_admin_right_view( + self, + user: User, + /, + ) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.main, + {"hint": "🧿 Вы уже не админ"}, + StartMode.RESET_STACK, + ) + + async def user_relinquished_admin_rights_view(self, user: User, /) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.main, + {"hint": "🧿 Вы больше не админ"}, + StartMode.RESET_STACK, + ) + + async def user_is_not_admin_view(self, user: User, /) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.main, + mode=StartMode.RESET_STACK, + show_mode=ShowMode.DELETE_AND_SEND, + ) + + 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, + TableUser.admin_right_via_other_admin_admin_id, + ) + .where(TableUser.id == other_user_id) + ) + result = await self._session.execute(stmt) + row = result.first() + + if row is None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.other_user_profile, + {"hint": "🎭 Пользователь с таким ID не зарегестрирован:"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + return + + if row.admin_right is None: + admin_right = None + else: + admin_right = row.admin_right.entity( + row.admin_right_via_other_admin_admin_id, + ) + + view = OtherUserProfileView.of( + other_user_id, + admin_right, + row.number_of_wins, + row.number_of_draws, + row.number_of_defeats, + row.account_stars, + row.rating, + ) + + manager = self._dialog_manager_for_user(user.id) + start_data = view.window_data() + await manager.start( + AdminDialogState.other_user_profile, + start_data, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def not_authorized_as_admin_via_admin_token_to_authorize_other_user_as_admin_view( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.authorize_other_user_as_admin, + {"hint": "❌ Вы не авторизованы через админ-токен"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def other_user_already_admin_to_authorize_other_user_as_admin_view( + self, user: User, other_user: User | None, /, + ) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.authorize_other_user_as_admin, + {"hint": "🧿 Пользователь уже админ"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def user_authorized_other_user_as_admin_view( + self, user: User, other_user: User | None, /, + ) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.authorize_other_user_as_admin, + {"hint": "🧿 Права выданы"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def not_authorized_as_admin_via_admin_token_to_deauthorize_other_user_as_admin_view( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.deauthorize_other_user_as_admin, + {"hint": "🧿 Вы не авторизованы через админ-токен"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def other_user_is_not_authorized_as_admin_via_other_admin_to_deauthorize_view( # noqa: E501 + self, user: User, other_user: User | None, /, + ) -> None: + manager = self._dialog_manager_for_user(user.id) + hint = ( + "🧿 Пользователь должен быть авторизован как админ другим админом" + ) + await manager.start( + AdminDialogState.deauthorize_other_user_as_admin, + {"hint": hint}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def user_deauthorized_other_user_as_admin_view( + self, user: User, other_user: User | None, /, + ) -> None: + manager = self._dialog_manager_for_user(user.id) + await manager.start( + AdminDialogState.deauthorize_other_user_as_admin, + {"hint": "🧿 Пользователь больше не админ"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + @dataclass(frozen=True, unsafe_hash=False) -class AiogramMessagesAsStarsPurchaseUserViews(StarsPurchaseUserViews): - _bot: Bot - _bg_dialog_manager_factory: BgManagerFactory +class AiogramStarsPurchaseUserViews(StarsPurchaseUserViews): + _dialog_manager_for_user: DialogManagerForUser async def invalid_stars_for_stars_purchase_view( self, @@ -142,9 +465,7 @@ async def stars_purchase_will_be_completed_view( user_id: int, /, ) -> None: - manager = self._bg_dialog_manager_factory.bg( - self._bot, user_id, user_id, - ) + manager = self._dialog_manager_for_user(user_id) await manager.start( MainDialogState.stars_shop, {"hint": "🌟 Звёзды скоро начислятся!"}, @@ -158,9 +479,7 @@ async def completed_stars_purchase_view( purchase_id: UUID, /, ) -> None: - manager = self._bg_dialog_manager_factory.bg( - self._bot, user.id, user.id, - ) + manager = self._dialog_manager_for_user(user.id) await manager.start( MainDialogState.stars_shop, {"hint": "🌟 Звезды начислились!"}, @@ -170,12 +489,7 @@ async def completed_stars_purchase_view( @dataclass(frozen=True, unsafe_hash=False) -class AiogramMessagesFromPostgresAsEmojiSelectionUserViews( - EmojiSelectionUserViews, -): - _bot: Bot - _session: AsyncSession - +class AiogramEmojiSelectionUserViews(EmojiSelectionUserViews): async def invalid_emoji_to_select_view( self, user_id: int, @@ -192,9 +506,8 @@ async def emoji_not_purchased_to_select_view( @dataclass(frozen=True, unsafe_hash=False) -class AiogramMessagesAsEmojiPurchaseUserViews(EmojiPurchaseUserViews): - _bot: Bot - _bg_dialog_manager_factory: BgManagerFactory +class AiogramEmojiPurchaseUserViews(EmojiPurchaseUserViews): + _dialog_manager_for_user: DialogManagerForUser async def not_enough_stars_to_buy_emoji_view( self, @@ -202,9 +515,7 @@ async def not_enough_stars_to_buy_emoji_view( stars_to_become_enough: Stars, /, ) -> None: - manager = self._bg_dialog_manager_factory.bg( - self._bot, user_id, user_id, - ) + manager = self._dialog_manager_for_user(user_id) await manager.start( MainDialogState.emoji_shop, {"hint": f"😞 Нужно ещё {stars_to_become_enough} 🌟 для покупки"}, @@ -213,9 +524,7 @@ async def not_enough_stars_to_buy_emoji_view( ) async def emoji_already_purchased_view(self, user_id: int, /) -> None: - manager = self._bg_dialog_manager_factory.bg( - self._bot, user_id, user_id, - ) + manager = self._dialog_manager_for_user(user_id) await manager.start( MainDialogState.emoji_shop, {"hint": "🎭 Эмоджи уже куплен"}, @@ -224,9 +533,7 @@ async def emoji_already_purchased_view(self, user_id: int, /) -> None: ) async def emoji_was_purchased_view(self, user_id: int, /) -> None: - manager = self._bg_dialog_manager_factory.bg( - self._bot, user_id, user_id, - ) + manager = self._dialog_manager_for_user(user_id) await manager.start( MainDialogState.emoji_shop, {"hint": "🌟 Куплено!"}, @@ -235,9 +542,7 @@ async def emoji_was_purchased_view(self, user_id: int, /) -> None: ) async def invalid_emoji_to_buy_view(self, user_id: int, /) -> None: - manager = self._bg_dialog_manager_factory.bg( - self._bot, user_id, user_id, - ) + manager = self._dialog_manager_for_user(user_id) message_text = ( "❌ Эмоджи должен состоять из одного символа" ) @@ -247,3 +552,90 @@ async def invalid_emoji_to_buy_view(self, user_id: int, /) -> None: StartMode.RESET_STACK, ShowMode.DELETE_AND_SEND, ) + + +@dataclass(frozen=True, unsafe_hash=False) +class AiogramChangeOtherUserAccountViews(ChangeOtherUserAccountViews): + _dialog_manager_for_user: DialogManagerForUser + _session: AsyncSession + _result_buffer: ResultBuffer + + async def user_account_to_change_view( + self, user_id: int, other_user_id: int, /, + ) -> None: + stmt = ( + select(TableUser.account_stars) + .where(TableUser.id == other_user_id) + ) + stars = await self._session.scalar(stmt) + stars = user_stars(stars) + + self._result_buffer.result = ChangeOtherUserAccount2View(stars) + + async def user_set_other_user_account_view( + self, user: User, other_user: User, /, + ) -> None: + await self._account_view( + user.id, other_user.id, other_user.account.stars, + ) + + async def user_changed_other_user_account_view( + self, + user: User, + other_user: User, + other_user_account_stars_vector: Stars, + /, + ) -> None: + await self._account_view( + user.id, other_user.id, other_user.account.stars, + ) + + async def negative_account_on_change_other_user_account_view( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars_vector: Stars, + /, + ) -> None: + await self._negative_account_view(user.id, other_user_id) + + async def negative_account_on_set_other_user_account_view( + self, + user: User, + other_user: User | None, + other_user_id: int, + other_user_account_stars: Stars, + /, + ) -> None: + await self._negative_account_view(user.id, other_user_id) + + async def _negative_account_view( + self, user_id: int, other_user_id: int, + ) -> None: + manager = self._dialog_manager_for_user(user_id) + start_data = { + "hint": "Счёт не может быть отрицательным 👎", + "other_user_id": other_user_id, + } + await manager.start( + AdminDialogState.change_other_user_account2, + start_data, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + async def _account_view( + self, user_id: int, other_user_id: int, other_user_account_stars: Stars, + ) -> None: + manager = self._dialog_manager_for_user(user_id) + start_data = { + "other_user_id": other_user_id, + "other_user_account_stars": other_user_account_stars, + } + await manager.start( + AdminDialogState.change_other_user_account2, + start_data, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) diff --git a/src/ttt/presentation/aiogram/common/bots.py b/src/ttt/presentation/aiogram/common/bots.py index 9ea6cd5..031285d 100644 --- a/src/ttt/presentation/aiogram/common/bots.py +++ b/src/ttt/presentation/aiogram/common/bots.py @@ -4,5 +4,6 @@ async def ttt_bot(bot: Bot) -> None: await bot.set_my_commands([ + BotCommand(command="admin", description="Админ-панель"), BotCommand(command="start", description="Запустить бота"), ]) diff --git a/src/ttt/presentation/aiogram/common/routes/admin.py b/src/ttt/presentation/aiogram/common/routes/admin.py new file mode 100644 index 0000000..5dfd296 --- /dev/null +++ b/src/ttt/presentation/aiogram/common/routes/admin.py @@ -0,0 +1,19 @@ +from aiogram import Router +from aiogram.filters import Command +from aiogram.fsm.state import any_state +from aiogram.types.message import Message +from aiogram_dialog import DialogManager, StartMode +from dishka.integrations.aiogram import inject + +from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState + + +admin_router = Router(name=__name__) + + +@admin_router.message(any_state, Command("admin")) +@inject +async def _(_: Message, dialog_manager: DialogManager) -> None: + await dialog_manager.start( + AdminDialogState.main, None, StartMode.RESET_STACK, + ) diff --git a/src/ttt/presentation/aiogram/common/routes/all.py b/src/ttt/presentation/aiogram/common/routes/all.py index 739a188..d75e1b2 100644 --- a/src/ttt/presentation/aiogram/common/routes/all.py +++ b/src/ttt/presentation/aiogram/common/routes/all.py @@ -1,8 +1,10 @@ +from ttt.presentation.aiogram.common.routes.admin import admin_router from ttt.presentation.aiogram.common.routes.error_handling import ( error_handling_router, ) common_routers = ( + admin_router, error_handling_router, ) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/__init__.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/__init__.py new file mode 100644 index 0000000..e289c88 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/__init__.py @@ -0,0 +1,38 @@ +from aiogram_dialog import Dialog + +from ttt.presentation.aiogram_dialog.admin_dialog.authorize_other_user_as_admin_window import ( # noqa: E501 + authorize_other_user_as_admin_window, +) +from ttt.presentation.aiogram_dialog.admin_dialog.change_other_user_account1_window import ( # noqa: E501 + change_other_user_account1_window, +) +from ttt.presentation.aiogram_dialog.admin_dialog.change_other_user_account2_window import ( # noqa: E501 + change_other_user_account2_window, +) +from ttt.presentation.aiogram_dialog.admin_dialog.deauthorize_other_user_as_admin_window import ( # noqa: E501 + deauthorize_other_user_as_admin_window, +) +from ttt.presentation.aiogram_dialog.admin_dialog.main_window import main_window +from ttt.presentation.aiogram_dialog.admin_dialog.other_user_profile_window import ( # noqa: E501 + other_user_profile_window, +) +from ttt.presentation.aiogram_dialog.admin_dialog.relinquish_admin_right1_window import ( # noqa: E501 + relinquish_admin_right1_window, +) +from ttt.presentation.aiogram_dialog.admin_dialog.relinquish_admin_right2_window import ( # noqa: E501 + relinquish_admin_right2_window, +) + + +__all__ = ["admin_dialog"] + +admin_dialog = Dialog( + main_window, + other_user_profile_window, + authorize_other_user_as_admin_window, + deauthorize_other_user_as_admin_window, + relinquish_admin_right1_window, + relinquish_admin_right2_window, + change_other_user_account1_window, + change_other_user_account2_window, +) 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 new file mode 100644 index 0000000..b2ca006 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/authorize_other_user_as_admin_window.py @@ -0,0 +1,61 @@ +from aiogram.enums import ContentType +from aiogram.types import Message +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import ( + SwitchTo, +) +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject +from magic_filter import F + +from ttt.application.user.authorize_other_user_as_admin import ( + AuthorizeOtherUserAsAdmin, +) +from ttt.entities.tools.assertion import not_none +from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState +from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( + OneTimekey, +) + + +@inject +async def input_user_id( + message: Message, + _: MessageInput, + manager: DialogManager, + authorize_other_user_as_admin: FromDishka[AuthorizeOtherUserAsAdmin], +) -> None: + try: + other_user_id = int(message.text) # type: ignore[arg-type] + except ValueError: + await manager.start( + AdminDialogState.authorize_other_user_as_admin, + {"hint": "❌ ID должен быть целочисленым числом:"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + else: + await authorize_other_user_as_admin( + not_none(message.from_user).id, other_user_id, + ) + + +authorize_other_user_as_admin_window = Window( + Format("{start_data[hint]}", when=F["start_data"]["hint"]), + + Const( + "🧿 Введите ID пользователя:", + when=~F["start_data"]["profile"] & ~F["start_data"]["hint"], + ), + MessageInput( + input_user_id, + content_types=[ContentType.ANY], + ), + + SwitchTo(Const("Назад"), id="back", state=AdminDialogState.main), + + OneTimekey("hint"), + state=AdminDialogState.authorize_other_user_as_admin, +) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account1_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account1_window.py new file mode 100644 index 0000000..33c2b34 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account1_window.py @@ -0,0 +1,57 @@ +from aiogram.enums import ContentType +from aiogram.types import Message +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import ( + SwitchTo, +) +from aiogram_dialog.widgets.text import Const, Format, Multi +from magic_filter import F + +from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState +from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( + OneTimekey, +) + + +async def input_user_id( + message: Message, + _: MessageInput, + manager: DialogManager, +) -> None: + try: + other_user_id = int(message.text) # type: ignore[arg-type] + except ValueError: + await manager.start( + AdminDialogState.change_other_user_account2, + {"hint": "❌ ID должен быть целочисленым числом:"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + else: + await manager.start( + AdminDialogState.change_other_user_account2, + {"other_user_id": other_user_id}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + +change_other_user_account1_window = Window( + Multi( + Format("{start_data[hint]}"), + Const(" "), + when=F["start_data"]["hint"], + ), + + Const("🧿 Введите ID пользователя:"), + MessageInput( + input_user_id, + content_types=[ContentType.ANY], + ), + + SwitchTo(Const("Назад"), id="back", state=AdminDialogState.main), + + OneTimekey("hint"), + state=AdminDialogState.change_other_user_account1, +) 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 new file mode 100644 index 0000000..b366004 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/change_other_user_account2_window.py @@ -0,0 +1,147 @@ +from dataclasses import dataclass +from functools import partial +from typing import Any, cast + +from aiogram.enums import ContentType +from aiogram.types import CallbackQuery, Message, User +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import ( + Button, + Row, + SwitchTo, +) +from aiogram_dialog.widgets.text import Const, Format +from dishka import AsyncContainer, FromDishka +from dishka.integrations.aiogram_dialog import CONTAINER_NAME, inject + +from ttt.application.user.change_other_user_account.change_other_user_account import ( # noqa: E501 + ChangeOtherUserAccount, +) +from ttt.application.user.change_other_user_account.set_other_user_account import ( # noqa: E501 + SetOtherUserAccount, +) +from ttt.application.user.change_other_user_account.view_user_account_to_change import ( # noqa: E501 + ViewUserAccountToChange, +) +from ttt.entities.core.stars import Stars +from ttt.entities.tools.assertion import not_none +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 +from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( + OneTimekey, +) +from ttt.presentation.result_buffer import ResultBuffer + + +@dataclass(frozen=True) +class ChangeOtherUserAccount2View(EncodableToWindowData): + other_user_account_stars: Stars + + +@inject +async def getter( + *, + event_from_user: User, + view_user_account_to_change: FromDishka[ViewUserAccountToChange], + result_buffer: FromDishka[ResultBuffer], + dialog_manager: DialogManager, + **_: Any, # noqa: ANN401 +) -> dict[str, Any]: + if not isinstance(dialog_manager.start_data, dict): + raise TypeError + + if "other_user_account_stars" in dialog_manager.start_data: + stars = dialog_manager.start_data.pop("other_user_account_stars") + view = ChangeOtherUserAccount2View(stars) + return view.window_data() + + await view_user_account_to_change( + event_from_user.id, + dialog_manager.start_data["other_user_id"], + ) + view = result_buffer(ChangeOtherUserAccount2View) + return view.window_data() + + +@inject +async def input_stars( + message: Message, + _: MessageInput, + manager: DialogManager, +) -> None: + dishka_container = ( + cast(AsyncContainer, manager.middleware_data[CONTAINER_NAME]) + ) + + invalid_format_view = partial( + manager.start, + AdminDialogState.authorize_other_user_as_admin, + {"hint": "Неправильный формат 👎"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + + if message.text is None: + await invalid_format_view() + return + + try: + stars = int(message.text.replace(" ", "")) + except ValueError: + await invalid_format_view() + return + + user_id = not_none(message.from_user).id + other_user_id = cast(dict[str, int], manager.start_data)["other_user_id"] + + match message.text[:1]: + case "+" | "-": + change_other_user_account = await dishka_container.get( + ChangeOtherUserAccount, + ) + await change_other_user_account(user_id, other_user_id, stars) + case _: + set_other_user_account = await dishka_container.get( + SetOtherUserAccount, + ) + await set_other_user_account(user_id, other_user_id, stars) + + +async def on_format_clicked( + callback: CallbackQuery, + _: Button, + __: DialogManager, +) -> None: + text = ( + "5000 — чтобы счёт был просто 5000 🌟" + "\n\n+5000 — чтобы счёт увеличился на 5000 🌟" + "\n\n-5000 — чтобы счёт уменьшился на 5000 🌟" + ) + await callback.answer(text, show_alert=True) + + +change_other_user_account2_window = Window( + Format("На счёту пользователя {main[other_user_account_stars]} 🌟"), + hint(key="hint"), + Const(" "), + Const("🧿 Введите звёзды:"), + MessageInput( + input_stars, + content_types=[ContentType.ANY], + ), + + Row( + Button(Const("Формат"), id="format", on_click=on_format_clicked), + SwitchTo( + Const("Назад"), + id="back", + state=AdminDialogState.change_other_user_account1, + ), + ), + + OneTimekey("hint"), + state=AdminDialogState.change_other_user_account2, + getter=getter, +) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/common.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/common.py new file mode 100644 index 0000000..61fe633 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/common.py @@ -0,0 +1,97 @@ +from typing import Literal + +from aiogram.fsm.state import State, StatesGroup +from aiogram.utils.formatting import Code + +from ttt.entities.core.user.admin_right import ( + AdminRight, + AdminRightViaAdminToken, + AdminRightViaOtherAdmin, +) + + +class AdminDialogState(StatesGroup): + main = State() + other_user_profile = State() + relinquish_admin_right1 = State() + relinquish_admin_right2 = State() + authorize_other_user_as_admin = State() + deauthorize_other_user_as_admin = State() + change_other_user_account1 = State() + change_other_user_account2 = State() + + +type AdminRightName = Literal["via_admin_token", "via_other_admin"] + + +def admin_right_name(admin_right: AdminRight | None) -> AdminRightName | None: + match admin_right: + case AdminRightViaAdminToken(): + return "via_admin_token" + case AdminRightViaOtherAdmin(): + return "via_other_admin" + case None: + return None + + +def user_sign(user_admin_right_name: AdminRightName | None) -> str: + match user_admin_right_name: + case None: + return "-" + case "via_admin_token": + return "*" + case "via_other_admin": + return "#" + + +def admin_tree_user_id_html(user_id: int, current_user_id: int) -> str: + trailer = " (Вы)" if user_id == current_user_id else "" + return f"{Code(user_id).as_html()}{trailer}" + + +def admin_tree_user_id_title_html( + user_id: int, + user_admin_right_name: AdminRightName | None, + current_user_id: int, +) -> str: + sign = user_sign(user_admin_right_name) + id_ = admin_tree_user_id_html(user_id, current_user_id) + + return f"{sign} {id_}" + + +def admin_tree_html( + parent: int, + childs: list[int], + admin_right_name_map: dict[int, AdminRightName], + current_user_id: int, +) -> str: + parent_indetation = " " * 4 + child_indetation = " " * 8 + + parent_title = admin_tree_user_id_title_html( + parent, admin_right_name_map.get(parent), current_user_id, + ) + parent_part = ( + f"{parent_indetation}{parent_title}:" + if childs + else f"{parent_indetation}{parent_title}" + ) + + if not childs: + return parent_part + + child_part = "\n".join( + f"""{ + child_indetation + }{ + admin_tree_user_id_title_html( + child, + admin_right_name_map.get(child), + current_user_id, + ) + }""" + for child in childs + ) + + return f"{parent_part}\n{child_part}" 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 new file mode 100644 index 0000000..5283b8b --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/deauthorize_other_user_as_admin_window.py @@ -0,0 +1,62 @@ + +from aiogram.enums import ContentType +from aiogram.types import Message +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import ( + SwitchTo, +) +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject +from magic_filter import F + +from ttt.application.user.deauthorize_other_user_as_admin import ( + DeauthorizeOtherUserAsAdmin, +) +from ttt.entities.tools.assertion import not_none +from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState +from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( + OneTimekey, +) + + +@inject +async def input_user_id( + message: Message, + _: MessageInput, + manager: DialogManager, + deauthorize_other_user_as_admin: FromDishka[DeauthorizeOtherUserAsAdmin], +) -> None: + try: + other_user_id = int(message.text) # type: ignore[arg-type] + except ValueError: + await manager.start( + AdminDialogState.authorize_other_user_as_admin, + {"hint": "❌ ID должен быть целочисленым числом:"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + else: + await deauthorize_other_user_as_admin( + not_none(message.from_user).id, other_user_id, + ) + + +deauthorize_other_user_as_admin_window = Window( + Format("{start_data[hint]}", when=F["start_data"]["hint"]), + + Const( + "🧿 Введите ID пользователя:", + when=~F["start_data"]["profile"] & ~F["start_data"]["hint"], + ), + MessageInput( + input_user_id, + content_types=[ContentType.ANY], + ), + + SwitchTo(Const("Назад"), id="back", state=AdminDialogState.main), + + OneTimekey("hint"), + state=AdminDialogState.deauthorize_other_user_as_admin, +) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py new file mode 100644 index 0000000..f995ffe --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/main_window.py @@ -0,0 +1,216 @@ +from dataclasses import dataclass, field +from typing import Any, Literal + +from aiogram.enums import ContentType, ParseMode +from aiogram.types import CallbackQuery, Message, User +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import Button, Start, SwitchTo +from aiogram_dialog.widgets.text import Const, Format, Multi +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject +from magic_filter import F + +from ttt.application.user.authorize_as_admin import AuthorizeAsAdmin +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.presentation.aiogram_dialog.admin_dialog.common import ( + AdminDialogState, + AdminRightName, + admin_tree_html, +) +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.main_dialog.common import MainDialogState +from ttt.presentation.result_buffer import ResultBuffer + + +@dataclass(frozen=True) +class AdminMainMenuViewForAdmin(EncodableToWindowData): + type_: Literal["admin"] = field(init=False, default="admin") + user_id: int + user_admin_right_name: AdminRightName + admin_trees: dict[int, list[int]] + admin_right_name_map: dict[int, AdminRightName] + admins_authorized_via_admin_token_count: int + admins_authorized_via_other_admins_count: int + admin_count: int + + @classmethod + def of( # noqa: PLR0913, PLR0917 + cls, + user_id: int, + user_admin_right_name: AdminRightName, + admin_trees: dict[int, list[int]], + admin_right_name_map: dict[int, AdminRightName], + admins_authorized_via_admin_token_count: int, + admins_authorized_via_other_admins_count: int, + ) -> "AdminMainMenuViewForAdmin": + admin_count = ( + admins_authorized_via_admin_token_count + + admins_authorized_via_other_admins_count + ) + + return AdminMainMenuViewForAdmin( + user_id=user_id, + user_admin_right_name=user_admin_right_name, + admin_trees=admin_trees, + admin_right_name_map=admin_right_name_map, + admins_authorized_via_admin_token_count=( + admins_authorized_via_admin_token_count + ), + admins_authorized_via_other_admins_count=( + admins_authorized_via_other_admins_count + ), + admin_count=admin_count, + ) + + +@dataclass(frozen=True) +class AdminMainMenuViewForNotAdmin(EncodableToWindowData): + type_: Literal["not_admin"] = field(init=False, default="not_admin") + + +AdminMainMenuView = AdminMainMenuViewForAdmin | AdminMainMenuViewForNotAdmin + + +@inject +async def main_getter( + *, + event_from_user: User, + view_admin_menu: FromDishka[ViewAdminMenu], + result_buffer: FromDishka[ResultBuffer], + **_: Any, # noqa: ANN401 +) -> dict[str, Any]: + await view_admin_menu(event_from_user.id) + view: AdminMainMenuView = result_buffer(AdminMainMenuView) # type: ignore[arg-type] + + return view.window_data() + + +@inject +async def input_admin_token( + message: Message, + _: MessageInput, + manager: DialogManager, + authorize_as_admin: FromDishka[AuthorizeAsAdmin], +) -> None: + admin_token = message.text + + if admin_token is None: + await manager.start( + AdminDialogState.main, + {"hint": "❌ Админ-токен должен быть в виде текста"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + return + + await authorize_as_admin(not_none(message.from_user).id, admin_token) + + +@inject +async def on_relinquish_admin_right_clicked( + callback: CallbackQuery, + _: Button, + __: DialogManager, + relinquish_admin_right: FromDishka[RelinquishAdminRight], +) -> None: + await relinquish_admin_right(callback.from_user.id) + + +@FuncText +async def admin_trees_text(data: dict[str, Any], _: DialogManager) -> str: # noqa: RUF029 + return "\n\n".join( + admin_tree_html( + parent, + childs, + data["main"]["admin_right_name_map"], + data["main"]["user_id"], + ) + for parent, childs in data["main"]["admin_trees"].items() + ) + + +is_admin_f = F["main"]["type_"] == "admin" +is_admin_via_admin_token_f = ( + is_admin_f & F["main"]["user_admin_right_name"] == "via_admin_token" +) + +main_window = Window( + Multi( + Format( + "Всего админов {main[admin_count]}" + " ({main[admins_authorized_via_admin_token_count]}" + "+{main[admins_authorized_via_other_admins_count]})", + ), + Multi( + Const("Админы:"), + admin_trees_text, + when=F["main"]["admin_trees"].len() > 0, + ), + Const(" "), + Const("🧿 Что хотите сделать?"), + when=is_admin_f & ~F["start_data"]["hint"], + ), + SwitchTo( + Const("Посмотреть профиль пользователя"), + state=AdminDialogState.other_user_profile, + id="other_user_profile", + when=is_admin_f, + ), + SwitchTo( + Const("Изменить счёт пользователя"), + state=AdminDialogState.change_other_user_account1, + id="change_other_user_account", + when=is_admin_f, + ), + SwitchTo( + Const("Выдать админ-права"), + id="authorize_other_user_as_admin", + state=AdminDialogState.authorize_other_user_as_admin, + when=is_admin_via_admin_token_f, + ), + SwitchTo( + Const("Забрать админ-права"), + id="deauthorize_other_user_as_admin", + state=AdminDialogState.deauthorize_other_user_as_admin, + when=is_admin_via_admin_token_f, + ), + SwitchTo( + Const("Отказатся от админ-прав"), + id="relinquish_admin_right", + state=AdminDialogState.relinquish_admin_right1, + when=is_admin_f, + ), + + Multi( + Const("Вы не админ"), + Const(" "), + Const("🧿 Что бы получить права админа введите админ-токен:"), + when=~is_admin_f & ~F["start_data"]["hint"], + ), + MessageInput( + input_admin_token, + content_types=[ContentType.ANY], + ), + + hint(key="hint"), + + Start( + Const("Вернутся в главное меню"), + id="exit", + state=MainDialogState.main, + mode=StartMode.RESET_STACK, + ), + + OneTimekey("hint"), + state=AdminDialogState.main, + getter=main_getter, + parse_mode=ParseMode.HTML, +) 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 new file mode 100644 index 0000000..c525233 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/other_user_profile_window.py @@ -0,0 +1,135 @@ +from dataclasses import dataclass + +from aiogram.enums import ContentType, ParseMode +from aiogram.types import Message +from aiogram.utils.formatting import Code, Text +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import ( + SwitchTo, +) +from aiogram_dialog.widgets.text import Case, Const, Format, Multi +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject +from magic_filter import F + +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.tools.assertion import not_none +from ttt.presentation.aiogram_dialog.admin_dialog.common import ( + AdminDialogState, + AdminRightName, + admin_right_name, +) +from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData +from ttt.presentation.aiogram_dialog.common.wigets.one_time_key import ( + OneTimekey, +) +from ttt.presentation.texts import ( + rank_title, + short_float_text, +) + + +@dataclass(frozen=True) +class OtherUserProfileView(EncodableToWindowData): + id_: int + admin_right_name: AdminRightName | None + admin_right: AdminRight | None + number_of_wins: int + number_of_draws: int + number_of_defeats: int + account_stars: int + rating_text: str + rank_text: str + + def _data_key(self) -> str: + return "profile" + + @classmethod + def of( # noqa: PLR0913, PLR0917 + cls, + id_: int, + admin_right: AdminRight | None, + number_of_wins: int, + number_of_draws: int, + number_of_defeats: int, + account_stars: int, + rating: float, + ) -> "OtherUserProfileView": + return OtherUserProfileView( + id_=id_, + admin_right_name=admin_right_name(admin_right), + admin_right=admin_right, + number_of_wins=number_of_wins, + number_of_draws=number_of_draws, + number_of_defeats=number_of_defeats, + account_stars=account_stars, + rating_text=short_float_text(rating), + rank_text=rank_title(rank_for_rating(rating)), + ) + + +@inject +async def input_user_id( + message: Message, + _: MessageInput, + manager: DialogManager, + view_other_user: FromDishka[ViewOtherUser], +) -> None: + try: + other_user_id = int(message.text) # type: ignore[arg-type] + except ValueError: + await manager.start( + AdminDialogState.other_user_profile, + {"hint": "❌ ID должен быть целочисленым числом:"}, + StartMode.RESET_STACK, + ShowMode.DELETE_AND_SEND, + ) + else: + await view_other_user(not_none(message.from_user).id, other_user_id) + + +other_user_profile_window = Window( + Format("{start_data[hint]}", when=F["start_data"]["hint"]), + + Const( + "🧿 Введите ID пользователя:", + when=~F["start_data"]["profile"] & ~F["start_data"]["hint"], + ), + MessageInput( + input_user_id, + content_types=[ContentType.ANY], + ), + + Multi( + Format(Text( + "🎭 Профиль пользователя ", Code("{start_data[profile][id_]}"), + ).as_html()), + Const(" "), + Case(selector=F["start_data"]["profile"]["admin_right_name"], texts={ + None: Const("🧿 Не авторизорван как админ"), + "via_admin_token": Const( + "🧿 Авторизорван как админ используя админ-токен", + ), + "via_other_admin": Format(Text( + "🧿 Авторизорван как админ пользователем ", + Code("{start_data[profile][admin_right][admin_id]}"), + ).as_html()), + }), + Format("🌟 Звёзд: {start_data[profile][account_stars]}"), + Format("🏅 Рейтинг: {start_data[profile][rating_text]}"), + Format("⚔️ Ранг: {start_data[profile][rank_text]}"), + Format("🏆 Побед: {start_data[profile][number_of_wins]}"), + Format("💀 Поражений: {start_data[profile][number_of_defeats]}"), + Format("🕊️ Ничьих: {start_data[profile][number_of_draws]}"), + when=F["start_data"]["profile"] & ~F["start_data"]["hint"], + ), + SwitchTo(Const("Назад"), id="back", state=AdminDialogState.main), + + OneTimekey("profile"), + OneTimekey("hint"), + state=AdminDialogState.other_user_profile, + parse_mode=ParseMode.HTML, +) diff --git a/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right1_window.py b/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right1_window.py new file mode 100644 index 0000000..2cffd72 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right1_window.py @@ -0,0 +1,27 @@ + +from aiogram_dialog import Window +from aiogram_dialog.widgets.kbd import Row, SwitchTo +from aiogram_dialog.widgets.text import Const + +from ttt.presentation.aiogram_dialog.admin_dialog.common import AdminDialogState + + +relinquish_admin_right1_window = Window( + Const("Вы потеряете доступ к админ-панели"), + Const(" "), + Const("🧿 Вы уверены?"), + + Row( + SwitchTo( + Const("Да"), + id="yes", + state=AdminDialogState.relinquish_admin_right2, + ), + SwitchTo( + Const("Нет"), + id="no", + state=AdminDialogState.main, + ), + ), + state=AdminDialogState.relinquish_admin_right1, +) 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 new file mode 100644 index 0000000..20e9afd --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/admin_dialog/relinquish_admin_right2_window.py @@ -0,0 +1,52 @@ + +from aiogram.types import CallbackQuery +from aiogram.utils.formatting import Bold, Text +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import Button, Row, SwitchTo +from aiogram_dialog.widgets.text import Const +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject +from magic_filter import F + +from ttt.application.user.relinquish_admin_right import RelinquishAdminRight +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 ( + OneTimekey, +) + + +@inject +async def on_yes_clicked( + callback: CallbackQuery, + _: Button, + __: DialogManager, + relinquish_admin_right: FromDishka[RelinquishAdminRight], +) -> None: + await relinquish_admin_right(callback.from_user.id) + + +relinquish_admin_right2_window = Window( + Const( + Text("🧿 Вы ", Bold("абсолютно точно"), " уверены?").as_html(), + when=~F["start"]["hint"], + ), + + hint(key="hint"), + Row( + Button( + Const("Да"), + id="yes", + on_click=on_yes_clicked, + ), + SwitchTo( + Const("Нет"), + id="no", + state=AdminDialogState.main, + ), + ), + + OneTimekey("hint"), + state=AdminDialogState.relinquish_admin_right2, + parse_mode="html", +) diff --git a/src/ttt/presentation/aiogram_dialog/common.py b/src/ttt/presentation/aiogram_dialog/common.py deleted file mode 100644 index 5eb1170..0000000 --- a/src/ttt/presentation/aiogram_dialog/common.py +++ /dev/null @@ -1,11 +0,0 @@ -from dataclasses import asdict, dataclass -from typing import Any - - -@dataclass(frozen=True) -class EncodableToWindowData: - def window_data(self) -> dict[str, Any]: - return {self._data_key(): asdict(self)} - - def _data_key(self) -> str: - return "main" diff --git a/src/ttt/presentation/aiogram_dialog/common/dialog_manager_for_user.py b/src/ttt/presentation/aiogram_dialog/common/dialog_manager_for_user.py new file mode 100644 index 0000000..40a5a5d --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/common/dialog_manager_for_user.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from aiogram import Bot +from aiogram_dialog import BaseDialogManager, BgManagerFactory +from aiogram_dialog.manager.manager import ManagerImpl + + +@dataclass +class DialogManagerForUser: + _event_dialog_manager: ManagerImpl | None + _bg_manager_factory: BgManagerFactory + _bot: Bot + + def __call__(self, user_id: int) -> BaseDialogManager: + if self._event_dialog_manager is None: + return self._bg(user_id) + + current_user = self._event_dialog_manager.event.from_user + + if current_user is None or current_user.id != user_id: + return self._bg(user_id) + + return self._event_dialog_manager + + def _bg(self, user_id: int) -> BaseDialogManager: + return self._bg_manager_factory.bg(self._bot, user_id, user_id) diff --git a/src/ttt/presentation/aiogram_dialog/common/wigets/func_text.py b/src/ttt/presentation/aiogram_dialog/common/wigets/func_text.py new file mode 100644 index 0000000..9e44411 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/common/wigets/func_text.py @@ -0,0 +1,24 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from aiogram_dialog import DialogManager +from aiogram_dialog.widgets.common import WhenCondition +from aiogram_dialog.widgets.text import Text + + +class FuncText(Text): + def __init__( + self, + func: Callable[[dict[str, Any], DialogManager], Awaitable[str]], + *, + when: WhenCondition = None, + ) -> None: + super().__init__(when=when) + self.func = func + + async def _render_text( + self, + data: dict[str, Any], + manager: DialogManager, + ) -> str: + return await self.func(data, manager) diff --git a/src/ttt/presentation/aiogram_dialog/common/wigets/hint.py b/src/ttt/presentation/aiogram_dialog/common/wigets/hint.py index b55fdc1..5fe0938 100644 --- a/src/ttt/presentation/aiogram_dialog/common/wigets/hint.py +++ b/src/ttt/presentation/aiogram_dialog/common/wigets/hint.py @@ -1,23 +1,6 @@ -from typing import Any - -from aiogram_dialog import DialogManager -from aiogram_dialog.widgets.text import Text +from aiogram_dialog.widgets.text import Format from magic_filter import F -class Hint(Text): - def __init__(self, text: Text, hint_key: str = "hint") -> None: - super().__init__(when=F["start_data"][hint_key]) - - self.text = text - self.hint_key = hint_key - - async def _render_text( - self, data: dict[str, Any], manager: DialogManager, - ) -> str: - text = await self.text.render_text(data, manager) - - if isinstance(manager.start_data, dict): - del manager.start_data[self.hint_key] - - return text +def hint(key: str) -> Format: + return Format(f"{{start_data[{key}]}}", when=F["start_data"][key]) diff --git a/src/ttt/presentation/aiogram_dialog/common/wigets/one_time_key.py b/src/ttt/presentation/aiogram_dialog/common/wigets/one_time_key.py new file mode 100644 index 0000000..502201b --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/common/wigets/one_time_key.py @@ -0,0 +1,21 @@ +from typing import Any + +from aiogram_dialog import DialogManager +from aiogram_dialog.widgets.text import Text + + +class OneTimekey(Text): + def __init__(self, key: str) -> None: + super().__init__(when=None) + self.key = key + + async def _render_text( + self, _: dict[str, Any], manager: DialogManager, + ) -> str: + if ( + isinstance(manager.start_data, dict) + and self.key in manager.start_data + ): + del manager.start_data[self.key] + + return "" diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/__init__.py b/src/ttt/presentation/aiogram_dialog/main_dialog/__init__.py index c8df698..88fdd15 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/__init__.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/__init__.py @@ -13,7 +13,19 @@ game_start_window, ) from ttt.presentation.aiogram_dialog.main_dialog.game_window import game_window +from ttt.presentation.aiogram_dialog.main_dialog.incoming_invitation_to_game_window import ( # noqa: E501 + incoming_invitation_to_game_window, +) +from ttt.presentation.aiogram_dialog.main_dialog.incoming_invitations_to_game_window import ( # noqa: E501 + incoming_invitations_to_game_window, +) from ttt.presentation.aiogram_dialog.main_dialog.main_window import main_window +from ttt.presentation.aiogram_dialog.main_dialog.notification_window import ( + notification_window, +) +from ttt.presentation.aiogram_dialog.main_dialog.outcoming_invitations_to_game_window import ( # noqa: E501 + outcoming_invitations_to_game_window, +) from ttt.presentation.aiogram_dialog.main_dialog.profile_window import ( profile_window, ) @@ -28,11 +40,15 @@ main_dialog = Dialog( main_window, game_start_window, + outcoming_invitations_to_game_window, ai_type_to_start_game_window, game_window, + incoming_invitations_to_game_window, + incoming_invitation_to_game_window, profile_window, emoji_window, shop_window, stars_shop_window, emoji_shop_window, + notification_window, ) diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/common.py b/src/ttt/presentation/aiogram_dialog/main_dialog/common.py index 91c30d5..c29c252 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/common.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/common.py @@ -1,4 +1,3 @@ - from aiogram.fsm.state import State, StatesGroup @@ -8,8 +7,12 @@ class MainDialogState(StatesGroup): profile = State() game_mode_to_start_game = State() ai_type_to_start_game = State() + outcoming_invitations_to_game = State() + incoming_invitations_to_game = State() + incoming_invitation_to_game = State() game = State() completed_game = State() shop = State() emoji_shop = State() stars_shop = State() + notification = State() 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 4be3e9d..11c225c 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 @@ -6,7 +6,7 @@ from aiogram_dialog.widgets.kbd import ( 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 @@ -14,7 +14,10 @@ from ttt.application.user.emoji_purchase.buy_emoji import BuyEmoji from ttt.entities.tools.assertion import not_none 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.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 @@ -32,8 +35,10 @@ async def handler( emoji_shop_window = Window( Const("🎭 Введите эмоджи:", when=~F["start_data"]["hint"]), - Hint(Format("{start_data[hint]}")), + hint(key="hint"), SwitchTo(Const("Назад"), id="back", state=MainDialogState.shop), MessageInput(handler, content_types=[ContentType.ANY]), + + OneTimekey("hint"), state=MainDialogState.emoji_shop, ) 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 2c1467f..405b4b6 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,3 @@ - from aiogram.types import CallbackQuery from aiogram_dialog import DialogManager, Window from aiogram_dialog.widgets.kbd import ( @@ -6,18 +5,21 @@ Row, 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 -from ttt.application.game.game.wait_game import WaitGame -from ttt.presentation.aiogram_dialog.common.wigets.hint import Hint +from ttt.application.matchmaking_queue.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 ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState @inject -async def on_game_against_user_clicked( +async def on_matchmaking_clicked( callback: CallbackQuery, _: Button, __: DialogManager, @@ -27,20 +29,29 @@ async def on_game_against_user_clicked( game_start_window = Window( - Const("⚔️ Выберите режим игры", when=~F["start_data"]["hint"]), - Hint(Format("{start_data[hint]}")), + Const("⚔️ Выберите режим", when=~F["start_data"]["hint"]), + hint(key="hint"), Row( Button( - Const("👥 Против человека"), - id="game_against_user", - on_click=on_game_against_user_clicked, + Const("🗡 Подбор игр"), + id="matchmaking", + on_click=on_matchmaking_clicked, ), SwitchTo( - Const("🤖 Против ИИ"), - id="game_against_ai", + Const("🤖 Играть с ИИ"), + id="single_game", state=MainDialogState.ai_type_to_start_game, ), ), - SwitchTo(Const("Назад"), id="back", state=MainDialogState.main), + Row( + SwitchTo( + Const("👤 Пригласить"), + id="outcoming_invitations_to_game", + state=MainDialogState.outcoming_invitations_to_game, + ), + SwitchTo(Const("Назад"), id="back", state=MainDialogState.main), + ), + + OneTimekey("hint"), state=MainDialogState.game_mode_to_start_game, ) 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 6c90924..ce5a98c 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/game_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/game_window.py @@ -27,7 +27,9 @@ from ttt.entities.core.user.win import UserWin from ttt.entities.tools.assertion import not_none 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.texts import ( copy_signed_text, @@ -193,7 +195,12 @@ def of(cls, game: Game, user_id: int) -> "CompletedGameView": False: Const("Ждите хода врага"), }, ), - Hint(Multi(Const(" "), Format("{start_data[hint]}"), when=active_game_f)), + Multi( + Const(" "), + Format("{start_data[hint]}"), + when=active_game_f & F["start_data"]["hint"], + ), + Group( Row(cell_button(1), cell_button(2), cell_button(3)), Row(cell_button(4), cell_button(5), cell_button(6)), @@ -242,5 +249,6 @@ def of(cls, game: Game, user_id: int) -> "CompletedGameView": ), SwitchTo(Const("Назад"), id="back", state=MainDialogState.main), + OneTimekey("hint"), state=MainDialogState.game, ) 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 new file mode 100644 index 0000000..16923bc --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitation_to_game_window.py @@ -0,0 +1,95 @@ +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from aiogram.enums import ParseMode +from aiogram.types import CallbackQuery +from aiogram.utils.formatting import Code, Text +from aiogram_dialog import DialogManager, Window +from aiogram_dialog.widgets.kbd import ( + Button, + Cancel, + Row, +) +from aiogram_dialog.widgets.text import Const +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject +from magic_filter import F + +from ttt.application.invitation_to_game.game.accpet_invitation_to_game import ( + AcceptInvitationToGame, +) +from ttt.application.invitation_to_game.game.reject_invitation_to_game import ( + RejectInvitationToGame, +) +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.main_dialog.common import MainDialogState + + +@dataclass(frozen=True) +class IncomingInvitationToGameView(EncodableToWindowData): + id_hex: str + inviting_user_id: int + + +@inject +async def on_accept_clicked( + callback: CallbackQuery, + _: Button, + manager: DialogManager, + accept_invitation_to_game: FromDishka[AcceptInvitationToGame], +) -> 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) + + +@inject +async def on_reject_clicked( + callback: CallbackQuery, + _: Button, + manager: DialogManager, + reject_invitation_to_game: FromDishka[RejectInvitationToGame], +) -> 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) + + +async def incoming_invitation_to_game_html( # noqa: RUF029 + _: dict[str, Any], + manager: DialogManager, +) -> str: + if not isinstance(manager.start_data, dict): + raise TypeError + + invitation = manager.start_data["main"] + text = Text( + "👤 Приглашение к игре от ", Code(invitation["inviting_user_id"]), + ) + return text.as_html() + + +incoming_invitation_to_game_window = Window( + hint(key="hint"), + FuncText(incoming_invitation_to_game_html, when=~F["start_data"]["hint"]), + Row( + Button(Const("Принять"), id="accept", on_click=on_accept_clicked), + Button(Const("Отклонить"), id="reject", on_click=on_reject_clicked), + when=~F["start_data"]["hint"], + ), + Cancel(Const("Назад")), + + OneTimekey("hint"), + state=MainDialogState.incoming_invitation_to_game, + parse_mode=ParseMode.HTML, +) 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 new file mode 100644 index 0000000..49f0170 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/incoming_invitations_to_game_window.py @@ -0,0 +1,133 @@ +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from aiogram.types import CallbackQuery, User +from aiogram_dialog import DialogManager, StartMode, Window +from aiogram_dialog.widgets.kbd import ( + ScrollingGroup, + Select, + SwitchTo, +) +from aiogram_dialog.widgets.text import Const, Format +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject +from magic_filter import F + +from ttt.application.invitation_to_game.game.view_incoming_invitation_to_game import ( # noqa: E501 + ViewIncomingInvitationToGame, +) +from ttt.application.invitation_to_game.game.view_incoming_invitations_to_game import ( # noqa: E501 + ViewIncomingInvitationsToGame, +) +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 +from ttt.presentation.aiogram_dialog.main_dialog.incoming_invitation_to_game_window import ( # noqa: E501 + IncomingInvitationToGameView, +) +from ttt.presentation.result_buffer import ResultBuffer + + +@dataclass(frozen=True) +class IncomingInvitationToGameData: + id_hex: str + inviting_user_id: int + + +@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 + ) + + +@inject +async def getter( + *, + event_from_user: User, + view_invitations: FromDishka[ViewIncomingInvitationsToGame], + result_buffer: FromDishka[ResultBuffer], + **_: Any, # noqa: ANN401 +) -> dict[str, Any]: + await view_invitations(event_from_user.id) + view = result_buffer(IncomingInvitationsToGameView) + + return view.window_data() + + +@inject +async def on_invitation_selected( + callback_query: CallbackQuery, + _: Select[Any], + manager: DialogManager, + invitation_id_hex: str, + view_invitation_to_game: FromDishka[ViewIncomingInvitationToGame], + result_buffer: FromDishka[ResultBuffer], +) -> None: + invitation_id = UUID(hex=invitation_id_hex) + await view_invitation_to_game(callback_query.from_user.id, invitation_id) + view = result_buffer.result + + if not isinstance(view, IncomingInvitationToGameView | None): + raise TypeError + + if view is None: + await manager.start( + MainDialogState.main, + {"hint": "😭 Предложение отклонено"}, + StartMode.RESET_STACK, + ) + return + + start_data = view.window_data() + await manager.start(MainDialogState.incoming_invitation_to_game, start_data) + + +async def incoming_invitations_to_game_html( # noqa: RUF029 + data: dict[str, Any], + _: DialogManager, +) -> str: + return f"👥 У вас {len(data["main"]["invitations"])} приглашений к игре" + + +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"], + ), + ScrollingGroup( + 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, + ), + width=4, + height=4, + id="y", + when=F["main"]["need_to_paginate"], + ), + + SwitchTo( + Const("Назад"), + id="back", + state=MainDialogState.main, + ), + state=MainDialogState.incoming_invitations_to_game, + getter=getter, +) 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 83a6982..322ff3e 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/main_window.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any +from typing import Any, Literal from aiogram.types import CallbackQuery, User from aiogram_dialog import DialogManager, StartMode, Window @@ -7,27 +7,61 @@ Button, SwitchTo, ) -from aiogram_dialog.widgets.text import Const, Format +from aiogram_dialog.widgets.text import Const, Format, Multi from dishka import FromDishka from dishka.integrations.aiogram_dialog import inject from magic_filter import F from ttt.application.game.game.cancel_game import CancelGame from ttt.application.game.game.view_game import ViewGame +from ttt.application.invitation_to_game.game.view_one_incoming_invitation_to_game import ( # noqa: E501 + ViewOneIncomingInvitationToGame, +) 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.elo.rating import EloRating 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.func_text import FuncText +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.aiogram_dialog.main_dialog.game_window import ( ActiveGameView, ) +from ttt.presentation.aiogram_dialog.main_dialog.incoming_invitation_to_game_window import ( # noqa: E501 + IncomingInvitationToGameView, +) from ttt.presentation.result_buffer import ResultBuffer +from ttt.presentation.texts import rank_progres_text, rank_title + + +type AmoutOfIncomingInvitationsToGame = Literal["no", "one", "many"] + + +@dataclass(frozen=True) +class IncomingInvitationToGameData: + id_hex: str @dataclass(frozen=True) class MainMenuView(EncodableToWindowData): is_user_in_game: bool has_user_emojis: bool + rating: EloRating + stars: Stars + amout_of_incoming_invitations_to_game: AmoutOfIncomingInvitationsToGame + + +async def rank_text( # noqa: RUF029 + data: dict[str, Any], + _: DialogManager, +) -> str: + rating = data["main"]["rating"] + rank = rank_for_rating(rating) + + return f"Вы — {rank_title(rank)} {rank_progres_text(rating)}" @inject @@ -69,10 +103,56 @@ async def on_back_to_game_clicked( await manager.start(MainDialogState.game, data, StartMode.RESET_STACK) +@inject +async def on_incoming_invitation_to_game_clicked( + callback_query: CallbackQuery, + _: Button, + manager: DialogManager, + view_invitation_to_game: FromDishka[ViewOneIncomingInvitationToGame], + result_buffer: FromDishka[ResultBuffer], +) -> None: + await view_invitation_to_game(callback_query.from_user.id) + view = result_buffer.result + + if not isinstance(view, IncomingInvitationToGameView | None): + raise TypeError + + if view is None: + await manager.start( + MainDialogState.main, + {"hint": "😭 Предложение отклонено"}, + StartMode.RESET_STACK, + ) + return + + start_data = view.window_data() + await manager.start(MainDialogState.incoming_invitation_to_game, start_data) + + main_window = Window( - Const("🧭 Меню", when=~F["start_data"]["hint"]), - Hint(Format("{start_data[hint]}")), + FuncText(rank_text), + Format("Звёзд: {main[stars]} 🌟"), + Multi( + Const(" "), + Format("{start_data[hint]}"), + when=F["start_data"]["hint"], + ), + Button( + Const("Предложение к игре"), + id="incoming_invitation_to_game", + when=( + ~F["main"]["is_user_in_game"] + & (F["main"]["amout_of_incoming_invitations_to_game"] == "one") + ), + on_click=on_incoming_invitation_to_game_clicked, + ), + SwitchTo( + Const("Предложения к игре"), + id="incoming_invitations_to_game", + when=F["main"]["amout_of_incoming_invitations_to_game"] == "many", + state=MainDialogState.incoming_invitations_to_game, + ), SwitchTo( Const("Начать игру"), id="start_game", @@ -107,6 +187,8 @@ async def on_back_to_game_clicked( id="shop", state=MainDialogState.shop, ), + + OneTimekey("hint"), state=MainDialogState.main, getter=main_getter, ) diff --git a/src/ttt/presentation/aiogram_dialog/main_dialog/notification_window.py b/src/ttt/presentation/aiogram_dialog/main_dialog/notification_window.py new file mode 100644 index 0000000..e5621ba --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/notification_window.py @@ -0,0 +1,17 @@ +from aiogram.enums import ParseMode +from aiogram_dialog import Window +from aiogram_dialog.widgets.kbd import ( + Cancel, +) +from aiogram_dialog.widgets.text import Const + +from ttt.presentation.aiogram_dialog.common.wigets.hint import hint +from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState + + +notification_window = Window( + hint(key="hint"), + Cancel(Const("OK")), + state=MainDialogState.notification, + parse_mode=ParseMode.HTML, +) 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 new file mode 100644 index 0000000..1e2edf4 --- /dev/null +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/outcoming_invitations_to_game_window.py @@ -0,0 +1,141 @@ +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +from aiogram.enums import ContentType +from aiogram.types import CallbackQuery, Message, User +from aiogram_dialog import DialogManager, ShowMode, StartMode, Window +from aiogram_dialog.widgets.input import MessageInput +from aiogram_dialog.widgets.kbd import ( + ScrollingGroup, + Select, + SwitchTo, +) +from aiogram_dialog.widgets.text import Const, Format, Multi +from alembic.util import not_none +from dishka import FromDishka +from dishka.integrations.aiogram_dialog import inject +from magic_filter import F + +from ttt.application.invitation_to_game.game.cancel_invitation_to_game import ( + CancelInvitationToGame, +) +from ttt.application.invitation_to_game.game.invite_to_game import InviteToGame +from ttt.application.invitation_to_game.game.view_outcoming_invitations_to_game import ( # noqa: E501 + ViewOutcomingInvitationsToGame, +) +from ttt.presentation.aiogram_dialog.common.data import EncodableToWindowData +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 OutcomingInvitationToGameData: + id_hex: str + invited_user_id: int + + +@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 + ) + + +@inject +async def getter( + *, + event_from_user: User, + view_invitations: FromDishka[ViewOutcomingInvitationsToGame], + result_buffer: FromDishka[ResultBuffer], + **_: Any, # noqa: ANN401 +) -> dict[str, Any]: + await view_invitations(event_from_user.id) + view = result_buffer(OutcomingInvitationsToGameView) + + return view.window_data() + + +@inject +async def on_invitation_selected( + callback_query: CallbackQuery, + _: Select[Any], + __: DialogManager, + invitation_id_hex: str, + cancel_invitation_to_game: FromDishka[CancelInvitationToGame], +) -> None: + invitation_id = UUID(hex=invitation_id_hex) + await cancel_invitation_to_game(callback_query.from_user.id, invitation_id) + + +@inject +async def input_user_id( + message: Message, + _: MessageInput, + manager: DialogManager, + invite_to_game: FromDishka[InviteToGame], +) -> 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 invite_to_game(not_none(message.from_user).id, invited_user_id) + + +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( + 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, + ), + width=4, + height=4, + id="y", + when=F["main"]["need_to_paginate"], + ), + + SwitchTo( + Const("Назад"), + id="back", + state=MainDialogState.game_mode_to_start_game, + ), + OneTimekey("hint"), + state=MainDialogState.outcoming_invitations_to_game, + getter=getter, +) 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 8b5b4c9..d8d50f6 100644 --- a/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py +++ b/src/ttt/presentation/aiogram_dialog/main_dialog/profile_window.py @@ -11,10 +11,12 @@ 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.presentation.aiogram_dialog.common.data import EncodableToWindowData from ttt.presentation.aiogram_dialog.main_dialog.common import MainDialogState from ttt.presentation.result_buffer import ResultBuffer from ttt.presentation.texts import ( + rank_title, short_float_text, ) @@ -26,6 +28,7 @@ class UserProfileView(EncodableToWindowData): number_of_defeats: int account_stars: int rating_text: str + rank_text: str @classmethod def of( @@ -42,6 +45,7 @@ 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)), ) @@ -65,6 +69,7 @@ async def profile_getter( Const(" "), Format("🌟 Звёзд: {main[account_stars]}"), Format("🏅 Рейтинг: {main[rating_text]}"), + Format("⚔️ Ранг: {main[rank_text]}"), Format("🏆 Побед: {main[number_of_wins]}"), Format("💀 Поражений: {main[number_of_defeats]}"), Format("🕊️ Ничьих: {main[number_of_draws]}"), 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 eaff0be..2a39e89 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 @@ -7,7 +7,7 @@ Row, 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 @@ -15,7 +15,10 @@ from ttt.application.user.stars_purchase.start_stars_purchase import ( StartStarsPurchase, ) -from ttt.presentation.aiogram_dialog.common.wigets.hint import Hint +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 @@ -84,9 +87,10 @@ async def stars_shop_getter( # noqa: RUF029 when=~F["has_start_hint"], ), - Hint(Format("{start_data[hint]}")), + hint(key="hint"), SwitchTo(Const("Назад"), id="back", state=MainDialogState.shop), + OneTimekey("hint"), state=MainDialogState.stars_shop, getter=stars_shop_getter, ) diff --git a/src/ttt/presentation/tasks/__init__.py b/src/ttt/presentation/tasks/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..e0de920 --- /dev/null +++ b/src/ttt/presentation/tasks/auto_cancel_invitation_to_game_task.py @@ -0,0 +1,17 @@ +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/texts.py b/src/ttt/presentation/texts.py index 2f1ec25..fc4d0c6 100644 --- a/src/ttt/presentation/texts.py +++ b/src/ttt/presentation/texts.py @@ -1,3 +1,5 @@ +from ttt.entities.core.user.rank import Rank, rank_for_rating, rank_with_tier +from ttt.entities.elo.rating import EloRating def short_float_text(float_: float) -> str: @@ -6,3 +8,50 @@ def short_float_text(float_: float) -> str: 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: + case -1: + return "🪨" + case 0: + return "🌱" + case 1: + return "🗡" + case 2: + return "👾" + case 3: + return "👹" + case 4: + return "🪬" + + +def rank_name(rank: Rank) -> str: + match rank.tier: + case -1: + return "Камень" + case 0: + return "Росток" + case 1: + return "Клинок" + case 2: + return "Монстр" + case 3: + return "Демон" + case 4: + return "Око" + + +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 + return f"({short_float_text(raiting)})" + + next_rank = rank_with_tier(rank.tier + 1) # type: ignore[arg-type] + + return f"({short_float_text(raiting)} / {next_rank.min_rating})" diff --git a/tests/test_ttt/test_entities/test_core/conftest.py b/tests/test_ttt/test_entities/test_core/conftest.py index b96d57e..1e2f831 100644 --- a/tests/test_ttt/test_entities/test_core/conftest.py +++ b/tests/test_ttt/test_entities/test_core/conftest.py @@ -34,6 +34,7 @@ def user1() -> User: number_of_draws=0, number_of_defeats=0, game_location=UserGameLocation(1, UUID(int=0)), + admin_right=None, ) @@ -51,6 +52,7 @@ def user2() -> User: 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 b04a082..869b689 100644 --- a/tests/test_ttt/test_entities/test_core/test_game.py +++ b/tests/test_ttt/test_entities/test_core/test_game.py @@ -443,6 +443,7 @@ def test_winning_game( # noqa: PLR0913, PLR0917 number_of_draws=0, number_of_defeats=0, game_location=None, + admin_right=None, ) if object_ == "user2": @@ -460,6 +461,7 @@ def test_winning_game( # noqa: PLR0913, PLR0917 number_of_draws=0, number_of_defeats=1, game_location=None, + admin_right=None, ) if object_ == "extra_move": @@ -544,6 +546,7 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 number_of_draws=1, number_of_defeats=0, game_location=None, + admin_right=None, ) if object_ == "user2": @@ -561,6 +564,7 @@ def test_drawn_game( # noqa: PLR0913, PLR0917 number_of_draws=1, number_of_defeats=0, game_location=None, + admin_right=None, ) if object_ == "extra_move": @@ -645,6 +649,7 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 number_of_draws=0, number_of_defeats=0, game_location=None, + admin_right=None, ) if object_ == "user2": @@ -662,6 +667,7 @@ def test_winning_game_with_filled_board( # noqa: PLR0913, PLR0917 number_of_draws=0, number_of_defeats=1, game_location=None, + admin_right=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 613040f..b452789 100644 --- a/tests/test_ttt/test_entities/test_core/test_user.py +++ b/tests/test_ttt/test_entities/test_core/test_user.py @@ -22,6 +22,7 @@ def test_create_user(tracking: Tracking, object_: str) -> None: number_of_draws=0, number_of_defeats=0, game_location=None, + admin_right=None, ) if object_ == "tracking": diff --git a/uv.lock b/uv.lock index c6c7385..4d0f8e6 100644 --- a/uv.lock +++ b/uv.lock @@ -1030,7 +1030,7 @@ wheels = [ [[package]] name = "ttt" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "aiogram" },