diff --git a/VERSION b/VERSION index 87a0871..448a0fa 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.3 \ No newline at end of file +0.3.4 \ No newline at end of file diff --git a/sql/03-tournament_teams_patch.sql b/sql/03-tournament_teams_patch.sql new file mode 100644 index 0000000..a7ba8a9 --- /dev/null +++ b/sql/03-tournament_teams_patch.sql @@ -0,0 +1,10 @@ +-- ===================================================== +-- Adds type of participant to tournament +-- ===================================================== + +ALTER TABLE `tournaments` ADD COLUMN `participant_type` ENUM('PLAYER','TEAM') NOT NULL DEFAULT 'PLAYER' AFTER `created_at`; + +ALTER TABLE `tournament_participants` + CHANGE COLUMN `user_id` `user_id` INT(11) NULL AFTER `tournament_id`, + ADD COLUMN `team_id` INT(11) NULL AFTER `user_id`, + ADD CONSTRAINT `fk_tp_team` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON UPDATE RESTRICT ON DELETE RESTRICT; diff --git a/src/ezgg_lan_manager/pages/TournamentDetailsPage.py b/src/ezgg_lan_manager/pages/TournamentDetailsPage.py index e481904..d852a30 100644 --- a/src/ezgg_lan_manager/pages/TournamentDetailsPage.py +++ b/src/ezgg_lan_manager/pages/TournamentDetailsPage.py @@ -1,27 +1,36 @@ +import logging from asyncio import sleep +from functools import partial 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 + PointerEvent, Rectangle, Color, Popup, Dropdown -from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService, TicketingService +from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService, TicketingService, TeamService 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.SessionStorage import SessionStorage +from src.ezgg_lan_manager.types.Team import Team, TeamStatus from src.ezgg_lan_manager.types.Tournament import Tournament -from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus, tournament_status_to_display_text, tournament_format_to_display_texts +from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus, tournament_status_to_display_text, tournament_format_to_display_texts, ParticipantType from src.ezgg_lan_manager.types.User import User +logger = logging.getLogger(__name__.split(".")[-1]) + class TournamentDetailsPage(Component): tournament: Optional[Union[Tournament, str]] = None rules_accepted: bool = False user: Optional[User] = None + user_teams: list[Team] = [] loading: bool = False participant_revealer_open: bool = False - current_tournament_user_list: list[User] = [] # ToDo: Integrate Teams + current_tournament_user_or_team_list: Union[list[User], list[Team]] = [] + team_dialog_open: bool = False + team_register_options: dict[str, Optional[Team]] = {"": None} + team_selected_for_register: Optional[Team] = None # State for message above register button message: str = "" @@ -37,11 +46,16 @@ class TournamentDetailsPage(Component): 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) + if self.tournament.participant_type == ParticipantType.PLAYER: + self.current_tournament_user_or_team_list = await self.session[TournamentService].get_users_from_participant_list(self.tournament.participants) + elif self.tournament.participant_type == ParticipantType.TEAM: + self.current_tournament_user_or_team_list = await self.session[TournamentService].get_teams_from_participant_list(self.tournament.participants) 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) + if self.user is not None: + self.user_teams = await self.session[TeamService].get_teams_for_user_by_id(self.user.user_id) self.loading_done() @@ -51,7 +65,12 @@ class TournamentDetailsPage(Component): async def update(self) -> None: self.tournament = await self.session[TournamentService].get_tournament_by_id(self.tournament.id) - self.current_tournament_user_list = await self.session[TournamentService].get_users_from_participant_list(self.tournament.participants) + if self.tournament is None: + return + if self.tournament.participant_type == ParticipantType.PLAYER: + self.current_tournament_user_or_team_list = await self.session[TournamentService].get_users_from_participant_list(self.tournament.participants) + elif self.tournament.participant_type == ParticipantType.TEAM: + self.current_tournament_user_or_team_list = await self.session[TournamentService].get_teams_from_participant_list(self.tournament.participants) def open_close_participant_revealer(self, _: PointerEvent) -> None: self.participant_revealer_open = not self.participant_revealer_open @@ -66,24 +85,73 @@ class TournamentDetailsPage(Component): self.is_success = False self.message = "Turnieranmeldung nur mit Ticket" else: - try: - await self.session[TournamentService].register_user_for_tournament(self.user.user_id, self.tournament.id) - await self.artificial_delay() - self.is_success = True - self.message = f"Erfolgreich angemeldet!" - except Exception as e: - self.is_success = False - self.message = f"Fehler: {e}" + # Register single player + if self.tournament.participant_type == ParticipantType.PLAYER: + try: + await self.session[TournamentService].register_user_for_tournament(self.user.user_id, self.tournament.id) + await self.artificial_delay() + self.is_success = True + self.message = f"Erfolgreich angemeldet!" + except Exception as e: + logger.error(e) + self.is_success = False + self.message = f"Fehler: {e}" + # Register team + elif self.tournament.participant_type == ParticipantType.TEAM: + try: + team_register_options = {"": None} + for team in self.user_teams: + if team.members[self.user] == TeamStatus.OFFICER or team.members[self.user] == TeamStatus.LEADER: + team_register_options[team.name] = team + if team_register_options: + self.team_register_options = team_register_options + else: + self.team_register_options = {"": None} + except StopIteration as e: + logger.error(f"Error trying to teams to register: {e}") + self.team_dialog_open = True + return # Model should handle loading state now + else: + pass await self.update() self.loading = False - async def unregister_pressed(self) -> None: + async def on_team_register_confirmed(self) -> None: + if self.team_selected_for_register is None: + await self.on_team_register_canceled() + return + try: + await self.session[TournamentService].register_team_for_tournament(self.team_selected_for_register.id, self.tournament.id) + await self.artificial_delay() + self.is_success = True + self.message = f"Erfolgreich angemeldet!" + self.team_dialog_open = False + self.team_selected_for_register = None + except Exception as e: + logger.error(e) + self.message = f"Fehler: {e}" + self.is_success = False + await self.update() + self.loading = False + + async def on_team_register_canceled(self) -> None: + self.team_dialog_open = False + self.team_selected_for_register = None + self.loading = False + + async def unregister_pressed(self, team: Optional[Team] = None) -> 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) + if self.tournament.participant_type == ParticipantType.PLAYER: + await self.session[TournamentService].unregister_user_from_tournament(self.user.user_id, self.tournament.id) + elif self.tournament.participant_type == ParticipantType.TEAM: + if team.members[self.user] == TeamStatus.OFFICER or team.members[self.user] == TeamStatus.LEADER: + await self.session[TournamentService].unregister_team_from_tournament(team.id, self.tournament.id) + else: + raise PermissionError("Nur Leiter und Offiziere können das Team abmelden") await self.artificial_delay() self.is_success = True self.message = f"Erfolgreich abgemeldet!" @@ -145,22 +213,47 @@ class TournamentDetailsPage(Component): 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 + if self.tournament.participant_type == ParticipantType.PLAYER: + self.current_tournament_user_or_team_list: list[User] # IDE TypeHint + participant_names = "\n".join([u.user_name for u in self.current_tournament_user_or_team_list]) + 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 + elif self.tournament.participant_type == ParticipantType.TEAM: + self.current_tournament_user_or_team_list: list[Team] # IDE TypeHint + participant_names = "\n".join([t.name for t in self.current_tournament_user_or_team_list]) + user_team_registered = [] + for team in self.user_teams: + if team.id in ids_of_participants: + user_team_registered.append(team) + if self.user and len(user_team_registered) > 0: # Any of the users teams already registered for tournament + button_text = f"{user_team_registered[0].abbreviation} abmelden" + button_sensitive_hook = True # User has already accepted the rules previously + color_key = "danger" + on_press_function = partial(self.unregister_pressed, user_team_registered[0]) + elif self.user and len(user_team_registered) == 0: + button_text = "Anmelden" + button_sensitive_hook = self.rules_accepted + else: + # This should NEVER happen + button_text = "Anmelden" + button_sensitive_hook = False else: - # This should NEVER happen - button_text = "Anmelden" - button_sensitive_hook = False + logger.fatal("Did someone add new values to ParticipantType ? ;)") + return Column() + 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 @@ -186,8 +279,6 @@ class TournamentDetailsPage(Component): # 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), @@ -213,7 +304,7 @@ class TournamentDetailsPage(Component): content=Rectangle( content=TournamentDetailsInfoRow( "Teilnehmer ▴" if self.participant_revealer_open else "Teilnehmer ▾", - f"{len(self.current_tournament_user_list)} / {self.tournament.max_participants}", + f"{len(self.current_tournament_user_or_team_list)} / {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 ), @@ -225,7 +316,7 @@ class TournamentDetailsPage(Component): Revealer( header=None, content=Text( - "\n".join([u.user_name for u in self.current_tournament_user_list]), # ToDo: Integrate Teams + participant_names, style=TextStyle(fill=self.session.theme.background_color) ), is_open=self.participant_revealer_open, @@ -255,6 +346,39 @@ class TournamentDetailsPage(Component): button ) + if self.tournament and self.tournament.participant_type == ParticipantType.TEAM: + content = Popup( + anchor=content, + content=Rectangle( + content=Column( + Text("Welches Team anmelden?", style=TextStyle(fill=self.session.theme.background_color, font_size=1.2), justify="center", margin_bottom=1), + ThemeContextSwitcher( + content=Dropdown( + options=self.team_register_options, + min_width=20, + selected_value=self.bind().team_selected_for_register + ), + color=self.session.theme.hud_color, + margin_bottom=1 + ), + Row( + Button(content="Abbrechen", shape="rectangle", grow_x=False, on_press=self.on_team_register_canceled), + Button(content="Anmelden", shape="rectangle", grow_x=False, on_press=self.on_team_register_confirmed), + spacing=1 + ), + 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.team_dialog_open, + color="none" + ) + return Column( MainViewContentBox( Column( diff --git a/src/ezgg_lan_manager/services/DatabaseService.py b/src/ezgg_lan_manager/services/DatabaseService.py index d6f46b8..bdb8b1b 100644 --- a/src/ezgg_lan_manager/services/DatabaseService.py +++ b/src/ezgg_lan_manager/services/DatabaseService.py @@ -859,6 +859,7 @@ class DatabaseService: t.status AS tournament_status, t.max_participants, t.created_at, + t.participant_type AS tournament_participant_type, /* ======================= Game Title @@ -874,6 +875,7 @@ class DatabaseService: ======================= */ tp.id AS participant_id, tp.user_id, + tp.team_id, tp.participant_type, tp.seed, tp.joined_at @@ -907,6 +909,8 @@ class DatabaseService: if current_tournament is None or current_tournament.id != row["tournament_id"]: if current_tournament is not None: tournaments.append(current_tournament) + participant_type = self._parse_participant_type(row["tournament_participant_type"]) + id_accessor = "user_id" if participant_type == ParticipantType.PLAYER else "team_id" current_tournament = Tournament( id_=row["tournament_id"], name=row["tournament_name"], @@ -920,14 +924,16 @@ class DatabaseService: 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 [], + participants=[Participant(id_=row[id_accessor], participant_type=self._parse_participant_type(row["participant_type"]))] if row[id_accessor] is not None else [], matches=None, # ToDo: Implement rounds=[], # ToDo: Implement - max_participants=row["max_participants"] + max_participants=row["max_participants"], + participant_type=participant_type ) else: + id_accessor = "user_id" if current_tournament.participant_type == ParticipantType.PLAYER else "team_id" current_tournament.add_participant( - Participant(id_=row["user_id"], participant_type=self._parse_participant_type(row["participant_type"])) + Participant(id_=row[id_accessor], participant_type=self._parse_participant_type(row["participant_type"])) ) else: tournaments.append(current_tournament) @@ -935,11 +941,14 @@ class DatabaseService: return tournaments async def add_participant_to_tournament(self, participant: Participant, tournament: Tournament) -> None: + if participant.participant_type != tournament.participant_type: + raise ValueError(f"Can not add {participant.participant_type.name} to {tournament.participant_type.name} tournament") + accessor = "user_id" if participant.participant_type == ParticipantType.PLAYER else "team_id" 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);", + f"INSERT INTO tournament_participants (tournament_id, {accessor}, participant_type) VALUES (%s, %s, %s);", (tournament.id, participant.id, participant.participant_type.name) ) await conn.commit() @@ -952,11 +961,12 @@ class DatabaseService: logger.warning(f"Error adding participant to tournament: {e}") async def remove_participant_from_tournament(self, participant: Participant, tournament: Tournament) -> None: + accessor = "user_id" if participant.participant_type == ParticipantType.PLAYER else "team_id" 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);", + f"DELETE FROM tournament_participants WHERE (tournament_id = %s AND {accessor} = %s);", (tournament.id, participant.id) ) await conn.commit() diff --git a/src/ezgg_lan_manager/services/TournamentService.py b/src/ezgg_lan_manager/services/TournamentService.py index 1a3f08b..393d7e3 100644 --- a/src/ezgg_lan_manager/services/TournamentService.py +++ b/src/ezgg_lan_manager/services/TournamentService.py @@ -3,6 +3,7 @@ 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.Team import Team 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 @@ -27,11 +28,24 @@ class TournamentService: tournament = await self.get_tournament_by_id(tournament_id) if not tournament: raise TournamentError(f"No tournament with ID {tournament_id} was found") + if tournament.participant_type != ParticipantType.PLAYER: + raise TournamentError(f"Can only add single player to team tournament, not {tournament.participant_type.name}") 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 register_team_for_tournament(self, team_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") + if tournament.participant_type != ParticipantType.TEAM: + raise TournamentError(f"Can only add team to team tournament, not {tournament.participant_type.name}") + participant = Participant(id_=team_id, participant_type=ParticipantType.TEAM) + 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: @@ -42,6 +56,16 @@ class TournamentService: await self._db_service.remove_participant_from_tournament(participant, tournament) self._cache_dirty = True + async def unregister_team_from_tournament(self, team_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 == team_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() @@ -57,6 +81,11 @@ class TournamentService: participant_ids = [p.id for p in participants] return list(filter(lambda u: u.user_id in participant_ids, all_users)) + async def get_teams_from_participant_list(self, participants: list[Participant]) -> list[Team]: + all_teams = await self._db_service.get_teams() + participant_ids = [p.id for p in participants] + return list(filter(lambda t: t.id in participant_ids, all_teams)) + async def start_tournament(self, tournament_id: int): tournament = await self.get_tournament_by_id(tournament_id) if tournament: diff --git a/src/ezgg_lan_manager/types/SessionStorage.py b/src/ezgg_lan_manager/types/SessionStorage.py index 206cdd2..b326b45 100644 --- a/src/ezgg_lan_manager/types/SessionStorage.py +++ b/src/ezgg_lan_manager/types/SessionStorage.py @@ -10,8 +10,8 @@ logger = logging.getLogger(__name__.split(".")[-1]) # Note for ToDo: rio.UserSettings are saved LOCALLY, do not just read a user_id here! @dataclass(frozen=False) class SessionStorage: - _user_id: Optional[int] = None # DEBUG: Put user ID here to skip login - _is_team_member: bool = False + _user_id: Optional[int] = 30 # DEBUG: Put user ID here to skip login + _is_team_member: bool = True _notification_callbacks: dict[str, Callable] = field(default_factory=dict) async def clear(self) -> None: diff --git a/src/ezgg_lan_manager/types/Team.py b/src/ezgg_lan_manager/types/Team.py index aed4454..f98a5f0 100644 --- a/src/ezgg_lan_manager/types/Team.py +++ b/src/ezgg_lan_manager/types/Team.py @@ -27,3 +27,11 @@ class Team: abbreviation: str members: dict[User, TeamStatus] join_password: str + + def __hash__(self) -> int: + return hash(self.id) + + def __eq__(self, other): + if not isinstance(other, Team): + return NotImplemented + return self.id == other.id diff --git a/src/ezgg_lan_manager/types/Tournament.py b/src/ezgg_lan_manager/types/Tournament.py index ea6ead1..e905135 100644 --- a/src/ezgg_lan_manager/types/Tournament.py +++ b/src/ezgg_lan_manager/types/Tournament.py @@ -5,7 +5,7 @@ from math import ceil, log2 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 +from src.ezgg_lan_manager.types.TournamentBase import GameTitle, TournamentFormat, TournamentStatus, TournamentError, Bracket, MatchStatus, ParticipantType class Tournament: @@ -20,7 +20,8 @@ class Tournament: participants: list[Participant], matches: Optional[tuple[Match]], rounds: list[list[Match]], - max_participants: int) -> None: + max_participants: int, + participant_type: ParticipantType) -> None: self._id = id_ self._name = name self._description = description @@ -32,6 +33,7 @@ class Tournament: self._matches = matches self._rounds = rounds self._max_participants = max_participants + self._participant_type = participant_type @property def id(self) -> int: @@ -85,6 +87,10 @@ class Tournament: def is_full(self) -> bool: return len(self._participants) >= self._max_participants + @property + def participant_type(self) -> ParticipantType: + return self._participant_type + 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")