diff --git a/src/EzggLanManager.py b/src/EzggLanManager.py index 11729f9..865d8d0 100644 --- a/src/EzggLanManager.py +++ b/src/EzggLanManager.py @@ -1,4 +1,5 @@ import logging +from uuid import uuid4 import sys @@ -10,6 +11,7 @@ from from_root import from_root from src.ezgg_lan_manager import pages, init_services, LocalDataService, RefreshService from src.ezgg_lan_manager.helpers.LoggedInGuard import logged_in_guard, not_logged_in_guard, team_guard from src.ezgg_lan_manager.services.LocalDataService import LocalData +from src.ezgg_lan_manager.types.UserSession import UserSession logger = logging.getLogger("EzggLanManager") @@ -34,13 +36,13 @@ if __name__ == "__main__": async def on_session_start(session: Session) -> None: # Use this line to fake being any user without having to log in - # session.attach(UserSession(id=uuid4(), user_id=30, is_team_member=True)) + session.attach(UserSession(id=uuid4(), user_id=30, is_team_member=True)) await session.set_title(lan_info.name) session.attach(RefreshService()) - if session[LocalData].stored_session_token: - user_session = session[LocalDataService].verify_token(session[LocalData].stored_session_token) - if user_session is not None: - session.attach(user_session) + # if session[LocalData].stored_session_token: + # user_session = session[LocalDataService].verify_token(session[LocalData].stored_session_token) + # if user_session is not None: + # session.attach(user_session) async def on_app_start(a: App) -> None: init_result = await a.default_attachments[4].init_db_pool() @@ -176,6 +178,11 @@ if __name__ == "__main__": url_segment="tournament", build=pages.TournamentDetailsPage, ), + ComponentPage( + name="TournamentTreePage", + url_segment="tournament-tree", + build=pages.TournamentTreePage, + ), ComponentPage( name="TournamentRulesPage", url_segment="tournament-rules", diff --git a/src/ezgg_lan_manager/pages/TournamentDetailsPage.py b/src/ezgg_lan_manager/pages/TournamentDetailsPage.py index b2c3f9f..1cabee9 100644 --- a/src/ezgg_lan_manager/pages/TournamentDetailsPage.py +++ b/src/ezgg_lan_manager/pages/TournamentDetailsPage.py @@ -7,7 +7,7 @@ from from_root import from_root from rio import Column, Component, event, TextStyle, Text, Row, Image, Spacer, ProgressCircle, Button, Checkbox, ThemeContextSwitcher, Link, Revealer, PointerEventListener, \ PointerEvent, Rectangle, Color, Popup, Dropdown -from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService, TicketingService, TeamService +from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService, TicketingService, TeamService, RefreshService from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ezgg_lan_manager.components.TournamentDetailsInfoRow import TournamentDetailsInfoRow from src.ezgg_lan_manager.types.DateUtil import weekday_to_display_text @@ -44,7 +44,7 @@ class TournamentDetailsPage(Component): tournament_id = None if tournament_id is not None: self.tournament = await self.session[TournamentService].get_tournament_by_id(tournament_id) - if self.tournament is not None: + if isinstance(self.tournament, Tournament): await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.tournament.name}") if self.tournament.participant_type == ParticipantType.PLAYER: self.current_tournament_user_or_team_list = await self.session[TournamentService].get_users_from_participant_list(self.tournament.participants) @@ -61,6 +61,8 @@ class TournamentDetailsPage(Component): self.user = None self.user_teams = [] + self.session[RefreshService].subscribe(self.on_populate) + self.loading_done() @staticmethod @@ -68,13 +70,14 @@ class TournamentDetailsPage(Component): await sleep(0.8) # https://medium.com/design-bootcamp/ux-psychology-of-artificial-waiting-enhancing-user-experiences-through-deliberate-delays-d7822faf3930 async def update(self) -> None: - self.tournament = await self.session[TournamentService].get_tournament_by_id(self.tournament.id) - if self.tournament is None: - return - if self.tournament.participant_type == ParticipantType.PLAYER: - self.current_tournament_user_or_team_list = await self.session[TournamentService].get_users_from_participant_list(self.tournament.participants) - elif self.tournament.participant_type == ParticipantType.TEAM: - self.current_tournament_user_or_team_list = await self.session[TournamentService].get_teams_from_participant_list(self.tournament.participants) + if isinstance(self.tournament, Tournament): + self.tournament = await self.session[TournamentService].get_tournament_by_id(self.tournament.id) + if self.tournament is None or isinstance(self.tournament, str): + return + if self.tournament.participant_type == ParticipantType.PLAYER: + self.current_tournament_user_or_team_list = await self.session[TournamentService].get_users_from_participant_list(self.tournament.participants) + elif self.tournament.participant_type == ParticipantType.TEAM: + self.current_tournament_user_or_team_list = await self.session[TournamentService].get_teams_from_participant_list(self.tournament.participants) def open_close_participant_revealer(self, _: PointerEvent) -> None: self.participant_revealer_open = not self.participant_revealer_open @@ -88,6 +91,9 @@ class TournamentDetailsPage(Component): if user_ticket is None: self.is_success = False self.message = "Turnieranmeldung nur mit Ticket" + elif not isinstance(self.tournament, Tournament): + self.is_success = False + self.message = "Fehler bei der Anmeldung" else: # Register single player if self.tournament.participant_type == ParticipantType.PLAYER: @@ -125,12 +131,15 @@ class TournamentDetailsPage(Component): await self.on_team_register_canceled() return try: - await self.session[TournamentService].register_team_for_tournament(self.team_selected_for_register.id, self.tournament.id) - await self.artificial_delay() - self.is_success = True - self.message = f"Erfolgreich angemeldet!" - self.team_dialog_open = False - self.team_selected_for_register = None + if isinstance(self.tournament, Tournament): + await self.session[TournamentService].register_team_for_tournament(self.team_selected_for_register.id, self.tournament.id) + await self.artificial_delay() + self.is_success = True + self.message = f"Erfolgreich angemeldet!" + self.team_dialog_open = False + self.team_selected_for_register = None + else: + raise ValueError("Turnier nicht gefunden") except Exception as e: logger.error(e) self.message = f"Fehler: {e}" @@ -149,9 +158,9 @@ class TournamentDetailsPage(Component): return try: - if self.tournament.participant_type == ParticipantType.PLAYER: + if isinstance(self.tournament, Tournament) and self.tournament.participant_type == ParticipantType.PLAYER: await self.session[TournamentService].unregister_user_from_tournament(self.user.user_id, self.tournament.id) - elif self.tournament.participant_type == ParticipantType.TEAM: + elif isinstance(self.tournament, Tournament) and self.tournament.participant_type == ParticipantType.TEAM: if team.members[self.user] == TeamStatus.OFFICER or team.members[self.user] == TeamStatus.LEADER: await self.session[TournamentService].unregister_team_from_tournament(team.id, self.tournament.id) else: @@ -166,7 +175,8 @@ class TournamentDetailsPage(Component): self.loading = False async def tree_button_clicked(self) -> None: - pass # ToDo: Implement tournament tree view + if isinstance(self.tournament, Tournament): + self.session.navigate_to(f"tournament-tree?id={self.tournament.id}") def loading_done(self) -> None: if self.tournament is None: @@ -349,7 +359,7 @@ class TournamentDetailsPage(Component): button ) - if self.tournament and self.tournament.participant_type == ParticipantType.TEAM: + if isinstance(self.tournament, Tournament) and self.tournament.participant_type == ParticipantType.TEAM: content = Popup( anchor=content, content=Rectangle( diff --git a/src/ezgg_lan_manager/pages/TournamentTreePage.py b/src/ezgg_lan_manager/pages/TournamentTreePage.py new file mode 100644 index 0000000..7a00de6 --- /dev/null +++ b/src/ezgg_lan_manager/pages/TournamentTreePage.py @@ -0,0 +1,269 @@ +import json +import logging +from typing import Optional, Union + +from from_root import from_root +from rio import Column, Component, event, TextStyle, Text, Row, Spacer, ProgressCircle, Rectangle, Stack + +from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService, TeamService, RefreshService, SeatingService +from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ezgg_lan_manager.types.Team import Team, TeamStatus +from src.ezgg_lan_manager.types.Tournament import Tournament +from src.ezgg_lan_manager.types.TournamentBase import ParticipantType, TournamentFormat +from src.ezgg_lan_manager.types.User import User +from src.ezgg_lan_manager.types.UserSession import UserSession + +logger = logging.getLogger(__name__.split(".")[-1]) + +class MatchInfo(Component): + opponent_1: str = "" + opponent_2: str = "" + opponent_1_seat: str = "" + opponent_2_seat: str = "" + winner: str = "" + + def build(self) -> Component: + return Rectangle( + content=Column( + Stack( + Row( + Row( + Text( + text=self.opponent_1, + style=TextStyle(fill=self.session.theme.success_color if self.winner == self.opponent_1 else self.session.theme.background_color), + justify="left", + margin_right=0.6, + font_size=0.9 + ), + Text( + text=f"({self.opponent_1_seat})", + style=TextStyle(fill=self.session.theme.background_color), + justify="left", + font_size=0.9 + ) + ), + Spacer(), + Row( + Text( + text=self.opponent_2, + style=TextStyle(fill=self.session.theme.success_color if self.winner == self.opponent_2 else self.session.theme.background_color), + justify="right", + margin_right=0.6, + font_size=0.9 + ), + Text( + text=f"({self.opponent_2_seat})", + style=TextStyle(fill=self.session.theme.background_color), + justify="right", + font_size=0.9 + ) + ), + margin=0.3 + ), + Row( + Text( + text=f"vs.", + style=TextStyle(fill=self.session.theme.background_color), + justify="center" + ), + margin=0.3 + ) + ) + ), + margin=1, + stroke_width=0.2, + stroke_color=self.session.theme.background_color, + fill=self.session.theme.hud_color, + ) + +class TournamentTreePage(Component): + tournament: Optional[Union[Tournament, str]] = None + user: Optional[User] = None + teams: list[Team] = [] + id_to_username_map: dict[int, str] = {} + id_to_seat_map: dict[int, str] = {} + is_fully_loaded: bool = False + + @event.on_populate + async def on_populate(self) -> None: + try: + tournament_id = int(self.session.active_page_url.query_string.split("id=")[-1]) + except (ValueError, AttributeError, TypeError): + tournament_id = None + if tournament_id is not None: + self.tournament = await self.session[TournamentService].get_tournament_by_id(tournament_id) + if isinstance(self.tournament, Tournament): + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.tournament.name}") + else: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turniere") + + try: + user_id = self.session[UserSession].user_id + self.user = await self.session[UserService].get_user(user_id) + except KeyError: + self.user = None + + self.teams = await self.session[TeamService].get_all_teams() + + all_users = await self.session[UserService].get_all_users() + id_to_username_map = {} + id_to_seat_map = {} + for user in all_users: + id_to_username_map[user.user_id] = user.user_name + seat = await self.session[SeatingService].get_user_seat(user.user_id) + if seat is not None: + id_to_seat_map[user.user_id] = seat.seat_id + + self.id_to_username_map = id_to_username_map + self.id_to_seat_map = id_to_seat_map + self.session[RefreshService].subscribe(self.on_populate) + self.is_fully_loaded = True + + def _get_seat_for_team(self, team: Team) -> str: + # Retrieves seat id for leader of a team + leader = list(team.members.keys())[0] + for member, rank in team.members.items(): + if rank == TeamStatus.LEADER: + leader = member + break + + return self.id_to_seat_map[leader.user_id] + + + def build(self) -> Component: + if self.tournament is None or not self.is_fully_loaded: + return Column( + MainViewContentBox( + Column( + Spacer(min_height=1), + Column( + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=0, + margin_bottom=0 + ), + min_height=10 + ), + Spacer(min_height=1) + ) + ), + align_y=0 + ) + elif isinstance(self.tournament, str): + content = Row( + Text( + text=self.tournament, + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ) + ) + else: + try: + file_name = self.tournament.name.replace(" ", "_") + ".json" + games_per_matchup = 1 + if self.tournament.format != TournamentFormat.FFA: + games_per_matchup = int(self.tournament.format.name[-1]) + logger.info(f"Trying to read tournament data from {file_name}") + with open(from_root("tournament_data", file_name), "r") as f: + json_data = json.load(f) + + last_valid_round = None + round_num = 0 + for round_ in json_data["rounds"]: + if all( + match["opponent_1_id"] is not None and match["opponent_2_id"] is not None + for match in round_ + ): + last_valid_round = round_ + round_num += 1 + + if last_valid_round is None: + raise ValueError + + match_infos = [] + if self.tournament.participant_type == ParticipantType.PLAYER: + match_infos = [MatchInfo( + opponent_1=self.id_to_username_map.get(match["opponent_1_id"], ""), + opponent_2=self.id_to_username_map.get(match["opponent_2_id"], ""), + winner=self.id_to_username_map.get(match["winner"], ""), + opponent_1_seat=self.id_to_seat_map.get(match["opponent_1_id"], ""), + opponent_2_seat=self.id_to_seat_map.get(match["opponent_2_id"], ""), + ) for match in last_valid_round] + elif self.tournament.participant_type == ParticipantType.TEAM: + for match in last_valid_round: + team_1: Optional[Team] = next(filter(lambda t: t.id == match["opponent_1_id"], self.teams), None) + team_2: Optional[Team] = next(filter(lambda t: t.id == match["opponent_2_id"], self.teams), None) + winner: Union[str, Team] = next(filter(lambda t: t.id == match["winner"], self.teams), "") + if team_1 is not None and team_2 is not None: + match_infos.append( + MatchInfo( + opponent_1=team_1.name, + opponent_2=team_2.name, + winner=winner if isinstance(winner, str) else winner.name, + opponent_1_seat=self._get_seat_for_team(team_1), + opponent_2_seat=self._get_seat_for_team(team_2), + ) + ) + else: + raise ValueError("Unknown participant type") + + + + content = Column( + Text( + text=f"{self.tournament.name}", + style=TextStyle(fill=self.session.theme.background_color), + justify="center", + font_size=1.2 + ), + Text( + text="Finale" if len(json_data["rounds"]) == round_num else f"Runde {round_num}", + style=TextStyle(fill=self.session.theme.background_color), + justify="center", + font_size=0.9, + margin_bottom=1 + ), + Text( + text=f"Spiele pro Matchup: {games_per_matchup}", + style=TextStyle(fill=self.session.theme.background_color), + justify="center", + font_size=0.8 + ), + Text( + text=f"Melde als Verlierer deinen Matchausgang\nim Discord oder an der Orga-Ecke", + style=TextStyle(fill=self.session.theme.background_color), + justify="center", + font_size=0.8 + ), + *match_infos + ) + except (FileNotFoundError, ValueError, AttributeError): + content = Column( + Text( + text=f"Der Turnierbaum für dieses Turnier steht leider nicht zur Verfügung.\n\nBitte melde sich beim Orga-Team.", + style=TextStyle(fill=self.session.theme.background_color), + margin_top=1, + margin_bottom=1, + align_x=0.5, + overflow="wrap", + min_width=30, + justify="center" + ) + ) + + return Column( + MainViewContentBox( + Column( + Spacer(min_height=1), + content, + Spacer(min_height=1) + ) + ), + align_y=0 + ) \ No newline at end of file diff --git a/src/ezgg_lan_manager/pages/__init__.py b/src/ezgg_lan_manager/pages/__init__.py index e900b6f..aee08b0 100644 --- a/src/ezgg_lan_manager/pages/__init__.py +++ b/src/ezgg_lan_manager/pages/__init__.py @@ -25,3 +25,4 @@ from .TournamentRulesPage import TournamentRulesPage from .ConwayPage import ConwayPage from .TeamsPage import TeamsPage from .AdminNavigationPage import AdminNavigationPage +from .TournamentTreePage import TournamentTreePage diff --git a/src/ezgg_lan_manager/services/DatabaseService.py b/src/ezgg_lan_manager/services/DatabaseService.py index 90fdb3b..bf39563 100644 --- a/src/ezgg_lan_manager/services/DatabaseService.py +++ b/src/ezgg_lan_manager/services/DatabaseService.py @@ -1,6 +1,6 @@ import logging -from datetime import date, datetime +from datetime import date, datetime, UTC from typing import Optional from decimal import Decimal @@ -16,7 +16,7 @@ from src.ezgg_lan_manager.types.Seat import Seat from src.ezgg_lan_manager.types.Team import TeamStatus, Team from src.ezgg_lan_manager.types.Ticket import Ticket from src.ezgg_lan_manager.types.Tournament import Tournament -from src.ezgg_lan_manager.types.TournamentBase import GameTitle, TournamentFormat, TournamentStatus, ParticipantType +from src.ezgg_lan_manager.types.TournamentBase import GameTitle, TournamentFormat, TournamentStatus, ParticipantType, MatchStatus from src.ezgg_lan_manager.types.Transaction import Transaction from src.ezgg_lan_manager.types.User import User @@ -1185,3 +1185,18 @@ class DatabaseService: if not pool_init_result: raise NoDatabaseConnectionError return await self.remove_user_from_team(team, user) + + async def change_tournament_status(self, tournament_id: int, status: TournamentStatus) -> None: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "UPDATE tournaments SET status = %s WHERE (id = %s)", + (status.name, tournament_id) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.change_tournament_status(tournament_id, status) diff --git a/src/ezgg_lan_manager/services/TournamentService.py b/src/ezgg_lan_manager/services/TournamentService.py index 393d7e3..95dd58e 100644 --- a/src/ezgg_lan_manager/services/TournamentService.py +++ b/src/ezgg_lan_manager/services/TournamentService.py @@ -90,12 +90,12 @@ class TournamentService: tournament = await self.get_tournament_by_id(tournament_id) if tournament: tournament.start() - # ToDo: Write matches/round to database + await self._db_service.change_tournament_status(tournament_id, tournament.status) self._cache_dirty = True async def cancel_tournament(self, tournament_id: int): tournament = await self.get_tournament_by_id(tournament_id) if tournament: tournament.cancel() - # ToDo: Update to database + await self._db_service.change_tournament_status(tournament_id, tournament.status) self._cache_dirty = True diff --git a/src/ezgg_lan_manager/types/Match.py b/src/ezgg_lan_manager/types/Match.py index da3a493..c3c220d 100644 --- a/src/ezgg_lan_manager/types/Match.py +++ b/src/ezgg_lan_manager/types/Match.py @@ -49,6 +49,14 @@ class Match: games.append(Game(game_id, self._match_id, game_number, None, None, False)) return tuple(games) + @property + def round_number(self) -> int: + return self._round_number + + @property + def best_of(self) -> int: + return self._best_of + @property def status(self) -> MatchStatus: if self._status == MatchStatus.COMPLETED: diff --git a/src/ezgg_lan_manager/types/Tournament.py b/src/ezgg_lan_manager/types/Tournament.py index e905135..0581396 100644 --- a/src/ezgg_lan_manager/types/Tournament.py +++ b/src/ezgg_lan_manager/types/Tournament.py @@ -1,3 +1,4 @@ +import logging import uuid from datetime import datetime from typing import Optional @@ -7,6 +8,7 @@ from src.ezgg_lan_manager.types.Match import Match, FFAMatch from src.ezgg_lan_manager.types.Participant import Participant from src.ezgg_lan_manager.types.TournamentBase import GameTitle, TournamentFormat, TournamentStatus, TournamentError, Bracket, MatchStatus, ParticipantType +logger = logging.getLogger(__name__.split(".")[-1]) class Tournament: def __init__(self, @@ -353,6 +355,8 @@ class Tournament: raise TournamentError(f"Unknown bracket type: {bracket_type}") self._status = TournamentStatus.ONGOING + logger.info(f"New tournament status for {self._name}: {self._status}") + print(self._matches, self._rounds) for match in self._matches: match.check_completion() diff --git a/tournament_data/.gitignore b/tournament_data/.gitignore new file mode 100644 index 0000000..94a2dd1 --- /dev/null +++ b/tournament_data/.gitignore @@ -0,0 +1 @@ +*.json \ No newline at end of file diff --git a/tournament_data/README.md b/tournament_data/README.md new file mode 100644 index 0000000..35f861f --- /dev/null +++ b/tournament_data/README.md @@ -0,0 +1,43 @@ +# Tournament data + +This directory contains JSON files for tournament trees. + +This is a temporary solution until the automatic tournament tree generation is completed. + +# Structure + +## Naming + +Tournament name with `_` as separators and `.json` suffix. + +## JSON structure + +```json +{ + "rounds": [ + [ + { + "opponent_1_id": 1, + "opponent_2_id": 2, + "winner": 1 + }, + { + "opponent_1_id": 3, + "opponent_2_id": 4, + "winner": null + } + ], + [ + { + "opponent_1_id": 1, + "opponent_2_id": null, + "winner": null + } + ] + ] +} +``` + +## ToDo + +- Make start button in UI generate initial `.json` file for started tournament