From fffb607b1673db1471290642371bb1c80a685bb1 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 2 Feb 2026 22:01:38 +0100 Subject: [PATCH] end of day commit --- sql/tournament_patch.sql | 144 ++++++++++++++++++ .../pages/TournamentDetailsPage.py | 18 ++- .../services/DatabaseService.py | 144 ++++++++++++++++++ .../services/TournamentService.py | 45 ++---- src/ezgg_lan_manager/types/Participant.py | 7 +- testing/unittests/TournamentDomainTests.py | 6 +- 6 files changed, 323 insertions(+), 41 deletions(-) create mode 100644 sql/tournament_patch.sql diff --git a/sql/tournament_patch.sql b/sql/tournament_patch.sql new file mode 100644 index 0000000..1d56421 --- /dev/null +++ b/sql/tournament_patch.sql @@ -0,0 +1,144 @@ +-- Apply this patch after using create_database.sql to extend the schema to support tournaments from version 0.2.0 +-- WARNING: Executing this on a post 0.2.0 database will delete all data related to tournaments !!! + +DROP TABLE IF EXISTS `game_titles`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `game_titles` ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + web_link VARCHAR(512) NOT NULL, + image_name VARCHAR(255) NOT NULL, + UNIQUE KEY uq_game_title_name (name) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + + +DROP TABLE IF EXISTS `tournaments`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tournaments` ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + game_title_id INT NOT NULL, + format VARCHAR(20) NOT NULL, -- SE_BO1, DE_BO3, ... + start_time DATETIME NOT NULL, + status VARCHAR(20) NOT NULL, -- OPEN, CLOSED, ONGOING, ... + max_participants INT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_tournament_game + FOREIGN KEY (game_title_id) + REFERENCES game_titles(id) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +CREATE INDEX idx_tournaments_game_title + ON tournaments(game_title_id); + +DROP TABLE IF EXISTS `tournament_participants`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tournament_participants` ( + id INT AUTO_INCREMENT PRIMARY KEY, + tournament_id INT NOT NULL, + user_id INT NOT NULL, + participant_type VARCHAR(10) NOT NULL DEFAULT 'PLAYER', + seed INT NULL, + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_tournament_user (tournament_id, user_id), + + CONSTRAINT fk_tp_tournament + FOREIGN KEY (tournament_id) + REFERENCES tournaments(id) + ON DELETE CASCADE, + + CONSTRAINT fk_tp_user + FOREIGN KEY (user_id) + REFERENCES users(user_id) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +CREATE INDEX idx_tp_tournament + ON tournament_participants(tournament_id); +CREATE INDEX idx_tp_user + ON tournament_participants(user_id); + +DROP TABLE IF EXISTS `tournament_rounds`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tournament_rounds` ( + id INT AUTO_INCREMENT PRIMARY KEY, + tournament_id INT NOT NULL, + bracket VARCHAR(10) NOT NULL, -- UPPER, LOWER, FINAL + round_index INT NOT NULL, + + UNIQUE KEY uq_round (tournament_id, bracket, round_index), + + CONSTRAINT fk_round_tournament + FOREIGN KEY (tournament_id) + REFERENCES tournaments(id) + ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +CREATE INDEX idx_rounds_tournament + ON tournament_rounds(tournament_id); + +DROP TABLE IF EXISTS `matches`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `matches` ( + id INT AUTO_INCREMENT PRIMARY KEY, + tournament_id INT NOT NULL, + round_id INT NOT NULL, + match_index INT NOT NULL, + status VARCHAR(15) NOT NULL, -- WAITING, PENDING, COMPLETED, ... + best_of INT NOT NULL, -- 1, 3, 5 + scheduled_time DATETIME NULL, + completed_at DATETIME NULL, + + UNIQUE KEY uq_match (round_id, match_index), + + CONSTRAINT fk_match_tournament + FOREIGN KEY (tournament_id) + REFERENCES tournaments(id) + ON DELETE CASCADE, + + CONSTRAINT fk_match_round + FOREIGN KEY (round_id) + REFERENCES tournament_rounds(id) + ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +CREATE INDEX idx_matches_tournament + ON matches(tournament_id); + +CREATE INDEX idx_matches_round + ON matches(round_id); + +DROP TABLE IF EXISTS `match_participants`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `match_participants` ( + match_id INT NOT NULL, + participant_id INT NOT NULL, + score INT NULL, + is_winner TINYINT(1) NULL, + + PRIMARY KEY (match_id, participant_id), + + CONSTRAINT fk_mp_match + FOREIGN KEY (match_id) + REFERENCES matches(id) + ON DELETE CASCADE, + + CONSTRAINT fk_mp_participant + FOREIGN KEY (participant_id) + REFERENCES tournament_participants(id) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; diff --git a/src/ezgg_lan_manager/pages/TournamentDetailsPage.py b/src/ezgg_lan_manager/pages/TournamentDetailsPage.py index 04314ff..c4e9dbf 100644 --- a/src/ezgg_lan_manager/pages/TournamentDetailsPage.py +++ b/src/ezgg_lan_manager/pages/TournamentDetailsPage.py @@ -55,6 +55,9 @@ class TournamentDetailsPage(Component): self.message = f"Erfolgreich abgemeldet!" # ToDo: Hook into Tournament Service self.loading = False + async def tree_button_clicked(self) -> None: + pass # ToDo: Implement tournament tree view + def loading_done(self) -> None: if self.tournament is None: self.tournament = "Turnier konnte nicht gefunden werden" @@ -86,13 +89,23 @@ class TournamentDetailsPage(Component): ) else: tournament_status_color = self.session.theme.background_color + tree_button = Spacer(grow_x=False, grow_y=False) if self.tournament.status == TournamentStatus.OPEN: tournament_status_color = self.session.theme.success_color elif self.tournament.status == TournamentStatus.CLOSED: tournament_status_color = self.session.theme.danger_color - elif self.tournament.status == TournamentStatus.ONGOING: + elif self.tournament.status == TournamentStatus.ONGOING or self.tournament.status == TournamentStatus.COMPLETED: tournament_status_color = self.session.theme.warning_color - + tree_button = Button( + content="Turnierbaum anzeigen", + shape="rectangle", + style="minor", + color="hud", + margin_left=4, + margin_right=4, + margin_top=1, + on_press=self.tree_button_clicked + ) # ToDo: Integrate Teams logic ids_of_participants = [p.id for p in self.tournament.participants] @@ -163,6 +176,7 @@ class TournamentDetailsPage(Component): f"{len(self.tournament.participants)} / {self.tournament.max_participants}", self.session.theme.danger_color if self.tournament.is_full else self.session.theme.background_color ), + tree_button, Row( Text( text="Info", diff --git a/src/ezgg_lan_manager/services/DatabaseService.py b/src/ezgg_lan_manager/services/DatabaseService.py index 351f58b..d7e5d3b 100644 --- a/src/ezgg_lan_manager/services/DatabaseService.py +++ b/src/ezgg_lan_manager/services/DatabaseService.py @@ -1,6 +1,7 @@ import logging from datetime import date, datetime +from pprint import pprint from typing import Optional from decimal import Decimal @@ -11,8 +12,11 @@ from src.ezgg_lan_manager.types.CateringMenuItem import CateringMenuItem, Cateri from src.ezgg_lan_manager.types.CateringOrder import CateringMenuItemsWithAmount, CateringOrderStatus from src.ezgg_lan_manager.types.ConfigurationTypes import DatabaseConfiguration from src.ezgg_lan_manager.types.News import News +from src.ezgg_lan_manager.types.Participant import Participant from src.ezgg_lan_manager.types.Seat import Seat 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.Transaction import Transaction from src.ezgg_lan_manager.types.User import User @@ -81,6 +85,52 @@ class DatabaseService: last_updated_at=data[11] ) + @staticmethod + def _parse_tournament_format(format_as_string: str) -> TournamentFormat: + if format_as_string == "SE_BO_1": + return TournamentFormat.SINGLE_ELIMINATION_BO_1 + elif format_as_string == "SE_BO_3": + return TournamentFormat.SINGLE_ELIMINATION_BO_3 + elif format_as_string == "SE_BO_5": + return TournamentFormat.SINGLE_ELIMINATION_BO_5 + elif format_as_string == "DE_BO_1": + return TournamentFormat.DOUBLE_ELIMINATION_BO_1 + elif format_as_string == "DE_BO_3": + return TournamentFormat.DOUBLE_ELIMINATION_BO_3 + elif format_as_string == "DE_BO_5": + return TournamentFormat.DOUBLE_ELIMINATION_BO_5 + else: + # If this happens, database is FUBAR + raise RuntimeError(f"Unknown TournamentFormat: {format_as_string}") + + @staticmethod + def _parse_tournament_status(status_as_string: str) -> TournamentStatus: + if status_as_string == "CLOSED": + return TournamentStatus.CLOSED + elif status_as_string == "OPEN": + return TournamentStatus.OPEN + elif status_as_string == "COMPLETED": + return TournamentStatus.COMPLETED + elif status_as_string == "CANCELED": + return TournamentStatus.CANCELED + elif status_as_string == "INVITE_ONLY": + return TournamentStatus.INVITE_ONLY + elif status_as_string == "ONGOING": + return TournamentStatus.ONGOING + else: + # If this happens, database is FUBAR + raise RuntimeError(f"Unknown TournamentStatus: {status_as_string}") + + @staticmethod + def _parse_participant_type(participant_type_as_string: str) -> ParticipantType: + if participant_type_as_string == "PLAYER": + return ParticipantType.PLAYER + elif participant_type_as_string == "TEAM": + return ParticipantType.TEAM + else: + # If this happens, database is FUBAR + raise RuntimeError(f"Unknown ParticipantType: {participant_type_as_string}") + async def get_user_by_name(self, user_name: str) -> Optional[User]: async with self._connection_pool.acquire() as conn: async with conn.cursor(aiomysql.Cursor) as cursor: @@ -787,3 +837,97 @@ class DatabaseService: return await self.remove_profile_picture(user_id) except Exception as e: logger.warning(f"Error deleting user profile picture: {e}") + + async def get_all_tournaments(self) -> list[Tournament]: + logger.info(f"Polling Tournaments...") + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + try: + await cursor.execute( + """ + SELECT + /* ======================= + Tournament + ======================= */ + t.id AS tournament_id, + t.name AS tournament_name, + t.description AS tournament_description, + t.format AS tournament_format, + t.start_time, + t.status AS tournament_status, + t.max_participants, + t.created_at, + + /* ======================= + Game Title + ======================= */ + gt.id AS game_title_id, + gt.name AS game_title_name, + gt.description AS game_title_description, + gt.web_link AS game_title_web_link, + gt.image_name AS game_title_image_name, + + /* ======================= + Tournament Participant + ======================= */ + tp.id AS participant_id, + tp.user_id, + tp.participant_type, + tp.seed, + tp.joined_at + + FROM tournaments t + JOIN game_titles gt + ON gt.id = t.game_title_id + + LEFT JOIN tournament_participants tp + ON tp.tournament_id = t.id + + ORDER BY + t.id, + tp.seed IS NULL, + tp.seed; + + """ + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_all_tournaments() + except Exception as e: + logger.warning(f"Error getting tournaments: {e}") + + tournaments = [] + current_tournament: Optional[Tournament] = None + for row in await cursor.fetchall(): + if current_tournament is None or current_tournament.id != row["tournament_id"]: + if current_tournament is not None: + tournaments.append(current_tournament) + current_tournament = Tournament( + id_=row["tournament_id"], + name=row["tournament_name"], + description=row["tournament_description"], + game_title=GameTitle( + name=row["game_title_name"], + description=row["game_title_description"], + web_link=row["game_title_web_link"], + image_name=row["game_title_image_name"] + ), + format_=self._parse_tournament_format(row["tournament_format"]), + start_time=row["start_time"], + status=self._parse_tournament_status(row["tournament_status"]), + participants=[Participant(id_=row["user_id"], participant_type=self._parse_participant_type(row["participant_type"]))], + matches=None, # ToDo: Implement + rounds=[], # ToDo: Implement + max_participants=row["max_participants"] + ) + else: + current_tournament.add_participant( + Participant(id_=row["user_id"], participant_type=self._parse_participant_type(row["participant_type"])) + ) + else: + tournaments.append(current_tournament) + + return tournaments diff --git a/src/ezgg_lan_manager/services/TournamentService.py b/src/ezgg_lan_manager/services/TournamentService.py index 0c6c43a..0ada577 100644 --- a/src/ezgg_lan_manager/services/TournamentService.py +++ b/src/ezgg_lan_manager/services/TournamentService.py @@ -21,28 +21,12 @@ class TournamentService: def __init__(self, db_service: DatabaseService, user_service: UserService) -> None: self._db_service = db_service self._user_service = user_service + self._cache: dict[int, Tournament] = {} + self._cache_dirty: bool = True # Setting this flag invokes cache update on next read # This overrides the database access and is meant for easy development. # Set to None before merging back into main. self._dev_data = [ - Tournament( - 0, - "Teeworlds 2vs2", - DEV_LOREM_IPSUM, - GameTitle( - "Teeworlds", - "Teeworlds is a free online multiplayer game, available for all major operating systems. Battle with up to 16 players in a variety of game modes.", - "https://store.steampowered.com/app/380840/Teeworlds/", - "teeworlds.png" - ), - TournamentFormat.SINGLE_ELIMINATION_BO_3, - datetime(2026, 5, 8, 18, 0, 0), - TournamentStatus.OPEN, - [], - None, - [], - 32 - ), Tournament( 1, "Rocket League 3vs3", @@ -56,7 +40,7 @@ class TournamentService: TournamentFormat.SINGLE_ELIMINATION_BO_3, datetime(2026, 5, 8, 18, 0, 0), TournamentStatus.OPEN, - [Participant(30, "Typhus", ParticipantType.PLAYER)], + [Participant(30, ParticipantType.PLAYER)], None, [], 8 @@ -81,18 +65,19 @@ class TournamentService: ) ] + async def _update_cache(self) -> None: + tournaments = await self._db_service.get_all_tournaments() + for tournament in tournaments: + self._cache[tournament.id] = tournament + self._cache_dirty = False + async def get_tournaments(self) -> list[Tournament]: - # Fake DB lookup delay - await sleep(1) - - if self._dev_data is not None: - return self._dev_data - return [] # ToDo: Implement database polling + if self._cache_dirty: + await self._update_cache() + return list(self._cache.values()) async def get_tournament_by_id(self, tournament_id: int) -> Optional[Tournament]: - await sleep(1) - try: - return self._dev_data[tournament_id] - except IndexError: - return None + if self._cache_dirty: + await self._update_cache() + return self._cache.get(tournament_id, None) diff --git a/src/ezgg_lan_manager/types/Participant.py b/src/ezgg_lan_manager/types/Participant.py index 4ac1bc1..f905e44 100644 --- a/src/ezgg_lan_manager/types/Participant.py +++ b/src/ezgg_lan_manager/types/Participant.py @@ -2,10 +2,9 @@ from src.ezgg_lan_manager.types.TournamentBase import ParticipantType class Participant: - def __init__(self, id_: int, display_name: str, participant_type: ParticipantType) -> None: + def __init__(self, id_: int, participant_type: ParticipantType) -> None: self._id = id_ self._participant_type = participant_type - self._display_name = display_name @property def id(self) -> int: @@ -14,7 +13,3 @@ class Participant: @property def participant_type(self) -> ParticipantType: return self._participant_type - - @property - def display_name(self) -> str: - return self._display_name diff --git a/testing/unittests/TournamentDomainTests.py b/testing/unittests/TournamentDomainTests.py index f24c9c7..f20ac8a 100644 --- a/testing/unittests/TournamentDomainTests.py +++ b/testing/unittests/TournamentDomainTests.py @@ -16,9 +16,9 @@ class TournamentDomainTests(unittest.TestCase): self.initial_status = TournamentStatus.CLOSED # Generic Participants - self.participant_a = Participant(1, "CoolUserName", ParticipantType.PLAYER) - self.participant_b = Participant(2, "CrazyUserName", ParticipantType.PLAYER) - self.participant_c = Participant(3, "FunnyUserName", ParticipantType.PLAYER) + self.participant_a = Participant(1, ParticipantType.PLAYER) + self.participant_b = Participant(2, ParticipantType.PLAYER) + self.participant_c = Participant(3, ParticipantType.PLAYER) def test_tournament_without_participants_can_not_be_started(self) -> None: tournament_under_test = generate_new_tournament(self.name, self.description, self.game_title, self.format_, self.start_time, 32, self.initial_status)