Release 0.2.0 #34
144
sql/tournament_patch.sql
Normal 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 */;
|
||||
@ -30,7 +30,7 @@ if __name__ == "__main__":
|
||||
corner_radius_large=0,
|
||||
font=Font(from_root("src/ezgg_lan_manager/assets/fonts/joystix.otf"))
|
||||
)
|
||||
default_attachments = [LocalData()]
|
||||
default_attachments: list = [LocalData()]
|
||||
default_attachments.extend(init_services())
|
||||
|
||||
lan_info = default_attachments[3].get_lan_info()
|
||||
@ -161,6 +161,16 @@ if __name__ == "__main__":
|
||||
name="DbErrorPage",
|
||||
url_segment="db-error",
|
||||
build=pages.DbErrorPage,
|
||||
),
|
||||
ComponentPage(
|
||||
name="TournamentDetailsPage",
|
||||
url_segment="tournament",
|
||||
build=pages.TournamentDetailsPage,
|
||||
),
|
||||
ComponentPage(
|
||||
name="TournamentRulesPage",
|
||||
url_segment="tournament-rules",
|
||||
build=pages.TournamentRulesPage,
|
||||
)
|
||||
],
|
||||
theme=theme,
|
||||
@ -188,5 +198,5 @@ if __name__ == "__main__":
|
||||
|
||||
sys.exit(app.run_as_web_server(
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
port=8001,
|
||||
))
|
||||
|
||||
@ -13,11 +13,12 @@ from src.ezgg_lan_manager.services.NewsService import NewsService
|
||||
from src.ezgg_lan_manager.services.ReceiptPrintingService import ReceiptPrintingService
|
||||
from src.ezgg_lan_manager.services.SeatingService import SeatingService
|
||||
from src.ezgg_lan_manager.services.TicketingService import TicketingService
|
||||
from src.ezgg_lan_manager.services.TournamentService import TournamentService
|
||||
from src.ezgg_lan_manager.services.UserService import UserService
|
||||
from src.ezgg_lan_manager.types import *
|
||||
|
||||
# Inits services in the correct order
|
||||
def init_services() -> tuple[AccountingService, CateringService, ConfigurationService, DatabaseService, MailingService, NewsService, SeatingService, TicketingService, UserService, LocalDataService, ReceiptPrintingService]:
|
||||
def init_services() -> tuple[AccountingService, CateringService, ConfigurationService, DatabaseService, MailingService, NewsService, SeatingService, TicketingService, UserService, LocalDataService, ReceiptPrintingService, TournamentService]:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
configuration_service = ConfigurationService(from_root("config.toml"))
|
||||
db_service = DatabaseService(configuration_service.get_database_configuration())
|
||||
@ -30,6 +31,7 @@ def init_services() -> tuple[AccountingService, CateringService, ConfigurationSe
|
||||
receipt_printing_service = ReceiptPrintingService(seating_service, configuration_service.get_receipt_printing_configuration(), configuration_service.DEV_MODE_ACTIVE)
|
||||
catering_service = CateringService(db_service, accounting_service, user_service, receipt_printing_service)
|
||||
local_data_service = LocalDataService()
|
||||
tournament_service = TournamentService(db_service, user_service)
|
||||
|
||||
|
||||
return accounting_service, catering_service, configuration_service, db_service, mailing_service, news_service, seating_service, ticketing_service, user_service, local_data_service, receipt_printing_service
|
||||
return accounting_service, catering_service, configuration_service, db_service, mailing_service, news_service, seating_service, ticketing_service, user_service, local_data_service, receipt_printing_service, tournament_service
|
||||
|
||||
BIN
src/ezgg_lan_manager/assets/img/games/darts.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
src/ezgg_lan_manager/assets/img/games/dota2.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src/ezgg_lan_manager/assets/img/games/golfit.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/ezgg_lan_manager/assets/img/games/jenga.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
src/ezgg_lan_manager/assets/img/games/neoee.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
src/ezgg_lan_manager/assets/img/games/rl.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
src/ezgg_lan_manager/assets/img/games/teeworlds.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
src/ezgg_lan_manager/assets/img/games/tetris.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/ezgg_lan_manager/assets/img/games/wikinger_schach.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
src/ezgg_lan_manager/assets/img/games/worms.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
35
src/ezgg_lan_manager/components/TournamentDetailsInfoRow.py
Normal file
@ -0,0 +1,35 @@
|
||||
from typing import Optional
|
||||
|
||||
from rio import Component, Row, Text, TextStyle, Color
|
||||
|
||||
|
||||
class TournamentDetailsInfoRow(Component):
|
||||
key: str
|
||||
value: str
|
||||
key_color: Optional[Color] = None
|
||||
value_color: Optional[Color] = None
|
||||
|
||||
|
||||
def build(self) -> Component:
|
||||
return Row(
|
||||
Text(
|
||||
text=self.key,
|
||||
style=TextStyle(
|
||||
fill=self.key_color if self.key_color is not None else self.session.theme.background_color,
|
||||
font_size=1
|
||||
),
|
||||
margin_bottom=0.5,
|
||||
align_x=0
|
||||
),
|
||||
Text(
|
||||
text=self.value,
|
||||
style=TextStyle(
|
||||
fill=self.value_color if self.value_color is not None else self.session.theme.background_color,
|
||||
font_size=1
|
||||
),
|
||||
margin_bottom=0.5,
|
||||
align_x=1
|
||||
),
|
||||
margin_left=4,
|
||||
margin_right=4
|
||||
)
|
||||
60
src/ezgg_lan_manager/components/TournamentPageRow.py
Normal file
@ -0,0 +1,60 @@
|
||||
from typing import Literal, Callable
|
||||
|
||||
from rio import Component, PointerEventListener, Rectangle, Image, Text, Tooltip, TextStyle, Color, Icon, Row, PointerEvent
|
||||
|
||||
from from_root import from_root
|
||||
|
||||
from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus
|
||||
|
||||
|
||||
class TournamentPageRow(Component):
|
||||
tournament_id: int
|
||||
tournament_name: str
|
||||
game_image_name: str
|
||||
current_participants: int
|
||||
max_participants: int
|
||||
tournament_status: TournamentStatus
|
||||
clicked_cb: Callable
|
||||
|
||||
def handle_click(self, _: PointerEvent) -> None:
|
||||
self.clicked_cb(self.tournament_id)
|
||||
|
||||
def determine_tournament_status_icon_color_and_text(self) -> tuple[str, Literal["success", "warning", "danger"], str]:
|
||||
if self.tournament_status == TournamentStatus.OPEN:
|
||||
return "material/lock_open", "success", "Anmeldung geöffnet"
|
||||
elif self.tournament_status == TournamentStatus.CLOSED:
|
||||
return "material/lock", "danger", "Anmeldung geschlossen"
|
||||
elif self.tournament_status == TournamentStatus.ONGOING:
|
||||
return "material/autoplay", "warning", "Turnier läuft"
|
||||
elif self.tournament_status == TournamentStatus.COMPLETED:
|
||||
return "material/check_circle", "success", "Turnier beendet"
|
||||
elif self.tournament_status == TournamentStatus.CANCELED:
|
||||
return "material/cancel", "danger", "Turnier abgesagt"
|
||||
elif self.tournament_status == TournamentStatus.INVITE_ONLY:
|
||||
return "material/person_cancel", "warning", "Teilnahme nur per Einladung"
|
||||
else:
|
||||
raise RuntimeError(f"Unknown tournament status: {str(self.tournament_status)}")
|
||||
|
||||
def build(self) -> Component:
|
||||
icon_name, color, text = self.determine_tournament_status_icon_color_and_text()
|
||||
return PointerEventListener(
|
||||
content=Rectangle(
|
||||
content=Row(
|
||||
Image(image=from_root(f"src/ezgg_lan_manager/assets/img/games/{self.game_image_name}")),
|
||||
Text(self.tournament_name, style=TextStyle(fill=self.session.theme.background_color, font_size=1)),
|
||||
Text(f"{self.current_participants}/{self.max_participants}", style=TextStyle(fill=self.session.theme.background_color, font_size=1), justify="right", margin_right=0.5),
|
||||
Tooltip(anchor=Icon(icon_name, min_width=1, min_height=1, fill=color), position="top",
|
||||
tip=Text(text, style=TextStyle(fill=self.session.theme.background_color, font_size=0.7))),
|
||||
proportions=[1, 4, 1, 1],
|
||||
margin=.5
|
||||
),
|
||||
fill=self.session.theme.hud_color,
|
||||
margin=1,
|
||||
margin_bottom=0,
|
||||
stroke_color=Color.TRANSPARENT,
|
||||
stroke_width=0.2,
|
||||
hover_stroke_color=self.session.theme.background_color,
|
||||
cursor="pointer"
|
||||
),
|
||||
on_press=self.handle_click
|
||||
)
|
||||
@ -1,32 +1,117 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from rio import Column, Component, event, TextStyle, Text, Spacer
|
||||
from from_root import from_root
|
||||
from rio import Column, Component, event, TextStyle, Text, Spacer, Row, Image, Tooltip, IconButton, Popup, Rectangle, Dropdown, ThemeContextSwitcher, Button
|
||||
|
||||
from src.ezgg_lan_manager import ConfigurationService
|
||||
from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService
|
||||
from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox
|
||||
from src.ezgg_lan_manager.types.DateUtil import weekday_to_display_text
|
||||
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 TournamentStatus
|
||||
|
||||
logger = logging.getLogger(__name__.split(".")[-1])
|
||||
|
||||
class ManageTournamentsPage(Component):
|
||||
tournaments: list[Tournament] = []
|
||||
remove_participant_popup_open: bool = False
|
||||
cancel_options: dict[str, Optional[Participant]] = {"": None}
|
||||
tournament_id_selected_for_participant_removal: Optional[int] = None
|
||||
participant_selected_for_removal: Optional[Participant] = None
|
||||
|
||||
@event.on_populate
|
||||
async def on_populate(self) -> None:
|
||||
self.tournaments = await self.session[TournamentService].get_tournaments()
|
||||
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turnier Verwaltung")
|
||||
|
||||
async def on_start_pressed(self, tournament_id: int) -> None:
|
||||
logger.info(f"Starting tournament with ID {tournament_id}")
|
||||
await self.session[TournamentService].start_tournament(tournament_id)
|
||||
|
||||
async def on_cancel_pressed(self, tournament_id: int) -> None:
|
||||
logger.info(f"Canceling tournament with ID {tournament_id}")
|
||||
await self.session[TournamentService].cancel_tournament(tournament_id)
|
||||
|
||||
async def on_remove_participant_pressed(self, tournament_id: int) -> None:
|
||||
tournament = await self.session[TournamentService].get_tournament_by_id(tournament_id)
|
||||
if tournament is None:
|
||||
return
|
||||
users = await self.session[UserService].get_all_users()
|
||||
try:
|
||||
self.cancel_options = {next(filter(lambda u: u.user_id == p.id, users)).user_name: p for p in tournament.participants}
|
||||
except StopIteration as e:
|
||||
logger.error(f"Error trying to find user for participant: {e}")
|
||||
self.tournament_id_selected_for_participant_removal = tournament_id
|
||||
self.remove_participant_popup_open = True
|
||||
|
||||
async def on_remove_participant_confirm_pressed(self) -> None:
|
||||
if self.participant_selected_for_removal is not None and self.tournament_id_selected_for_participant_removal is not None:
|
||||
logger.info(f"Removing participant with ID {self.participant_selected_for_removal.id} from tournament with ID {self.tournament_id_selected_for_participant_removal}")
|
||||
await self.session[TournamentService].unregister_user_from_tournament(self.participant_selected_for_removal.id, self.tournament_id_selected_for_participant_removal)
|
||||
await self.on_remove_participant_cancel_pressed()
|
||||
|
||||
async def on_remove_participant_cancel_pressed(self) -> None:
|
||||
self.tournament_id_selected_for_participant_removal = None
|
||||
self.participant_selected_for_removal = None
|
||||
self.remove_participant_popup_open = False
|
||||
|
||||
def build(self) -> Component:
|
||||
return Column(
|
||||
MainViewContentBox(
|
||||
Column(
|
||||
Text(
|
||||
text="Turnier Verwaltung",
|
||||
style=TextStyle(
|
||||
fill=self.session.theme.background_color,
|
||||
font_size=1.2
|
||||
),
|
||||
margin_top=2,
|
||||
margin_bottom=2,
|
||||
align_x=0.5
|
||||
)
|
||||
tournament_rows = []
|
||||
for tournament in self.tournaments:
|
||||
start_time_color = self.session.theme.background_color
|
||||
if tournament.start_time < datetime.now() and tournament.status == TournamentStatus.OPEN:
|
||||
start_time_color = self.session.theme.warning_color
|
||||
|
||||
tournament_rows.append(
|
||||
Row(
|
||||
Image(image=from_root(f"src/ezgg_lan_manager/assets/img/games/{tournament.game_title.image_name}"), min_width=1.5, margin_right=1),
|
||||
Text(tournament.name, style=TextStyle(fill=self.session.theme.background_color, font_size=0.8), justify="left", margin_right=1.5),
|
||||
Text(f"{weekday_to_display_text(tournament.start_time.weekday())[:2]}.{tournament.start_time.strftime('%H:%M')} Uhr", style=TextStyle(fill=start_time_color, font_size=0.8), justify="left", margin_right=1),
|
||||
Spacer(),
|
||||
Tooltip(anchor=IconButton("material/play_arrow", min_size=2, margin_right=0.5, on_press=lambda: self.on_start_pressed(tournament.id)), tip="Starten"),
|
||||
Tooltip(anchor=IconButton("material/cancel_schedule_send", min_size=2, margin_right=0.5, on_press=lambda: self.on_cancel_pressed(tournament.id)), tip="Absagen"),
|
||||
Tooltip(anchor=IconButton("material/person_cancel", min_size=2, on_press=lambda: self.on_remove_participant_pressed(tournament.id)), tip="Spieler entfernen"),
|
||||
margin=1
|
||||
)
|
||||
)
|
||||
|
||||
return Column(
|
||||
Popup(
|
||||
anchor=MainViewContentBox(
|
||||
Column(
|
||||
Text(
|
||||
text="Turnier Verwaltung",
|
||||
style=TextStyle(
|
||||
fill=self.session.theme.background_color,
|
||||
font_size=1.2
|
||||
),
|
||||
margin_top=2,
|
||||
margin_bottom=2,
|
||||
align_x=0.5
|
||||
),
|
||||
*tournament_rows
|
||||
)
|
||||
),
|
||||
content=Rectangle(
|
||||
content=Row(
|
||||
ThemeContextSwitcher(
|
||||
content=Dropdown(options=self.cancel_options, min_width=20, selected_value=self.bind().participant_selected_for_removal), color=self.session.theme.hud_color
|
||||
),
|
||||
Button(content="REMOVE", shape="rectangle", grow_x=False, on_press=self.on_remove_participant_confirm_pressed),
|
||||
Button(content="CANCEL", shape="rectangle", grow_x=False, on_press=self.on_remove_participant_cancel_pressed),
|
||||
margin=0.5
|
||||
),
|
||||
min_width=30,
|
||||
min_height=4,
|
||||
fill=self.session.theme.primary_color,
|
||||
margin_top=3.5,
|
||||
stroke_width=0.3,
|
||||
stroke_color=self.session.theme.neutral_color,
|
||||
),
|
||||
is_open=self.remove_participant_popup_open,
|
||||
color="none"
|
||||
),
|
||||
Spacer()
|
||||
)
|
||||
)
|
||||
|
||||
252
src/ezgg_lan_manager/pages/TournamentDetailsPage.py
Normal file
@ -0,0 +1,252 @@
|
||||
from typing import Optional, Union, Literal
|
||||
|
||||
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
|
||||
|
||||
from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService
|
||||
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
|
||||
from src.ezgg_lan_manager.types.Participant import Participant
|
||||
from src.ezgg_lan_manager.types.SessionStorage import SessionStorage
|
||||
from src.ezgg_lan_manager.types.Tournament import Tournament
|
||||
from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus, tournament_status_to_display_text, tournament_format_to_display_texts
|
||||
from src.ezgg_lan_manager.types.User import User
|
||||
|
||||
|
||||
class TournamentDetailsPage(Component):
|
||||
tournament: Optional[Union[Tournament, str]] = None
|
||||
rules_accepted: bool = False
|
||||
user: Optional[User] = None
|
||||
loading: bool = False
|
||||
participant_revealer_open: bool = False
|
||||
current_tournament_user_list: list[User] = [] # ToDo: Integrate Teams
|
||||
|
||||
# State for message above register button
|
||||
message: str = ""
|
||||
is_success: 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 self.tournament is not None:
|
||||
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.tournament.name}")
|
||||
self.current_tournament_user_list = await self.session[TournamentService].get_users_from_participant_list(self.tournament.participants)
|
||||
else:
|
||||
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turniere")
|
||||
|
||||
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
|
||||
|
||||
self.loading_done()
|
||||
|
||||
def open_close_participant_revealer(self, _: PointerEvent) -> None:
|
||||
self.participant_revealer_open = not self.participant_revealer_open
|
||||
|
||||
async def register_pressed(self) -> None:
|
||||
self.loading = True
|
||||
if not self.user:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.session[TournamentService].register_user_for_tournament(self.user.user_id, self.tournament.id)
|
||||
self.is_success = True
|
||||
self.message = f"Erfolgreich angemeldet!"
|
||||
except Exception as e:
|
||||
self.is_success = False
|
||||
self.message = f"Fehler: {e}"
|
||||
self.loading = False
|
||||
await self.on_populate()
|
||||
|
||||
async def unregister_pressed(self) -> None:
|
||||
self.loading = True
|
||||
if not self.user:
|
||||
return
|
||||
|
||||
try:
|
||||
await self.session[TournamentService].unregister_user_from_tournament(self.user.user_id, self.tournament.id)
|
||||
self.is_success = True
|
||||
self.message = f"Erfolgreich abgemeldet!"
|
||||
except Exception as e:
|
||||
self.is_success = False
|
||||
self.message = f"Fehler: {e}"
|
||||
self.loading = False
|
||||
await self.on_populate()
|
||||
|
||||
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"
|
||||
self.session[SessionStorage].subscribe_to_logged_in_or_out_event(str(self.__class__), self.on_populate)
|
||||
|
||||
def build(self) -> Component:
|
||||
if self.tournament is None:
|
||||
content = Column(
|
||||
ProgressCircle(
|
||||
color="secondary",
|
||||
align_x=0.5,
|
||||
margin_top=0,
|
||||
margin_bottom=0
|
||||
),
|
||||
min_height=10
|
||||
)
|
||||
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:
|
||||
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 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]
|
||||
color_key: Literal["hud", "danger"] = "hud"
|
||||
on_press_function = self.register_pressed
|
||||
if self.user and self.user.user_id in ids_of_participants: # User already registered for tournament
|
||||
button_text = "Abmelden"
|
||||
button_sensitive_hook = True # User has already accepted the rules previously
|
||||
color_key = "danger"
|
||||
on_press_function = self.unregister_pressed
|
||||
elif self.user and self.user.user_id not in ids_of_participants:
|
||||
button_text = "Anmelden"
|
||||
button_sensitive_hook = self.rules_accepted
|
||||
else:
|
||||
# This should NEVER happen
|
||||
button_text = "Anmelden"
|
||||
button_sensitive_hook = False
|
||||
|
||||
if self.tournament.status != TournamentStatus.OPEN or self.tournament.is_full:
|
||||
button_sensitive_hook = False # Override button controls if tournament is not open anymore or full
|
||||
|
||||
if self.user:
|
||||
accept_rules_row = Row(
|
||||
ThemeContextSwitcher(content=Checkbox(is_on=self.bind().rules_accepted, margin_left=4), color=self.session.theme.hud_color),
|
||||
Text("Ich akzeptiere die ", margin_left=1, style=TextStyle(fill=self.session.theme.background_color, font_size=0.8), overflow="nowrap", justify="right"),
|
||||
Link(Text("Turnierregeln", margin_right=4, style=TextStyle(fill=self.session.theme.background_color, font_size=0.8, italic=True), overflow="nowrap", justify="left"), "./tournament-rules", open_in_new_tab=True)
|
||||
)
|
||||
button = Button(
|
||||
content=button_text,
|
||||
shape="rectangle",
|
||||
style="major",
|
||||
color=color_key,
|
||||
margin_left=2,
|
||||
margin_right=2,
|
||||
is_sensitive=button_sensitive_hook,
|
||||
on_press=on_press_function,
|
||||
is_loading=self.loading
|
||||
)
|
||||
else:
|
||||
# No UI here if user not logged in
|
||||
accept_rules_row, button = Spacer(), Spacer()
|
||||
|
||||
|
||||
|
||||
content = Column(
|
||||
Row(
|
||||
Image(image=from_root(f"src/ezgg_lan_manager/assets/img/games/{self.tournament.game_title.image_name}"), margin_right=1),
|
||||
Text(
|
||||
text=self.tournament.name,
|
||||
style=TextStyle(
|
||||
fill=self.session.theme.background_color,
|
||||
font_size=1.2
|
||||
),
|
||||
margin_top=1,
|
||||
margin_bottom=1,
|
||||
align_x=0.5
|
||||
),
|
||||
margin_right=6,
|
||||
margin_left=6
|
||||
),
|
||||
Spacer(min_height=1),
|
||||
TournamentDetailsInfoRow("Status", tournament_status_to_display_text(self.tournament.status), value_color=tournament_status_color),
|
||||
TournamentDetailsInfoRow("Startzeit", f"{weekday_to_display_text(self.tournament.start_time.weekday())}, {self.tournament.start_time.strftime('%H:%M')} Uhr"),
|
||||
TournamentDetailsInfoRow("Format", tournament_format_to_display_texts(self.tournament.format)[0]),
|
||||
TournamentDetailsInfoRow("Best of", tournament_format_to_display_texts(self.tournament.format)[1]),
|
||||
PointerEventListener(
|
||||
content=Rectangle(
|
||||
content=TournamentDetailsInfoRow(
|
||||
"Teilnehmer ▴" if self.participant_revealer_open else "Teilnehmer ▾",
|
||||
f"{len(self.tournament.participants)} / {self.tournament.max_participants}",
|
||||
value_color=self.session.theme.danger_color if self.tournament.is_full else self.session.theme.background_color,
|
||||
key_color=self.session.theme.secondary_color
|
||||
),
|
||||
fill=Color.TRANSPARENT,
|
||||
cursor="pointer"
|
||||
),
|
||||
on_press=self.open_close_participant_revealer
|
||||
),
|
||||
Revealer(
|
||||
header=None,
|
||||
content=Text(
|
||||
"\n".join([u.user_name for u in self.current_tournament_user_list]), # ToDo: Integrate Teams
|
||||
style=TextStyle(fill=self.session.theme.background_color)
|
||||
),
|
||||
is_open=self.participant_revealer_open,
|
||||
margin_left=4,
|
||||
margin_right=4
|
||||
),
|
||||
tree_button,
|
||||
Row(
|
||||
Text(
|
||||
text="Info",
|
||||
style=TextStyle(
|
||||
fill=self.session.theme.background_color,
|
||||
font_size=1.2
|
||||
),
|
||||
margin_top=1,
|
||||
margin_bottom=1,
|
||||
align_x=0.5
|
||||
)
|
||||
),
|
||||
# FixMe: Use rio.Markdown with correct TextStyle instead to allow basic text formatting from DB-side.
|
||||
Text(self.tournament.description, margin_left=2, margin_right=2, style=TextStyle(fill=self.session.theme.background_color, font_size=1), overflow="wrap"),
|
||||
Spacer(min_height=2),
|
||||
accept_rules_row,
|
||||
Spacer(min_height=0.5),
|
||||
Text(self.message, margin_left=2, margin_right=2, style=TextStyle(fill=self.session.theme.success_color if self.is_success else self.session.theme.danger_color, font_size=1), overflow="wrap", justify="center"),
|
||||
Spacer(min_height=0.5),
|
||||
button
|
||||
)
|
||||
|
||||
return Column(
|
||||
MainViewContentBox(
|
||||
Column(
|
||||
Spacer(min_height=1),
|
||||
content,
|
||||
Spacer(min_height=1)
|
||||
)
|
||||
),
|
||||
align_y=0
|
||||
)
|
||||
52
src/ezgg_lan_manager/pages/TournamentRulesPage.py
Normal file
@ -0,0 +1,52 @@
|
||||
from rio import Column, Component, event, TextStyle, Text, Spacer
|
||||
|
||||
from src.ezgg_lan_manager import ConfigurationService
|
||||
from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox
|
||||
|
||||
RULES: list[str] = [
|
||||
"Den Anweisungen der Turnierleitung ist stets Folge zu leisten.",
|
||||
"Teilnehmer müssen aktiv dafür sorgen, dass Spiele ohne Verzögerungen stattfinden.",
|
||||
"Unvollständige Teams werden ggf. zum Turnierstart entfernt.",
|
||||
"Verzögerungen und Ausfälle sind er Turnierleitung sofort zu melden.",
|
||||
"Jeder Spieler erstellt Screenshots am Rundenende zur Ergebnisdokumentation.",
|
||||
"Der Verlierer trägt das Ergebnis ein, der Gewinner überprüft es.",
|
||||
"Bei fehlendem oder falschem Ergebnis, ist sofort die Turnierorganisation zu informieren.",
|
||||
"Von 02:00–11:00 Uhr besteht keine Spielpflicht",
|
||||
"Täuschung, Falschangaben sowie Bugusing und Cheaten führen zur sofortigen Disqualifikation."
|
||||
]
|
||||
|
||||
class TournamentRulesPage(Component):
|
||||
@event.on_populate
|
||||
async def on_populate(self) -> None:
|
||||
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turnierregeln")
|
||||
|
||||
def build(self) -> Component:
|
||||
return Column(
|
||||
MainViewContentBox(
|
||||
Column(
|
||||
Text(
|
||||
text="Turnierregeln",
|
||||
style=TextStyle(
|
||||
fill=self.session.theme.background_color,
|
||||
font_size=1.2
|
||||
),
|
||||
margin_top=2,
|
||||
margin_bottom=2,
|
||||
align_x=0.5
|
||||
),
|
||||
*[Text(
|
||||
f"{idx + 1}. {rule}",
|
||||
style=TextStyle(
|
||||
fill=self.session.theme.background_color,
|
||||
font_size=0.9
|
||||
),
|
||||
margin_bottom=0.8,
|
||||
margin_left=1,
|
||||
margin_right=1,
|
||||
overflow="wrap"
|
||||
) for idx, rule in enumerate(RULES)],
|
||||
Spacer(min_height=1)
|
||||
)
|
||||
),
|
||||
Spacer(grow_y=True)
|
||||
)
|
||||
@ -1,15 +1,50 @@
|
||||
from rio import Column, Component, event, TextStyle, Text
|
||||
from rio import Column, Component, event, TextStyle, Text, Spacer, ProgressCircle
|
||||
|
||||
from src.ezgg_lan_manager import ConfigurationService
|
||||
from src.ezgg_lan_manager import ConfigurationService, TournamentService
|
||||
from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox
|
||||
from src.ezgg_lan_manager.components.TournamentPageRow import TournamentPageRow
|
||||
from src.ezgg_lan_manager.types.Tournament import Tournament
|
||||
|
||||
|
||||
class TournamentsPage(Component):
|
||||
tournament_data: list[Tournament] = []
|
||||
|
||||
@event.on_populate
|
||||
async def on_populate(self) -> None:
|
||||
self.tournament_data = await self.session[TournamentService].get_tournaments()
|
||||
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turniere")
|
||||
|
||||
def tournament_clicked(self, tournament_id: int) -> None:
|
||||
self.session.navigate_to(f"tournament?id={tournament_id}")
|
||||
|
||||
def build(self) -> Component:
|
||||
tournament_page_rows = []
|
||||
for tournament in self.tournament_data:
|
||||
tournament_page_rows.append(
|
||||
TournamentPageRow(
|
||||
tournament.id,
|
||||
tournament.name,
|
||||
tournament.game_title.image_name,
|
||||
len(tournament.participants),
|
||||
tournament.max_participants,
|
||||
tournament.status,
|
||||
self.tournament_clicked
|
||||
)
|
||||
)
|
||||
|
||||
if len(self.tournament_data) == 0:
|
||||
content = [Column(
|
||||
ProgressCircle(
|
||||
color="secondary",
|
||||
align_x=0.5,
|
||||
margin_top=0,
|
||||
margin_bottom=0
|
||||
),
|
||||
min_height=10
|
||||
)]
|
||||
else:
|
||||
content = tournament_page_rows
|
||||
|
||||
return Column(
|
||||
MainViewContentBox(
|
||||
Column(
|
||||
@ -20,18 +55,11 @@ class TournamentsPage(Component):
|
||||
font_size=1.2
|
||||
),
|
||||
margin_top=2,
|
||||
margin_bottom=0,
|
||||
margin_bottom=2,
|
||||
align_x=0.5
|
||||
),
|
||||
Text(
|
||||
text="Aktuell ist noch kein Turnierplan hinterlegt.",
|
||||
style=TextStyle(
|
||||
fill=self.session.theme.background_color,
|
||||
font_size=0.9
|
||||
),
|
||||
margin=1,
|
||||
overflow="wrap"
|
||||
)
|
||||
*content,
|
||||
Spacer(min_height=1)
|
||||
)
|
||||
),
|
||||
align_y=0
|
||||
|
||||
@ -20,3 +20,5 @@ from .ManageUsersPage import ManageUsersPage
|
||||
from .ManageCateringPage import ManageCateringPage
|
||||
from .ManageTournamentsPage import ManageTournamentsPage
|
||||
from .OverviewPage import OverviewPage
|
||||
from .TournamentDetailsPage import TournamentDetailsPage
|
||||
from .TournamentRulesPage import TournamentRulesPage
|
||||
|
||||
@ -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}")
|
||||
|
||||
72
src/ezgg_lan_manager/services/TournamentService.py
Normal file
@ -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
|
||||
15
src/ezgg_lan_manager/types/DateUtil.py
Normal file
@ -0,0 +1,15 @@
|
||||
def weekday_to_display_text(weekday: int) -> str:
|
||||
if weekday == 0:
|
||||
return "Montag"
|
||||
elif weekday == 1:
|
||||
return "Dienstag"
|
||||
elif weekday == 2:
|
||||
return "Mittwoch"
|
||||
elif weekday == 3:
|
||||
return "Donnerstag"
|
||||
elif weekday == 4:
|
||||
return "Freitag"
|
||||
elif weekday == 5:
|
||||
return "Samstag"
|
||||
else:
|
||||
return "Sonntag"
|
||||
@ -8,9 +8,9 @@ from src.ezgg_lan_manager.types.TournamentBase import MatchStatus, TournamentErr
|
||||
|
||||
|
||||
class MatchParticipant:
|
||||
def __init__(self, participant_id: int, slot_number: Literal[1, 2]) -> None:
|
||||
def __init__(self, participant_id: int, slot_number: Literal[-1, 1, 2]) -> None:
|
||||
self._participant_id = participant_id
|
||||
if slot_number not in (1, 2):
|
||||
if slot_number not in (-1, 1, 2):
|
||||
raise TournamentError("Invalid slot number")
|
||||
self.slot_number = slot_number
|
||||
|
||||
@ -99,7 +99,9 @@ class Match:
|
||||
def next_match_lose_id(self) -> Optional[int]:
|
||||
return self._next_match_lose_id
|
||||
|
||||
def assign_participant(self, participant_id: int, slot: Literal[1, 2]) -> None:
|
||||
def assign_participant(self, participant_id: int, slot: Literal[-1, 1, 2]) -> None:
|
||||
if slot == -1:
|
||||
raise TournamentError("Normal match does not support slot -1")
|
||||
new_participant = MatchParticipant(participant_id, slot)
|
||||
if len(self._participants) < 2 and not any(p.participant_id == participant_id for p in self._participants):
|
||||
if len(self._participants) == 1 and self._participants[-1].slot_number == new_participant.slot_number:
|
||||
@ -131,3 +133,28 @@ class Match:
|
||||
)
|
||||
return (f"<Match id={self._match_id} round={self._round_number} bracket={self._bracket.name} "
|
||||
f"status={self.status.name} participants=[{participants}] winner={self.winner}>")
|
||||
|
||||
class FFAMatch(Match):
|
||||
"""
|
||||
Specialized match that supports infinite participants
|
||||
"""
|
||||
def __init__(self, match_id: int, tournament_id: int, round_number: int, bracket: Bracket, best_of: int, status: MatchStatus,
|
||||
next_match_win_lose_ids: tuple[Optional[int], Optional[int]], match_has_ended_callback: Callable) -> None:
|
||||
super().__init__(match_id, tournament_id, round_number, bracket, best_of, status, next_match_win_lose_ids, match_has_ended_callback)
|
||||
|
||||
@property
|
||||
def is_fully_seeded(self) -> bool:
|
||||
return len(self._participants) > 1
|
||||
|
||||
def assign_participant(self, participant_id: int, slot: Literal[-1, 1, 2]) -> None:
|
||||
if slot != -1:
|
||||
raise TournamentError("FFAMatch does not support slot 1 and 2")
|
||||
new_participant = MatchParticipant(participant_id, slot)
|
||||
self._participants.append(new_participant)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
participants = ", ".join(
|
||||
f"{p.participant_id}" for p in self._participants
|
||||
)
|
||||
return (f"<Match id={self._match_id} round={self._round_number} bracket={self._bracket.name} "
|
||||
f"status={self.status.name} participants=[{participants}] winner={self.winner}>")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -3,7 +3,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
from math import ceil, log2
|
||||
|
||||
from src.ezgg_lan_manager.types.Match import Match
|
||||
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
|
||||
|
||||
@ -12,15 +12,18 @@ class Tournament:
|
||||
def __init__(self,
|
||||
id_: int,
|
||||
name: str,
|
||||
description: str,
|
||||
game_title: GameTitle,
|
||||
format_: TournamentFormat,
|
||||
start_time: datetime,
|
||||
status: TournamentStatus,
|
||||
participants: list[Participant],
|
||||
matches: Optional[tuple[Match]],
|
||||
rounds: list[list[Match]]) -> None:
|
||||
rounds: list[list[Match]],
|
||||
max_participants: int) -> None:
|
||||
self._id = id_
|
||||
self._name = name
|
||||
self._description = description
|
||||
self._game_title = game_title
|
||||
self._format = format_
|
||||
self._start_time = start_time
|
||||
@ -28,6 +31,7 @@ class Tournament:
|
||||
self._participants = participants
|
||||
self._matches = matches
|
||||
self._rounds = rounds
|
||||
self._max_participants = max_participants
|
||||
|
||||
@property
|
||||
def id(self) -> int:
|
||||
@ -69,11 +73,31 @@ class Tournament:
|
||||
def matches(self) -> list[Match]:
|
||||
return self._matches if self._matches else []
|
||||
|
||||
@property
|
||||
def max_participants(self) -> int:
|
||||
return self._max_participants
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self._description
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
return len(self._participants) >= self._max_participants
|
||||
|
||||
def add_participant(self, participant: Participant) -> None:
|
||||
if participant.id in (p.id for p in self._participants):
|
||||
raise TournamentError(f"Participant with ID {participant.id} already registered for tournament")
|
||||
self._participants.append(participant)
|
||||
|
||||
def remove_participant(self, participant: Participant) -> None:
|
||||
if participant.id not in (p.id for p in self._participants):
|
||||
raise TournamentError(f"Participant with ID {participant.id} not registered for tournament")
|
||||
# ToDo: Check if tournament already started => correctly resolve matches with now missing participant
|
||||
self._participants.remove(participant)
|
||||
|
||||
def cancel(self):
|
||||
self.status = TournamentStatus.CANCELED
|
||||
|
||||
def match_has_ended_callback(self, match: Match) -> None:
|
||||
if self._matches is None:
|
||||
@ -108,10 +132,12 @@ class Tournament:
|
||||
bracket = "SINGLE"
|
||||
elif fmt.name.startswith("DOUBLE_ELIMINATION"):
|
||||
bracket = "DOUBLE"
|
||||
elif fmt.name.startswith("FFA"):
|
||||
bracket = "FINAL"
|
||||
else:
|
||||
raise TournamentError(f"Unsupported tournament format: {fmt}")
|
||||
|
||||
if fmt.name.endswith("_BO_1"):
|
||||
if fmt.name.endswith("_BO_1") or fmt.name.endswith("FFA"):
|
||||
bo = 1
|
||||
elif fmt.name.endswith("_BO_3"):
|
||||
bo = 3
|
||||
@ -129,7 +155,28 @@ class Tournament:
|
||||
num_participants = len(self.participants)
|
||||
match_id_counter = 1
|
||||
|
||||
if bracket_type == "SINGLE":
|
||||
if bracket_type == "FINAL":
|
||||
rounds: list[list[Match]] = []
|
||||
round_matches = []
|
||||
match = FFAMatch(
|
||||
match_id=match_id_counter,
|
||||
tournament_id=self._id,
|
||||
round_number=1,
|
||||
bracket=Bracket.FINAL,
|
||||
best_of=best_of,
|
||||
status=MatchStatus.WAITING,
|
||||
next_match_win_lose_ids=(None, None),
|
||||
match_has_ended_callback=self.match_has_ended_callback
|
||||
)
|
||||
|
||||
for participant in self.participants:
|
||||
match.assign_participant(participant.id, -1)
|
||||
|
||||
round_matches.append(match)
|
||||
rounds.append(round_matches)
|
||||
self._matches = [match]
|
||||
|
||||
elif bracket_type == "SINGLE":
|
||||
# --- single-elimination as before ---
|
||||
num_rounds = ceil(log2(num_participants))
|
||||
rounds: list[list[Match]] = []
|
||||
@ -304,16 +351,18 @@ class Tournament:
|
||||
match.check_completion()
|
||||
|
||||
|
||||
def generate_new_tournament(name: str, game_title: GameTitle, format_: TournamentFormat, start_time: datetime, initial_status: TournamentStatus = TournamentStatus.CLOSED) -> Tournament:
|
||||
def generate_new_tournament(name: str, description: str, game_title: GameTitle, format_: TournamentFormat, start_time: datetime, max_participants: int, initial_status: TournamentStatus = TournamentStatus.CLOSED) -> Tournament:
|
||||
id_ = uuid.uuid4().int
|
||||
return Tournament(
|
||||
id_,
|
||||
name,
|
||||
description,
|
||||
game_title,
|
||||
format_,
|
||||
start_time,
|
||||
initial_status,
|
||||
list(),
|
||||
None,
|
||||
list()
|
||||
list(),
|
||||
max_participants
|
||||
)
|
||||
|
||||
@ -7,7 +7,7 @@ class GameTitle:
|
||||
name: str
|
||||
description: str
|
||||
web_link: str
|
||||
|
||||
image_name: str # Name of the image in assets/img/games
|
||||
|
||||
class TournamentFormat(Enum):
|
||||
SINGLE_ELIMINATION_BO_1 = 1
|
||||
@ -16,6 +16,26 @@ class TournamentFormat(Enum):
|
||||
DOUBLE_ELIMINATION_BO_1 = 4
|
||||
DOUBLE_ELIMINATION_BO_3 = 5
|
||||
DOUBLE_ELIMINATION_BO_5 = 6
|
||||
FFA = 7
|
||||
|
||||
def tournament_format_to_display_texts(tournament_format: TournamentFormat) -> tuple[str, str]:
|
||||
""" Returns tuple where idx 0 is SE/DE/FFA string and idx 1 is match count """
|
||||
if tournament_format == TournamentFormat.SINGLE_ELIMINATION_BO_1:
|
||||
return "Single Elimination", "1"
|
||||
elif tournament_format == TournamentFormat.SINGLE_ELIMINATION_BO_3:
|
||||
return "Single Elimination", "3"
|
||||
elif tournament_format == TournamentFormat.SINGLE_ELIMINATION_BO_5:
|
||||
return "Single Elimination", "5"
|
||||
elif tournament_format == TournamentFormat.DOUBLE_ELIMINATION_BO_1:
|
||||
return "Double Elimination", "1"
|
||||
elif tournament_format == TournamentFormat.DOUBLE_ELIMINATION_BO_3:
|
||||
return "Double Elimination", "3"
|
||||
elif tournament_format == TournamentFormat.DOUBLE_ELIMINATION_BO_5:
|
||||
return "Double Elimination", "5"
|
||||
elif tournament_format == TournamentFormat.FFA:
|
||||
return "Free for All", "1"
|
||||
else:
|
||||
raise RuntimeError(f"Unknown tournament status: {str(tournament_format)}")
|
||||
|
||||
|
||||
class TournamentStatus(Enum):
|
||||
@ -26,6 +46,22 @@ class TournamentStatus(Enum):
|
||||
INVITE_ONLY = 5 # For Show-matches
|
||||
ONGOING = 6
|
||||
|
||||
def tournament_status_to_display_text(tournament_status: TournamentStatus) -> str:
|
||||
if tournament_status == TournamentStatus.OPEN:
|
||||
return "Offen"
|
||||
elif tournament_status == TournamentStatus.CLOSED:
|
||||
return "Geschlossen"
|
||||
elif tournament_status == TournamentStatus.ONGOING:
|
||||
return "Läuft"
|
||||
elif tournament_status == TournamentStatus.COMPLETED:
|
||||
return "Abgeschlossen"
|
||||
elif tournament_status == TournamentStatus.CANCELED:
|
||||
return "Abgesagt"
|
||||
elif tournament_status == TournamentStatus.INVITE_ONLY:
|
||||
return "Invite-only"
|
||||
else:
|
||||
raise RuntimeError(f"Unknown tournament status: {str(tournament_status)}")
|
||||
|
||||
|
||||
class TournamentError(Exception):
|
||||
def __init__(self, message: str) -> None:
|
||||
|
||||
@ -9,29 +9,30 @@ class TournamentDomainTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Generic Tournament config
|
||||
self.name = "Tetris 1vs1"
|
||||
self.game_title = GameTitle("Tetris99", "Some Description", "https://de.wikipedia.org/wiki/Tetris_99")
|
||||
self.description = "Just play Tetris, yo"
|
||||
self.game_title = GameTitle("Tetris99", "Some Description", "https://de.wikipedia.org/wiki/Tetris_99", "tetris.png")
|
||||
self.format_ = TournamentFormat.SINGLE_ELIMINATION_BO_3
|
||||
self.start_time = datetime(year=2100, month=6, day=23, hour=16, minute=30, second=0)
|
||||
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.game_title, self.format_, self.start_time, self.initial_status)
|
||||
tournament_under_test = generate_new_tournament(self.name, self.description, self.game_title, self.format_, self.start_time, 32, self.initial_status)
|
||||
with self.assertRaises(TournamentError):
|
||||
tournament_under_test.start()
|
||||
|
||||
def test_adding_the_same_participant_twice_leads_to_exception(self) -> None:
|
||||
tournament_under_test = generate_new_tournament(self.name, self.game_title, self.format_, self.start_time, self.initial_status)
|
||||
tournament_under_test = generate_new_tournament(self.name, self.description, self.game_title, self.format_, self.start_time, 32, self.initial_status)
|
||||
tournament_under_test.add_participant(self.participant_a)
|
||||
with self.assertRaises(TournamentError):
|
||||
tournament_under_test.add_participant(self.participant_a)
|
||||
|
||||
def test_single_elimination_bo3_tournament_gets_generated_correctly(self) -> None:
|
||||
tournament_under_test = generate_new_tournament(self.name, self.game_title, self.format_, self.start_time, self.initial_status)
|
||||
tournament_under_test = generate_new_tournament(self.name, self.description, self.game_title, self.format_, self.start_time, 32, self.initial_status)
|
||||
|
||||
tournament_under_test.add_participant(self.participant_a)
|
||||
tournament_under_test.add_participant(self.participant_b)
|
||||
@ -63,4 +64,19 @@ class TournamentDomainTests(unittest.TestCase):
|
||||
self.assertEqual(sm.status, MatchStatus.WAITING)
|
||||
self.assertEqual(sm.participants[0].participant_id, self.participant_c.id)
|
||||
self.assertEqual(sm.participants[0].slot_number, 1)
|
||||
self.assertIsNone(sm.winner)
|
||||
self.assertIsNone(sm.winner)
|
||||
|
||||
def test_ffa_tournament_with_15_participants_gets_generated_correctly(self) -> None:
|
||||
tournament_under_test = generate_new_tournament("Among Us", "It's Among Us", GameTitle("Among Us", "", "", ""), TournamentFormat.FFA, self.start_time, 32, TournamentStatus.OPEN)
|
||||
|
||||
for i in range(1, 16):
|
||||
tournament_under_test.add_participant(Participant(i, ParticipantType.PLAYER))
|
||||
tournament_under_test.start()
|
||||
|
||||
# Assert Tournament was switched to ONGOING
|
||||
self.assertEqual(TournamentStatus.ONGOING, tournament_under_test.status)
|
||||
|
||||
matches_in_tournament = sorted(tournament_under_test.matches, key=lambda m: m.match_id)
|
||||
|
||||
self.assertEqual(1, len(matches_in_tournament))
|
||||
self.assertEqual(15, len(matches_in_tournament[0].participants))
|
||||
|
||||