Compare commits

..

3 Commits

Author SHA1 Message Date
tcprod
9cf6558892 fix password reset with fallbackpassword 2026-02-23 07:04:32 +00:00
d57f4baedd enable teams to register for tournaments (#53)
Co-authored-by: David Rodenkirchen <davidr.develop@gmail.com>
Reviewed-on: #53
2026-02-22 00:45:11 +00:00
David Rodenkirchen
f4db57b2ff Various smaller improvements 2026-02-21 23:39:43 +01:00
11 changed files with 237 additions and 55 deletions

View File

@ -1 +1 @@
0.3.2
0.3.4

View File

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

View File

@ -1,7 +1,6 @@
from typing import Callable
from rio import Component, Row, Text, TextStyle, Color, Rectangle, CursorStyle
from rio.components.pointer_event_listener import PointerEvent, PointerEventListener
from rio import Component, Row, Text, TextStyle, Color, Rectangle, PointerEventListener
from src.ezgg_lan_manager.types.CateringOrder import CateringOrderStatus, CateringOrder
@ -41,7 +40,7 @@ class CateringOrderItem(Component):
fill=self.session.theme.primary_color,
hover_fill=self.session.theme.hud_color,
transition_time=0.1,
cursor=CursorStyle.POINTER
cursor="pointer"
),
on_press=lambda _: self.info_modal_cb(self.order),
)

View File

@ -1,4 +1,3 @@
from copy import copy, deepcopy
from typing import Optional, Callable
from rio import *

View File

@ -1,8 +1,7 @@
from asyncio import sleep, create_task
from decimal import Decimal
import rio
from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer, Popup, Table, event
from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer, Popup, Table, event, Card
from src.ezgg_lan_manager.components.CateringCartItem import CateringCartItem
from src.ezgg_lan_manager.components.CateringOrderItem import CateringOrderItem
@ -97,7 +96,7 @@ class ShoppingCartAndOrders(Component):
show_popup_task = create_task(self.show_popup("Bestellung erfolgreich aufgegeben!", False))
async def _create_order_info_modal(self, order: CateringOrder) -> None:
def build_dialog_content() -> rio.Component:
def build_dialog_content() -> Component:
# @todo: rio 0.10.8 did not have the ability to align the columns, check back in a future version
table = Table(
{
@ -107,9 +106,9 @@ class ShoppingCartAndOrders(Component):
},
show_row_numbers=False
)
return rio.Card(
rio.Column(
rio.Text(
return Card(
Column(
Text(
f"Deine Bestellung ({order.order_id})",
align_x=0.5,
margin_bottom=0.5
@ -134,7 +133,7 @@ class ShoppingCartAndOrders(Component):
)
await dialog.wait_for_close()
def build(self) -> rio.Component:
def build(self) -> Component:
user_id = self.session[SessionStorage].user_id
catering_service = self.session[CateringService]
cart = catering_service.get_cart(user_id)
@ -155,7 +154,6 @@ class ShoppingCartAndOrders(Component):
margin=1
)
return Column(
cart_container,
Popup(
anchor=cart_container,
content=Text(self.popup_message, style=TextStyle(fill=self.session.theme.danger_color if self.popup_is_error else self.session.theme.success_color), overflow="wrap", margin=2, justify="center", min_width=20),

View File

@ -1,4 +1,4 @@
from typing import Optional, Callable
from typing import Optional
from rio import Column, Component, event, TextStyle, Text, Spacer, Revealer, SwitcherBar, SwitcherBarChangeEvent, ProgressCircle

View File

@ -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:
# 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:
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,10 +213,12 @@ 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.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
@ -161,6 +231,29 @@ class TournamentDetailsPage(Component):
# 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:
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(

View File

@ -1,7 +1,6 @@
import logging
from datetime import date, datetime
from pprint import pprint
from typing import Optional
from decimal import Decimal
@ -449,7 +448,7 @@ class DatabaseService:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.change_ticket_owner(ticket_id)
return await self.delete_ticket(ticket_id)
except Exception as e:
logger.warning(f"Error deleting ticket: {e}")
return False
@ -861,6 +860,7 @@ class DatabaseService:
t.status AS tournament_status,
t.max_participants,
t.created_at,
t.participant_type AS tournament_participant_type,
/* =======================
Game Title
@ -876,6 +876,7 @@ class DatabaseService:
======================= */
tp.id AS participant_id,
tp.user_id,
tp.team_id,
tp.participant_type,
tp.seed,
tp.joined_at
@ -909,6 +910,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"],
@ -922,14 +925,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)
@ -937,11 +942,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()
@ -954,11 +962,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()

View File

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

View File

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

View File

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