Add Tournaments UI (#32)

Co-authored-by: David Rodenkirchen <drodenkirchen@linetco.com>
Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
2026-02-03 23:00:58 +00:00
parent ff5d715a4e
commit 54df84a7da
28 changed files with 1116 additions and 56 deletions
@@ -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,54 @@ 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
elif format_as_string == "FFA":
return TournamentFormat.FFA
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 +839,131 @@ 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"]))] if row["user_id"] is not None else [],
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
async def add_participant_to_tournament(self, participant: Participant, tournament: Tournament) -> None:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"INSERT INTO tournament_participants (tournament_id, user_id, participant_type) VALUES (%s, %s, %s);",
(tournament.id, participant.id, participant.participant_type.name)
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.add_participant_to_tournament(participant, tournament)
except Exception as e:
logger.warning(f"Error adding participant to tournament: {e}")
async def remove_participant_from_tournament(self, participant: Participant, tournament: Tournament) -> None:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"DELETE FROM tournament_participants WHERE (tournament_id = %s AND user_id = %s);",
(tournament.id, participant.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.remove_participant_from_tournament(participant, tournament)
except Exception as e:
logger.warning(f"Error removing participant from tournament: {e}")
@@ -0,0 +1,72 @@
from typing import Optional
from src.ezgg_lan_manager.services.DatabaseService import DatabaseService
from src.ezgg_lan_manager.services.UserService import UserService
from src.ezgg_lan_manager.types.Participant import Participant
from src.ezgg_lan_manager.types.Tournament import Tournament
from src.ezgg_lan_manager.types.TournamentBase import ParticipantType, TournamentError
from src.ezgg_lan_manager.types.User import User
class TournamentService:
def __init__(self, db_service: DatabaseService, user_service: UserService) -> None:
self._db_service = db_service
self._user_service = user_service
# Crude cache mechanism. If performance suffers, maybe implement a queue with Single-Owner-Pattern or a Lock
self._cache: dict[int, Tournament] = {}
self._cache_dirty: bool = True # Setting this flag invokes cache update on next read
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 register_user_for_tournament(self, user_id: int, tournament_id: int) -> None:
tournament = await self.get_tournament_by_id(tournament_id)
if not tournament:
raise TournamentError(f"No tournament with ID {tournament_id} was found")
participant = Participant(id_=user_id, participant_type=ParticipantType.PLAYER)
tournament.add_participant(participant)
await self._db_service.add_participant_to_tournament(participant, tournament)
self._cache_dirty = True
async def unregister_user_from_tournament(self, user_id: int, tournament_id: int) -> None:
tournament = await self.get_tournament_by_id(tournament_id)
if not tournament:
raise TournamentError(f"No tournament with ID {tournament_id} was found")
participant = next(filter(lambda p: p.id == user_id, tournament.participants), None)
if participant is not None:
tournament.remove_participant(participant)
await self._db_service.remove_participant_from_tournament(participant, tournament)
self._cache_dirty = True
async def get_tournaments(self) -> list[Tournament]:
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]:
if self._cache_dirty:
await self._update_cache()
return self._cache.get(tournament_id, None)
async def get_users_from_participant_list(self, participants: list[Participant]) -> list[User]:
all_users = await self._db_service.get_all_users()
participant_ids = [p.id for p in participants]
return list(filter(lambda u: u.user_id in participant_ids, all_users))
async def start_tournament(self, tournament_id: int):
tournament = await self.get_tournament_by_id(tournament_id)
if tournament:
tournament.start()
# ToDo: Write matches/round to database
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
self._cache_dirty = True