Add Tournaments UI #32

Merged
Typhus merged 10 commits from feature/add-tournaments-ui into main 2026-02-03 23:00:58 +00:00
6 changed files with 323 additions and 41 deletions
Showing only changes of commit fffb607b16 - Show all commits

144
sql/tournament_patch.sql Normal file
View File

@ -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 */;

View File

@ -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",

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)