From f713443c201ea0d1692610bc758b2f98b4a3840e Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 4 Feb 2026 20:23:57 +0100 Subject: [PATCH 01/17] Add easteregg --- src/EzggLanManager.py | 5 ++ src/ezgg_lan_manager/pages/ConwayPage.py | 83 ++++++++++++++++++++++++ src/ezgg_lan_manager/pages/__init__.py | 1 + 3 files changed, 89 insertions(+) create mode 100644 src/ezgg_lan_manager/pages/ConwayPage.py diff --git a/src/EzggLanManager.py b/src/EzggLanManager.py index 0779eca..82d5146 100644 --- a/src/EzggLanManager.py +++ b/src/EzggLanManager.py @@ -171,6 +171,11 @@ if __name__ == "__main__": name="TournamentRulesPage", url_segment="tournament-rules", build=pages.TournamentRulesPage, + ), + ComponentPage( + name="ConwaysGameOfLife", + url_segment="conway", + build=pages.ConwayPage, ) ], theme=theme, diff --git a/src/ezgg_lan_manager/pages/ConwayPage.py b/src/ezgg_lan_manager/pages/ConwayPage.py new file mode 100644 index 0000000..fba7f3c --- /dev/null +++ b/src/ezgg_lan_manager/pages/ConwayPage.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from copy import deepcopy +from random import randint +from typing import * # type: ignore + +from rio import Component, event, Column, Row, Color, Rectangle + + + +class ConwayPage(Component): + """ + This is an Easter egg. + """ + + active_generation: list[list] = [] + rows: int = 36 + cols: int = 20 + + @event.periodic(1) + async def calc_next_gen(self) -> None: + self.create_next_grid() + + @event.on_populate + def prepare(self) -> None: + self.active_generation = self.create_initial_grid() + + def create_initial_grid(self) -> list[list]: + grid = [] + for row in range(self.rows): + grid_rows = [] + for col in range(self.cols): + if randint(0, 7) == 0: + grid_rows += [1] + else: + grid_rows += [0] + grid += [grid_rows] + return grid + + def create_next_grid(self) -> None: + next_grid = deepcopy(self.active_generation) + + for row in range(self.rows): + for col in range(self.cols): + live_neighbors = self.get_live_neighbors(row, col, self.active_generation) + + if live_neighbors < 2 or live_neighbors > 3: + next_grid[row][col] = 0 + elif live_neighbors == 3 and self.active_generation[row][col] == 0: + next_grid[row][col] = 1 + else: + next_grid[row][col] = self.active_generation[row][col] + + self.active_generation = next_grid + + def get_live_neighbors(self, row: int, col: int, grid: list[list]) -> int: + life_sum = 0 + for i in range(-1, 2): + for j in range(-1, 2): + if not (i == 0 and j == 0): + life_sum += grid[((row + i) % self.rows)][((col + j) % self.cols)] + return life_sum + + def grid_changing(self, next_grid: list[list]) -> bool: + for row in range(self.rows): + for col in range(self.cols): + if not self.active_generation[row][col] == next_grid[row][col]: + return True + return False + + def build(self) -> Component: + rows = [] + + for row in self.active_generation: + rectangles = [] + + for cell in row: + color = Color.WHITE if cell == 1 else Color.BLACK + rectangles.append(Rectangle(fill=color, transition_time=0.3)) + + rows.append(Row(*rectangles)) + + return Column(*rows) diff --git a/src/ezgg_lan_manager/pages/__init__.py b/src/ezgg_lan_manager/pages/__init__.py index d20bffc..8bb9e24 100644 --- a/src/ezgg_lan_manager/pages/__init__.py +++ b/src/ezgg_lan_manager/pages/__init__.py @@ -22,3 +22,4 @@ from .ManageTournamentsPage import ManageTournamentsPage from .OverviewPage import OverviewPage from .TournamentDetailsPage import TournamentDetailsPage from .TournamentRulesPage import TournamentRulesPage +from .ConwayPage import ConwayPage -- 2.45.2 From aa3691a59f799a3898ded912bd8f855388bf27f8 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 8 Feb 2026 01:26:55 +0100 Subject: [PATCH 02/17] Fix bug where users without ticket could register for tournament --- .../pages/TournamentDetailsPage.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/ezgg_lan_manager/pages/TournamentDetailsPage.py b/src/ezgg_lan_manager/pages/TournamentDetailsPage.py index 042d907..8e3f3e6 100644 --- a/src/ezgg_lan_manager/pages/TournamentDetailsPage.py +++ b/src/ezgg_lan_manager/pages/TournamentDetailsPage.py @@ -4,7 +4,7 @@ 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 import ConfigurationService, TournamentService, UserService, TicketingService 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 @@ -53,13 +53,18 @@ class TournamentDetailsPage(Component): 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: + user_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id) + if user_ticket is None: self.is_success = False - self.message = f"Fehler: {e}" + self.message = "Turnieranmeldung nur mit Ticket" + else: + 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() -- 2.45.2 From 9e86a7655e0ecd1f2c8ce61a835c9fbd506d658d Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 13 Jul 2025 23:37:17 +0200 Subject: [PATCH 03/17] upgrade rio to 0.11.2rc6 --- requirements.txt | Bin 218 -> 228 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index d2dea2e3f934aebec47d8fe918599e0abf17e341..2a749b4f5ef783a7645599d1596d7db63e3b2a89 100644 GIT binary patch delta 21 ccmcb`_=Isn9Je8Z9)l4>5koSA*~Iv207H%i!2kdN delta 11 ScmaFDc#CmD9HY_1%xVA|galUr -- 2.45.2 From 4e0139fef1ca50f1b5ebbf45a2be17b2a60c3fb1 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 4 Feb 2026 20:23:57 +0100 Subject: [PATCH 04/17] Add easteregg -- 2.45.2 From 00019a8c0da3c69d5b27d9767446cc034b3408ec Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 8 Feb 2026 01:26:55 +0100 Subject: [PATCH 05/17] Fix bug where users without ticket could register for tournament -- 2.45.2 From 124e1a1a0602c281a89722df3cfcb498016df512 Mon Sep 17 00:00:00 2001 From: tcprod Date: Sun, 8 Feb 2026 01:24:49 +0100 Subject: [PATCH 06/17] fix formatting balance bug in email fix formatting total balance bug in email --- src/ezgg_lan_manager/services/MailingService.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ezgg_lan_manager/services/MailingService.py b/src/ezgg_lan_manager/services/MailingService.py index 8636c68..cb3121f 100644 --- a/src/ezgg_lan_manager/services/MailingService.py +++ b/src/ezgg_lan_manager/services/MailingService.py @@ -45,7 +45,7 @@ class MailingService: return f""" Hallo {user.user_name}, - deinem Account wurden {added_balance} € hinzugefügt. Dein neues Guthaben beträgt nun {total_balance} €. + deinem Account wurden {added_balance:.2f} € hinzugefügt. Dein neues Guthaben beträgt nun {total_balance:.2f} €. Wenn du zu dieser Aufladung Fragen hast, stehen wir dir in unserem Discord Server oder per Mail an {self._configuration_service.get_lan_info().organizer_mail} zur Verfügung. -- 2.45.2 From e79118a0f432e9708a29372814c9e04c0380aa07 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 8 Feb 2026 01:42:42 +0100 Subject: [PATCH 07/17] bump to version 0.2.1 --- README.md | 3 ++- VERSION | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 60f6d00..9d70fae 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This repository contains the code for the EZGG LAN Manager. ### Step 1: Preparing Database -To prepare the database, apply the SQL file located in `sql/create_database.sql` to your database server. This is easily accomplished with the MYSQL Workbench, but it can be also done by pipeing the file into the mariadb-server executable. +To prepare the database, apply the SQL file located in `sql/create_database.sql` followed by `sql/tournament_patch.sql` to your database server. This is easily accomplished with the MYSQL Workbench, but it can be also done by pipeing the file into the mariadb-server executable. Optionally, you can now execute the script `create_demo_database_content.py`, found in `src/ezgg_lan_manager/helpers`. Be aware that it can be buggy sometimes, especially if you overwrite existing data. @@ -43,3 +43,4 @@ FLUSH PRIVILEGES; ``` 3. Make sure to **NOT** use the default passwords! 4. Apply the `create_database.sql` when starting the MariaDB container for the first time. +5. Apply the `tournament_patch.sql` when starting the MariaDB container for the first time. diff --git a/VERSION b/VERSION index 341cf11..7dff5b8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 \ No newline at end of file +0.2.1 \ No newline at end of file -- 2.45.2 From 914dd4eaa80a16154f5668b53335b6ae0f88d427 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 11 Feb 2026 08:48:39 +0000 Subject: [PATCH 08/17] Add Sponsoring section to navigation (#40) Co-authored-by: David Rodenkirchen Reviewed-on: https://git.ezgg-ev.de/Vereins-IT/ezgg-lan-manager/pulls/40 --- src/ezgg_lan_manager/assets/img/crackz.png | Bin 0 -> 58443 bytes .../components/DesktopNavigation.py | 11 ++++++--- src/ezgg_lan_manager/components/LoginBox.py | 4 +-- .../components/NavigationSponsorBox.py | 23 ++++++++++++++++++ .../components/UserInfoBox.py | 2 +- 5 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 src/ezgg_lan_manager/assets/img/crackz.png create mode 100644 src/ezgg_lan_manager/components/NavigationSponsorBox.py diff --git a/src/ezgg_lan_manager/assets/img/crackz.png b/src/ezgg_lan_manager/assets/img/crackz.png new file mode 100644 index 0000000000000000000000000000000000000000..7be58c58502d959dfdbb081b30851502a80a1add GIT binary patch literal 58443 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 X+J3j2(HM9VKLdlOtDnm{r-UW|pRk$0 literal 0 HcmV?d00001 diff --git a/src/ezgg_lan_manager/components/DesktopNavigation.py b/src/ezgg_lan_manager/components/DesktopNavigation.py index e828db7..20be550 100644 --- a/src/ezgg_lan_manager/components/DesktopNavigation.py +++ b/src/ezgg_lan_manager/components/DesktopNavigation.py @@ -5,6 +5,7 @@ from rio import * from src.ezgg_lan_manager import ConfigurationService, UserService, LocalDataService from src.ezgg_lan_manager.components.DesktopNavigationButton import DesktopNavigationButton +from src.ezgg_lan_manager.components.NavigationSponsorBox import NavigationSponsorBox from src.ezgg_lan_manager.components.UserInfoAndLoginBox import UserInfoAndLoginBox from src.ezgg_lan_manager.services.LocalDataService import LocalData from src.ezgg_lan_manager.types.SessionStorage import SessionStorage @@ -44,7 +45,7 @@ class DesktopNavigation(Component): self.force_login_box_refresh.append(user_info_and_login_box.force_refresh) user_navigation = [ DesktopNavigationButton("News", "./news"), - Spacer(min_height=1), + Spacer(min_height=0.7), DesktopNavigationButton(f"Über {lan_info.name} {lan_info.iteration}", "./overview"), DesktopNavigationButton("Ticket kaufen", "./buy_ticket"), DesktopNavigationButton("Sitzplan", "./seating"), @@ -53,12 +54,12 @@ class DesktopNavigation(Component): DesktopNavigationButton("Turniere", "./tournaments"), DesktopNavigationButton("FAQ", "./faq"), DesktopNavigationButton("Regeln & AGB", "./rules-gtc"), - Spacer(min_height=1), + Spacer(min_height=0.7), DesktopNavigationButton("Discord", "https://discord.gg/8gTjg34yyH", open_new_tab=True), DesktopNavigationButton("Die EZ GG e.V.", "https://ezgg-ev.de/about", open_new_tab=True), DesktopNavigationButton("Kontakt", "./contact"), DesktopNavigationButton("Impressum & DSGVO", "./imprint"), - Spacer(min_height=1) + Spacer(min_height=0.7) ] team_navigation = [ Text("Verwaltung", align_x=0.5, margin_top=0.3, style=TextStyle(fill=Color.from_hex("F0EADE"), font_size=1.2)), @@ -67,7 +68,7 @@ class DesktopNavigation(Component): DesktopNavigationButton("Benutzer", "./manage-users", is_team_navigation=True), DesktopNavigationButton("Catering", "./manage-catering", is_team_navigation=True), DesktopNavigationButton("Turniere", "./manage-tournaments", is_team_navigation=True), - Spacer(min_height=1), + Spacer(min_height=0.7), Revealer( header="Normale Navigation", content=Column(*user_navigation), @@ -83,6 +84,8 @@ class DesktopNavigation(Component): Text(f"Edition {lan_info.iteration}", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=1.2), margin_top=0.3, margin_bottom=2), user_info_and_login_box, *nav_to_use, + Text("Unsere Sponsoren", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=0.9), margin_bottom=0.5, margin_top=1), + NavigationSponsorBox(img_name="crackz", url="https://www.crackz.gg/"), align_y=0 ), color=self.session.theme.neutral_color, diff --git a/src/ezgg_lan_manager/components/LoginBox.py b/src/ezgg_lan_manager/components/LoginBox.py index 496b3e7..6a83129 100644 --- a/src/ezgg_lan_manager/components/LoginBox.py +++ b/src/ezgg_lan_manager/components/LoginBox.py @@ -102,5 +102,5 @@ class LoginBox(Component): min_width=12, align_x=0.5, margin_top=0.3, - margin_bottom=2 - ) \ No newline at end of file + margin_bottom=1.5 + ) diff --git a/src/ezgg_lan_manager/components/NavigationSponsorBox.py b/src/ezgg_lan_manager/components/NavigationSponsorBox.py new file mode 100644 index 0000000..63cb354 --- /dev/null +++ b/src/ezgg_lan_manager/components/NavigationSponsorBox.py @@ -0,0 +1,23 @@ +from from_root import from_root +from rio import Component, Link, Rectangle, Image, Color + + +class NavigationSponsorBox(Component): + img_name: str + url: str + img_suffix: str = "png" + + def build(self) -> Component: + return Link( + content=Rectangle( + content=Image(image=from_root(f"src/ezgg_lan_manager/assets/img/{self.img_name}.{self.img_suffix}"), min_width=10, min_height=10), + stroke_width=0.1, + stroke_color=Color.TRANSPARENT, + hover_stroke_width=0.1, + hover_stroke_color=self.session.theme.secondary_color, + margin=0.6, + cursor="pointer" + ), + target_url=self.url, + open_in_new_tab=True + ) diff --git a/src/ezgg_lan_manager/components/UserInfoBox.py b/src/ezgg_lan_manager/components/UserInfoBox.py index a1215c2..4db6d55 100644 --- a/src/ezgg_lan_manager/components/UserInfoBox.py +++ b/src/ezgg_lan_manager/components/UserInfoBox.py @@ -117,5 +117,5 @@ class UserInfoBox(Component): min_width=12, align_x=0.5, margin_top=0.3, - margin_bottom=2 + margin_bottom=1.5 ) -- 2.45.2 From d238153a223277c192f16fca5c1c2d81beb1acff Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 11 Feb 2026 23:23:49 +0100 Subject: [PATCH 09/17] Fix tournament register button not updating state correctly --- .../pages/TournamentDetailsPage.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/ezgg_lan_manager/pages/TournamentDetailsPage.py b/src/ezgg_lan_manager/pages/TournamentDetailsPage.py index 8e3f3e6..e481904 100644 --- a/src/ezgg_lan_manager/pages/TournamentDetailsPage.py +++ b/src/ezgg_lan_manager/pages/TournamentDetailsPage.py @@ -1,3 +1,4 @@ +from asyncio import sleep from typing import Optional, Union, Literal from from_root import from_root @@ -8,7 +9,6 @@ from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserSe 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 @@ -45,6 +45,14 @@ class TournamentDetailsPage(Component): self.loading_done() + @staticmethod + async def artificial_delay() -> None: + await sleep(0.8) # https://medium.com/design-bootcamp/ux-psychology-of-artificial-waiting-enhancing-user-experiences-through-deliberate-delays-d7822faf3930 + + 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) + def open_close_participant_revealer(self, _: PointerEvent) -> None: self.participant_revealer_open = not self.participant_revealer_open @@ -60,13 +68,14 @@ class TournamentDetailsPage(Component): 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}" + await self.update() self.loading = False - await self.on_populate() async def unregister_pressed(self) -> None: self.loading = True @@ -75,13 +84,14 @@ class TournamentDetailsPage(Component): try: await self.session[TournamentService].unregister_user_from_tournament(self.user.user_id, self.tournament.id) + await self.artificial_delay() self.is_success = True self.message = f"Erfolgreich abgemeldet!" except Exception as e: self.is_success = False self.message = f"Fehler: {e}" + await self.update() self.loading = False - await self.on_populate() async def tree_button_clicked(self) -> None: pass # ToDo: Implement tournament tree view @@ -203,7 +213,7 @@ class TournamentDetailsPage(Component): content=Rectangle( content=TournamentDetailsInfoRow( "Teilnehmer ▴" if self.participant_revealer_open else "Teilnehmer ▾", - f"{len(self.tournament.participants)} / {self.tournament.max_participants}", + f"{len(self.current_tournament_user_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 ), -- 2.45.2 From 27d1f60e2c5b7e8b27f0708075d438d79793d94a Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 11 Feb 2026 23:33:14 +0100 Subject: [PATCH 10/17] Release Version 0.2.2 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 7dff5b8..f477849 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.1 \ No newline at end of file +0.2.2 \ No newline at end of file -- 2.45.2 From 908bee1e7b323149e93e773ccad69061f1ee9c92 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 12 Feb 2026 09:28:42 +0100 Subject: [PATCH 11/17] update event description --- src/ezgg_lan_manager/pages/OverviewPage.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/ezgg_lan_manager/pages/OverviewPage.py b/src/ezgg_lan_manager/pages/OverviewPage.py index 8605132..39c5b3d 100644 --- a/src/ezgg_lan_manager/pages/OverviewPage.py +++ b/src/ezgg_lan_manager/pages/OverviewPage.py @@ -75,7 +75,7 @@ class OverviewPage(Component): Row( Text("Internet", fill=self.session.theme.neutral_color, margin_left=1), Spacer(), - Text(f"60/20 Mbit/s (down/up)", fill=self.session.theme.neutral_color, margin_right=1), + Text(f"100/50 Mbit/s (down/up)", fill=self.session.theme.neutral_color, margin_right=1), margin_bottom=0.3 ), Row( @@ -125,17 +125,5 @@ class OverviewPage(Component): ) ) ), - MainViewContentBox( - Column( - Text("Turniere & Ablauf", font_size=2, justify="center", fill=self.session.theme.neutral_color, margin_top=0.5, margin_bottom=1), - Column( - Row( - Text("Zum aktuellen Zeitpunkt steht noch nicht fest welche Turniere gespielt werden. Wir planen diverse Online- und Offline Turniere mit Preisen durchzuführen. Weitere Informationen gibt es, sobald sie kommen, auf der NEWS- und Turnier-Seite.", font_size=0.7, - fill=self.session.theme.neutral_color, margin_left=1, overflow="wrap"), - margin_bottom=0.3 - ) - ) - ) - ), Spacer() ) -- 2.45.2 From deec60347bbbf88f437a7e1888fc4e518474835c Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 15 Feb 2026 00:16:55 +0000 Subject: [PATCH 12/17] Add Teams (#45) Co-authored-by: David Rodenkirchen Reviewed-on: https://git.ezgg-ev.de/Vereins-IT/ezgg-lan-manager/pulls/45 --- README.md | 9 +- VERSION | 2 +- ...ment_patch.sql => 01-tournament_patch.sql} | 0 sql/02-teams_patch.sql | 63 +++++ src/EzggLanManager.py | 5 + src/ezgg_lan_manager/__init__.py | 6 +- .../components/DesktopNavigation.py | 3 +- .../components/TeamRevealer.py | 44 ++++ .../components/TeamsDialogHandler.py | 222 ++++++++++++++++++ src/ezgg_lan_manager/pages/BasePage.py | 12 +- src/ezgg_lan_manager/pages/TeamsPage.py | 172 ++++++++++++++ src/ezgg_lan_manager/pages/__init__.py | 1 + .../services/DatabaseService.py | 206 ++++++++++++++++ src/ezgg_lan_manager/services/TeamService.py | 134 +++++++++++ src/ezgg_lan_manager/types/Team.py | 29 +++ src/ezgg_lan_manager/types/User.py | 7 +- 16 files changed, 904 insertions(+), 11 deletions(-) rename sql/{tournament_patch.sql => 01-tournament_patch.sql} (100%) create mode 100644 sql/02-teams_patch.sql create mode 100644 src/ezgg_lan_manager/components/TeamRevealer.py create mode 100644 src/ezgg_lan_manager/components/TeamsDialogHandler.py create mode 100644 src/ezgg_lan_manager/pages/TeamsPage.py create mode 100644 src/ezgg_lan_manager/services/TeamService.py create mode 100644 src/ezgg_lan_manager/types/Team.py diff --git a/README.md b/README.md index 9d70fae..f2c20b9 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,16 @@ This repository contains the code for the EZGG LAN Manager. ### Prerequisites -- Working Installation of MySQL 5 or latest MariaDB Server (`mariadb-server` for Debian-based Linux, `XAMPP` for Windows) +- Working Installation of MariaDB Server (version `10.6.25` or later) + + MySQL should work too, but there are no guarantees. - Python 3.9 or higher - PyCharm or similar IDE (optional) ### Step 1: Preparing Database -To prepare the database, apply the SQL file located in `sql/create_database.sql` followed by `sql/tournament_patch.sql` to your database server. This is easily accomplished with the MYSQL Workbench, but it can be also done by pipeing the file into the mariadb-server executable. +To prepare the database, apply the SQL file located in `sql/create_database.sql` to your database server. This is easily accomplished with the MYSQL Workbench, but it can be also done by piping the file into the mariadb-server executable. + +After creating the database, apply all patches found in `sql/*_patch.sql` in their numeric order. Optionally, you can now execute the script `create_demo_database_content.py`, found in `src/ezgg_lan_manager/helpers`. Be aware that it can be buggy sometimes, especially if you overwrite existing data. @@ -43,4 +46,4 @@ FLUSH PRIVILEGES; ``` 3. Make sure to **NOT** use the default passwords! 4. Apply the `create_database.sql` when starting the MariaDB container for the first time. -5. Apply the `tournament_patch.sql` when starting the MariaDB container for the first time. +5. Apply the patches (`sql/*_patch.sql`) when starting the MariaDB container for the first time. diff --git a/VERSION b/VERSION index f477849..9325c3c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.2 \ No newline at end of file +0.3.0 \ No newline at end of file diff --git a/sql/tournament_patch.sql b/sql/01-tournament_patch.sql similarity index 100% rename from sql/tournament_patch.sql rename to sql/01-tournament_patch.sql diff --git a/sql/02-teams_patch.sql b/sql/02-teams_patch.sql new file mode 100644 index 0000000..16283ff --- /dev/null +++ b/sql/02-teams_patch.sql @@ -0,0 +1,63 @@ +-- ===================================================== +-- Teams +-- ===================================================== + +DROP TABLE IF EXISTS `team_members`; +DROP TABLE IF EXISTS `teams`; + + +-- ----------------------------------------------------- +-- Teams table +-- ----------------------------------------------------- +CREATE TABLE `teams` ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + abbreviation VARCHAR(10) NOT NULL, + join_password VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY uq_team_name (name), + UNIQUE KEY uq_team_abbr (abbreviation) + +) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; + + +-- ----------------------------------------------------- +-- Team Members (Junction Table) +-- ----------------------------------------------------- +CREATE TABLE `team_members` ( + team_id INT NOT NULL, + user_id INT NOT NULL, + + status ENUM('MEMBER','OFFICER','LEADER') + NOT NULL DEFAULT 'MEMBER', + + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (team_id, user_id), + + CONSTRAINT fk_tm_team + FOREIGN KEY (team_id) + REFERENCES teams(id) + ON DELETE CASCADE, + + CONSTRAINT fk_tm_user + FOREIGN KEY (user_id) + REFERENCES users(user_id) + ON DELETE CASCADE + +) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; + + +-- ----------------------------------------------------- +-- Indexes +-- ----------------------------------------------------- +CREATE INDEX idx_tm_user + ON team_members(user_id); + +CREATE INDEX idx_tm_team_status + ON team_members(team_id, status); diff --git a/src/EzggLanManager.py b/src/EzggLanManager.py index 82d5146..ce50bc8 100644 --- a/src/EzggLanManager.py +++ b/src/EzggLanManager.py @@ -172,6 +172,11 @@ if __name__ == "__main__": url_segment="tournament-rules", build=pages.TournamentRulesPage, ), + ComponentPage( + name="Teams", + url_segment="teams", + build=pages.TeamsPage, + ), ComponentPage( name="ConwaysGameOfLife", url_segment="conway", diff --git a/src/ezgg_lan_manager/__init__.py b/src/ezgg_lan_manager/__init__.py index f781029..b52831d 100644 --- a/src/ezgg_lan_manager/__init__.py +++ b/src/ezgg_lan_manager/__init__.py @@ -12,13 +12,14 @@ from src.ezgg_lan_manager.services.MailingService import MailingService 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.TeamService import TeamService 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, TournamentService]: +def init_services() -> tuple[AccountingService, CateringService, ConfigurationService, DatabaseService, MailingService, NewsService, SeatingService, TicketingService, UserService, LocalDataService, ReceiptPrintingService, TournamentService, TeamService]: logging.basicConfig(level=logging.DEBUG) configuration_service = ConfigurationService(from_root("config.toml")) db_service = DatabaseService(configuration_service.get_database_configuration()) @@ -32,6 +33,7 @@ def init_services() -> tuple[AccountingService, CateringService, ConfigurationSe catering_service = CateringService(db_service, accounting_service, user_service, receipt_printing_service) local_data_service = LocalDataService() tournament_service = TournamentService(db_service, user_service) + team_service = TeamService(db_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 + 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, team_service diff --git a/src/ezgg_lan_manager/components/DesktopNavigation.py b/src/ezgg_lan_manager/components/DesktopNavigation.py index 20be550..2a97964 100644 --- a/src/ezgg_lan_manager/components/DesktopNavigation.py +++ b/src/ezgg_lan_manager/components/DesktopNavigation.py @@ -51,14 +51,13 @@ class DesktopNavigation(Component): DesktopNavigationButton("Sitzplan", "./seating"), DesktopNavigationButton("Catering", "./catering"), DesktopNavigationButton("Teilnehmer", "./guests"), + DesktopNavigationButton("Teams", "./teams"), DesktopNavigationButton("Turniere", "./tournaments"), DesktopNavigationButton("FAQ", "./faq"), DesktopNavigationButton("Regeln & AGB", "./rules-gtc"), Spacer(min_height=0.7), DesktopNavigationButton("Discord", "https://discord.gg/8gTjg34yyH", open_new_tab=True), DesktopNavigationButton("Die EZ GG e.V.", "https://ezgg-ev.de/about", open_new_tab=True), - DesktopNavigationButton("Kontakt", "./contact"), - DesktopNavigationButton("Impressum & DSGVO", "./imprint"), Spacer(min_height=0.7) ] team_navigation = [ diff --git a/src/ezgg_lan_manager/components/TeamRevealer.py b/src/ezgg_lan_manager/components/TeamRevealer.py new file mode 100644 index 0000000..4c04701 --- /dev/null +++ b/src/ezgg_lan_manager/components/TeamRevealer.py @@ -0,0 +1,44 @@ +from functools import partial +from typing import Callable, Optional, Literal + +from rio import Component, Revealer, TextStyle, Column, Row, Tooltip, Icon, Spacer, Text, Button + +from src.ezgg_lan_manager.types.Team import TeamStatus, Team +from src.ezgg_lan_manager.types.User import User + + +class TeamRevealer(Component): + user: Optional[User] + team: Team + mode: Literal["join", "leave", "display"] + on_button_pressed: Callable + + def build(self) -> Component: + return Revealer( + header=self.team.name, + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + content=Column( + *[Row( + Tooltip( + anchor=Icon("material/star" if self.team.members[member] == TeamStatus.LEADER else "material/stat_1", fill=self.session.theme.hud_color), + tip="Leiter" if self.team.members[member] == TeamStatus.LEADER else "Mitglied", position="top"), + Text(member.user_name, style=TextStyle(fill=self.session.theme.background_color, font_size=1), margin_left=0.5), + Spacer(grow_y=False)) + for member in self.team.members + ], + Row(Button( + content=f"{self.team.name} beitreten" if self.mode == "join" else f"{self.team.name} verlassen", + shape="rectangle", + style="major", + color="hud", + on_press=partial(self.on_button_pressed, self.team), + ), margin_top=1, margin_bottom=1), + margin_right=1, + margin_left=1 + ), + margin_left=1, + margin_right=1, + ) diff --git a/src/ezgg_lan_manager/components/TeamsDialogHandler.py b/src/ezgg_lan_manager/components/TeamsDialogHandler.py new file mode 100644 index 0000000..10a9532 --- /dev/null +++ b/src/ezgg_lan_manager/components/TeamsDialogHandler.py @@ -0,0 +1,222 @@ +import logging +from typing import Optional, Callable + +from rio import Component, Text, Spacer, Rectangle, Column, TextStyle, Row, Button, TextInput, ThemeContextSwitcher + +from src.ezgg_lan_manager.services.TeamService import TeamService, NotMemberError, TeamLeadRemovalError, AlreadyMemberError, NameNotAllowedError, TeamNameTooLongError, \ + TeamAbbrInvalidError, TeamNameAlreadyTaken +from src.ezgg_lan_manager.types.Team import Team +from src.ezgg_lan_manager.types.User import User + +logger = logging.getLogger(__name__.split(".")[-1]) + + +class ErrorBox(Component): + error_message: str + cancel: Callable + + def build(self) -> Component: + return Rectangle( + content=Column( + Text(self.error_message, style=TextStyle(fill=self.session.theme.background_color), margin_bottom=1.5), + Row( + Button( + content="Ok", + shape="rectangle", + style="major", + color="hud", + on_press=self.cancel, + ) + ), + margin=1 + ), + fill=self.session.theme.primary_color + ) + + +class TeamsDialogJoinHandler(Component): + is_active: bool + cancel: Callable + user: Optional[User] = None + team: Optional[Team] = None + error_message: Optional[str] = None + password: str = "" + + async def join(self) -> None: + if self.user is None or self.team is None: + return + + if self.password != self.team.join_password: + self.error_message = "Falsches Passwort!" + return + + try: + await self.session[TeamService].add_member_to_team(self.team, self.user) + except AlreadyMemberError: + self.error_message = "Du bist bereits Mitglied dieses Teams" + else: + await self.cancel_with_reset() + + async def cancel_with_reset(self) -> None: + await self.cancel() + self.error_message = None + self.password = "" + + def build(self) -> Component: + if not self.is_active or self.user is None or self.team is None: + return Spacer() + + if self.error_message is not None: + return ErrorBox(error_message=self.error_message, cancel=self.cancel_with_reset) + + return Rectangle( + content=Column( + Text(f"Team {self.team.name} beitreten", style=TextStyle(fill=self.session.theme.background_color), margin_bottom=1, justify="center"), + ThemeContextSwitcher(content=TextInput(text=self.bind().password, label="Beitrittspasswort", margin_bottom=1), color="secondary"), + Row( + Button( + content="Abbrechen", + shape="rectangle", + style="major", + color=self.session.theme.danger_color, + on_press=self.cancel_with_reset, + ), + Button( + content="Beitreten", + shape="rectangle", + style="major", + color=self.session.theme.success_color, + on_press=self.join, + ), + spacing=1 + ), + margin=1 + ), + fill=self.session.theme.primary_color + ) + + +class TeamsDialogLeaveHandler(Component): + is_active: bool + cancel: Callable + user: Optional[User] = None + team: Optional[Team] = None + error_message: Optional[str] = None + + async def leave(self) -> None: + if self.user is not None and self.team is not None: + try: + await self.session[TeamService].remove_member_from_team(self.team, self.user) + except NotMemberError: + self.error_message = "Du bist kein Mitglied in diesem Team" + except TeamLeadRemovalError: + self.error_message = "Als Teamleiter kannst du das Team nicht verlassen" + else: + await self.cancel_with_reset() + + async def cancel_with_reset(self) -> None: + await self.cancel() + self.error_message = None + + def build(self) -> Component: + if not self.is_active or self.user is None or self.team is None: + return Spacer() + + if self.error_message is not None: + return ErrorBox(error_message=self.error_message, cancel=self.cancel_with_reset) + + return Rectangle( + content=Column( + Text(f"Team {self.team.name} wirklich verlassen?", style=TextStyle(fill=self.session.theme.background_color), margin_bottom=1.5, justify="center"), + Row( + Button( + content="Nein", + shape="rectangle", + style="major", + color=self.session.theme.danger_color, + on_press=self.cancel_with_reset, + ), + Button( + content="Ja", + shape="rectangle", + style="major", + color=self.session.theme.success_color, + on_press=self.leave, + ), + spacing=1 + ), + margin=1 + ), + fill=self.session.theme.primary_color + ) + + +class TeamsDialogCreateHandler(Component): + is_active: bool + cancel: Callable + user: Optional[User] = None + error_message: Optional[str] = None + team_name: str = "" + team_abbr: str = "" + team_join_password: str = "" + + async def cancel_with_reset(self) -> None: + await self.cancel() + self.error_message = None + self.team_name, self.team_abbr, self.team_join_password = "", "", "" + + async def create(self) -> None: + if self.user is None: + return + + if not self.team_name or not self.team_abbr or not self.team_join_password: + self.error_message = "Angaben unvollständig" + return + + try: + await self.session[TeamService].create_team(self.team_name, self.team_abbr, self.team_join_password, self.user) + except NameNotAllowedError as e: + self.error_message = f"Angaben ungültig. Darf kein '{e.disallowed_char}' enthalten." + except TeamNameTooLongError: + self.error_message = f"Name zu lang. Maximal {TeamService.MAX_TEAM_NAME_LENGTH} Zeichen." + except TeamAbbrInvalidError: + self.error_message = f"Name zu lang. Maximal {TeamService.MAX_TEAM_ABBR_LENGTH} Zeichen." + except TeamNameAlreadyTaken: + self.error_message = "Ein Team mit diesem Namen existiert bereits." + else: + await self.cancel_with_reset() + + def build(self) -> Component: + if not self.is_active or self.user is None: + return Spacer() + + if self.error_message is not None: + return ErrorBox(error_message=self.error_message, cancel=self.cancel_with_reset) + + return Rectangle( + content=Column( + Text(f"Team gründen", style=TextStyle(fill=self.session.theme.background_color), margin_bottom=1.5, justify="center"), + ThemeContextSwitcher(content=TextInput(text=self.bind().team_name, label="Team Name", margin_bottom=1), color="secondary"), + ThemeContextSwitcher(content=TextInput(text=self.bind().team_abbr, label="Team Abkürzung", margin_bottom=1), color="secondary"), + ThemeContextSwitcher(content=TextInput(text=self.bind().team_join_password, label="Beitrittspasswort", margin_bottom=1), color="secondary"), + Row( + Button( + content="Abbrechen", + shape="rectangle", + style="major", + color=self.session.theme.danger_color, + on_press=self.cancel_with_reset, + ), + Button( + content="Gründen", + shape="rectangle", + style="major", + color=self.session.theme.success_color, + on_press=self.create, + ), + spacing=1 + ), + margin=1 + ), + fill=self.session.theme.primary_color + ) diff --git a/src/ezgg_lan_manager/pages/BasePage.py b/src/ezgg_lan_manager/pages/BasePage.py index 91c6cf6..128aa94 100644 --- a/src/ezgg_lan_manager/pages/BasePage.py +++ b/src/ezgg_lan_manager/pages/BasePage.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import * # type: ignore -from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text, PageView, Button +from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text, PageView, Button, Link from src.ezgg_lan_manager import ConfigurationService, DatabaseService from src.ezgg_lan_manager.components.DesktopNavigation import DesktopNavigation @@ -58,7 +58,15 @@ class BasePage(Component): Row( Spacer(grow_x=True, grow_y=False), Card( - content=Text(f"EZGG LAN Manager Version {self.session[ConfigurationService].APP_VERSION} © EZ GG e.V.", align_x=0.5, align_y=0.5, fill=self.session.theme.primary_color, style=TextStyle(font_size=0.5)), + content=Row( + Text(f"EZGG LAN Manager Version {self.session[ConfigurationService].APP_VERSION} © EZ GG e.V.", fill=self.session.theme.primary_color, style=TextStyle(font_size=0.5)), + Text(f"-", fill=self.session.theme.primary_color, style=TextStyle(font_size=0.5), margin_left=0.5, margin_right=0.5), + Link(content=Text(f"Impressum & DSGVO", fill=self.session.theme.primary_color, style=TextStyle(font_size=0.5)), target_url="./imprint"), + Text(f"-", fill=self.session.theme.primary_color, style=TextStyle(font_size=0.5), margin_left=0.5, margin_right=0.5), + Link(content=Text(f"Kontakt", fill=self.session.theme.primary_color, style=TextStyle(font_size=0.5)), target_url="./contact"), + align_x=0.5, + align_y=0.5 + ), color=self.session.theme.neutral_color, corner_radius=(0, 0, 0.5, 0.5), grow_x=False, diff --git a/src/ezgg_lan_manager/pages/TeamsPage.py b/src/ezgg_lan_manager/pages/TeamsPage.py new file mode 100644 index 0000000..b6c94a6 --- /dev/null +++ b/src/ezgg_lan_manager/pages/TeamsPage.py @@ -0,0 +1,172 @@ +from asyncio import sleep + +from rio import event, ProgressCircle, PointerEventListener, PointerEvent, Popup, Color + +from src.ezgg_lan_manager import ConfigurationService +from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ezgg_lan_manager.components.TeamRevealer import TeamRevealer +from src.ezgg_lan_manager.components.TeamsDialogHandler import * +from src.ezgg_lan_manager.services.TeamService import TeamService +from src.ezgg_lan_manager.services.UserService import UserService +from src.ezgg_lan_manager.types.SessionStorage import SessionStorage +from src.ezgg_lan_manager.types.Team import Team +from src.ezgg_lan_manager.types.User import User + + +class TeamsPage(Component): + all_teams: Optional[list[Team]] = None + user: Optional[User] = None + + # Dialog handling + popup_open: bool = False + join_active: bool = False + leave_active: bool = True + create_active: bool = False + selected_team_for_join_or_leave: Optional[Team] = None + + @event.on_populate + async def on_populate(self) -> None: + self.all_teams = await self.session[TeamService].get_all_teams() + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Teams") + self.session[SessionStorage].subscribe_to_logged_in_or_out_event(str(self.__class__), self.on_populate) + + async def on_join_button_pressed(self, team: Team) -> None: + if self.user is None: + return + self.selected_team_for_join_or_leave = team + self.join_active, self.leave_active, self.create_active = True, False, False + self.popup_open = True + + async def on_leave_button_pressed(self, team: Team) -> None: + if self.user is None: + return + self.selected_team_for_join_or_leave = team + self.join_active, self.leave_active, self.create_active = False, True, False + self.popup_open = True + + async def on_create_button_pressed(self, _: PointerEvent) -> None: + if self.user is None: + return + self.join_active, self.leave_active, self.create_active = False, False, True + self.popup_open = True + + async def popup_action_cancelled(self) -> None: + self.popup_open = False + await sleep(0.2) # Waits for the animation to play before resetting its contents + self.join_active, self.leave_active, self.create_active = False, False, False + self.selected_team_for_join_or_leave = None + self.all_teams = await self.session[TeamService].get_all_teams() + + def build(self) -> Component: + if self.all_teams is None: + return Column( + MainViewContentBox( + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=1, + margin_bottom=1 + ) + ), + Spacer() + ) + + team_list = [] + for team in self.all_teams: + team_list.append( + TeamRevealer( + user=self.user, + team=team, + mode="leave" if self.user in team.members.keys() else "join", + on_button_pressed=self.on_leave_button_pressed if self.user in team.members.keys() else self.on_join_button_pressed + ) + ) + + if team_list: + team_list[-1].margin_bottom = 1 + + own_teams_content = Spacer(grow_x=False, grow_y=False) + if self.user is not None: + user_team_list = [] + for team in self.all_teams: + if self.user in team.members.keys(): + user_team_list.append(TeamRevealer(user=self.user, team=team, mode="leave", on_button_pressed=self.on_leave_button_pressed)) + + if not user_team_list: + user_team_list.append(Text( + text="Du bist noch in keinem Team.", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=1, + margin_bottom=1, + align_x=0.5 + )) + else: + user_team_list[-1].margin_bottom = 1 + own_teams_content = MainViewContentBox( + Column( + Row( + Text( + text="Deine Teams", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + grow_x=True, + justify="right", + margin_right=3 + ), + Column( + PointerEventListener(Rectangle( + content=Text(text="Team erstellen", style=TextStyle(fill=self.session.theme.background_color, font_size=0.7), margin=0.1, selectable=False), + stroke_width=0.1, + stroke_color=self.session.theme.hud_color, + cursor="pointer", + hover_fill=self.session.theme.hud_color, + transition_time=0 + ), on_press=self.on_create_button_pressed), + Spacer(), + margin_right=2 + ), + margin_top=1, + margin_bottom=1 + ), + *user_team_list + ) + ) + + return Popup( + anchor=Column( + own_teams_content, + MainViewContentBox( + Column( + Text( + text="Alle Teams", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=1, + margin_bottom=1, + align_x=0.5 + ), + *team_list + ) + ), + align_y=0, + ), + content=Column( + TeamsDialogJoinHandler(is_active=self.join_active, cancel=self.popup_action_cancelled, user=self.user, team=self.selected_team_for_join_or_leave), + TeamsDialogLeaveHandler(is_active=self.leave_active, cancel=self.popup_action_cancelled, user=self.user, team=self.selected_team_for_join_or_leave), + TeamsDialogCreateHandler(is_active=self.create_active, cancel=self.popup_action_cancelled, user=self.user) + ), + is_open=self.popup_open, + modal=False, + corner_radius=(0.5, 0.5, 0.5, 0.5), + color=Color.TRANSPARENT, + user_closable=False, + position="top" + ) diff --git a/src/ezgg_lan_manager/pages/__init__.py b/src/ezgg_lan_manager/pages/__init__.py index 8bb9e24..e682fb2 100644 --- a/src/ezgg_lan_manager/pages/__init__.py +++ b/src/ezgg_lan_manager/pages/__init__.py @@ -23,3 +23,4 @@ from .OverviewPage import OverviewPage from .TournamentDetailsPage import TournamentDetailsPage from .TournamentRulesPage import TournamentRulesPage from .ConwayPage import ConwayPage +from .TeamsPage import TeamsPage diff --git a/src/ezgg_lan_manager/services/DatabaseService.py b/src/ezgg_lan_manager/services/DatabaseService.py index ccbbfc1..efecffe 100644 --- a/src/ezgg_lan_manager/services/DatabaseService.py +++ b/src/ezgg_lan_manager/services/DatabaseService.py @@ -14,6 +14,7 @@ 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.Team import TeamStatus, Team 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 @@ -967,3 +968,208 @@ class DatabaseService: return await self.remove_participant_from_tournament(participant, tournament) except Exception as e: logger.warning(f"Error removing participant from tournament: {e}") + + async def get_teams(self) -> list[Team]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + query = """ + SELECT + t.id AS team_id, + t.name AS team_name, + t.abbreviation AS team_abbr, + t.join_password, + t.created_at AS team_created_at, + + tm.status AS team_status, + tm.joined_at AS member_joined_at, + + u.* + + FROM teams t + + LEFT JOIN team_members tm + ON t.id = tm.team_id + + LEFT JOIN users u + ON tm.user_id = u.user_id + + ORDER BY + t.id, + CASE tm.status + WHEN 'LEADER' THEN 1 + WHEN 'OFFICER' THEN 2 + WHEN 'MEMBER' THEN 3 + ELSE 4 + END, + u.user_name; + """ + try: + await cursor.execute(query) + 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_teams() + except Exception as e: + logger.warning(f"Error getting teams: {e}") + return [] + + current_team: Optional[Team] = None + all_teams = [] + + for row in await cursor.fetchall(): + if row[5] is None: # Teams without single member are ignored + continue + if current_team is None: + user = self._map_db_result_to_user(row[7:]) + current_team = Team(id=row[0], name=row[1], abbreviation=row[2], join_password=row[3], members={user: TeamStatus.from_str(row[5])}) + elif current_team.id == row[0]: # Still same team + current_team.members[self._map_db_result_to_user(row[7:])] = TeamStatus.from_str(row[5]) + else: + all_teams.append(current_team) + user = self._map_db_result_to_user(row[7:]) + current_team = Team(id=row[0], name=row[1], abbreviation=row[2], join_password=row[3], members={user: TeamStatus.from_str(row[5])}) + + all_teams.append(current_team) + + return all_teams + + async def get_team_by_id(self, team_id: int) -> Optional[Team]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + query = """ + SELECT + t.id AS team_id, + t.name AS team_name, + t.abbreviation AS team_abbr, + t.join_password, + t.created_at AS team_created_at, + + tm.status AS team_status, + tm.joined_at AS member_joined_at, + + u.* + + FROM teams t + + LEFT JOIN team_members tm + ON t.id = tm.team_id + + LEFT JOIN users u + ON tm.user_id = u.user_id + + WHERE t.id = %s + + ORDER BY + t.id, + CASE tm.status + WHEN 'LEADER' THEN 1 + WHEN 'OFFICER' THEN 2 + WHEN 'MEMBER' THEN 3 + ELSE 4 + END, + u.user_name; + """ + try: + await cursor.execute(query, (team_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.get_team_by_id(team_id) + except Exception as e: + logger.warning(f"Error getting team: {e}") + return None + + team: Optional[Team] = None + + for row in await cursor.fetchall(): + if team is None: + user = self._map_db_result_to_user(row[7:]) + team = Team(id=row[0], name=row[1], abbreviation=row[2], join_password=row[3], members={user: TeamStatus.from_str(row[5])}) + elif team.id == row[0]: + team.members[self._map_db_result_to_user(row[7:])] = TeamStatus.from_str(row[5]) + + return team + + async def create_team(self, team_name: str, team_abbr: str, join_password: str, leader: User) -> Team: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "INSERT INTO teams (name, abbreviation, join_password) " + "VALUES (%s, %s, %s)", (team_name, team_abbr, join_password) + ) + await conn.commit() + team_id = cursor.lastrowid + await cursor.execute( + "INSERT INTO team_members (team_id, user_id, status) VALUES (%s, %s, %s)", + (team_id, leader.user_id, TeamStatus.LEADER.name) + ) + await conn.commit() + return await self.get_team_by_id(team_id) + + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.create_team(team_name, team_abbr, join_password) + except aiomysql.IntegrityError as e: + logger.warning(f"Aborted duplication entry: {e}") + raise DuplicationError + + async def update_team(self, team: Team) -> Team: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "UPDATE teams SET name = %s, abbreviation = %s, join_password = %s WHERE (id = %s)", + (team.name, team.abbreviation, team.join_password, team.id) + ) + await conn.commit() + return await self.get_team_by_id(team.id) + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.update_team(team) + except aiomysql.IntegrityError as e: + logger.warning(f"Aborted duplication entry: {e}") + raise DuplicationError + + + async def add_member_to_team(self, team: Team, user: User, status: TeamStatus = TeamStatus.MEMBER) -> None: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "INSERT INTO team_members (team_id, user_id, status) VALUES (%s, %s, %s)", + (team.id, user.user_id, status.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_member_to_team(team, user, status) + except aiomysql.IntegrityError as e: + logger.warning(f"Failed to add member {user.user_name} to team {team.name}: {e}") + raise DuplicationError + + + async def remove_user_from_team(self, team: Team, user: User) -> None: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "DELETE FROM team_members WHERE team_id = %s AND user_id = %s", + (team.id, user.user_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_user_from_team(team, user) diff --git a/src/ezgg_lan_manager/services/TeamService.py b/src/ezgg_lan_manager/services/TeamService.py new file mode 100644 index 0000000..b9732fa --- /dev/null +++ b/src/ezgg_lan_manager/services/TeamService.py @@ -0,0 +1,134 @@ +from string import ascii_letters, digits +from typing import Optional + +from src.ezgg_lan_manager.services.DatabaseService import DatabaseService, DuplicationError +from src.ezgg_lan_manager.types.Team import TeamStatus, Team +from src.ezgg_lan_manager.types.User import User + + +class NameNotAllowedError(Exception): + def __init__(self, disallowed_char: str) -> None: + self.disallowed_char = disallowed_char + + +class AlreadyMemberError(Exception): + def __init__(self) -> None: + pass + + +class NotMemberError(Exception): + def __init__(self) -> None: + pass + + +class TeamLeadRemovalError(Exception): + def __init__(self) -> None: + pass + + +class TeamNameTooLongError(Exception): + def __init__(self) -> None: + pass + + +class TeamNameAlreadyTaken(Exception): + def __init__(self) -> None: + pass + + +class TeamAbbrInvalidError(Exception): + def __init__(self) -> None: + pass + + +class TeamService: + ALLOWED_TEAM_NAME_SYMBOLS = ascii_letters + digits + "!#$%&*+,-./:;<=>?[]^_{|}~ " + MAX_TEAM_NAME_LENGTH = 24 + MAX_TEAM_ABBR_LENGTH = 8 + + def __init__(self, db_service: DatabaseService) -> None: + self._db_service = db_service + + async def get_all_teams(self) -> list[Team]: + return await self._db_service.get_teams() + + async def get_team_by_id(self, team_id: int) -> Optional[Team]: + return await self._db_service.get_team_by_id(team_id) + + async def get_teams_for_user_by_id(self, user_id: int) -> list[Team]: + all_teams = await self.get_all_teams() + user_teams = [] + for team in all_teams: + if user_id in [u.user_id for u in team.members.keys()]: + user_teams.append(team) + return user_teams + + async def create_team(self, team_name: str, team_abbr: str, join_password: str, leader: User) -> Team: + disallowed_char = self._check_for_disallowed_char(team_name) + if disallowed_char: + raise NameNotAllowedError(disallowed_char) + disallowed_char = self._check_for_disallowed_char(team_abbr) + if disallowed_char: + raise NameNotAllowedError(disallowed_char) + + if not team_name or len(team_name) > self.MAX_TEAM_NAME_LENGTH: + raise TeamNameTooLongError() + + if not team_abbr or len(team_abbr) > self.MAX_TEAM_ABBR_LENGTH: + raise TeamAbbrInvalidError() + + try: + created_team = await self._db_service.create_team(team_name, team_abbr, join_password, leader) + except DuplicationError: + raise TeamNameAlreadyTaken + return created_team + + async def update_team(self, team: Team) -> Team: + """ + Updates the team EXCLUDING adding and removing members. This is to be done via add_member_to_team and remove_member_from_team + :param team: New instance of Team that is to be updated + :return: The modified Team instance + """ + disallowed_char = self._check_for_disallowed_char(team.name) + if disallowed_char: + raise NameNotAllowedError(disallowed_char) + disallowed_char = self._check_for_disallowed_char(team.abbreviation) + if disallowed_char: + raise NameNotAllowedError(disallowed_char) + + if not team.name or len(team.name) > self.MAX_TEAM_NAME_LENGTH: + raise TeamNameTooLongError() + + if not team.abbreviation or len(team.abbreviation) > self.MAX_TEAM_ABBR_LENGTH: + raise TeamAbbrInvalidError() + + return await self._db_service.update_team(team) + + async def add_member_to_team(self, team: Team, user: User, status: TeamStatus = TeamStatus.MEMBER) -> Team: + if user in team.members: + raise AlreadyMemberError() + + await self._db_service.add_member_to_team(team, user, status) + return await self.get_team_by_id(team.id) + + async def remove_member_from_team(self, team: Team, user: User) -> Team: + if user not in team.members: + raise NotMemberError() + + if team.members[user] == TeamStatus.LEADER: + raise TeamLeadRemovalError() + + await self._db_service.remove_user_from_team(team, user) + return await self.get_team_by_id(team.id) + + async def is_join_password_valid(self, team_id: int, join_password: str) -> bool: + team = await self.get_team_by_id(team_id) + if not team: + return False + return team.join_password == join_password + + def _check_for_disallowed_char(self, name: str) -> Optional[str]: + for c in name: + if c not in self.ALLOWED_TEAM_NAME_SYMBOLS: + return c + return None diff --git a/src/ezgg_lan_manager/types/Team.py b/src/ezgg_lan_manager/types/Team.py new file mode 100644 index 0000000..aed4454 --- /dev/null +++ b/src/ezgg_lan_manager/types/Team.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Self + +from src.ezgg_lan_manager.types.User import User + +class TeamStatus(Enum): + MEMBER = 0 + OFFICER = 1 + LEADER = 2 + + @classmethod + def from_str(cls, team_status: str) -> Self: + if team_status == "MEMBER": + return TeamStatus.MEMBER + elif team_status == "OFFICER": + return TeamStatus.OFFICER + elif team_status == "LEADER": + return TeamStatus.LEADER + raise ValueError + + +@dataclass(frozen=True) +class Team: + id: int + name: str + abbreviation: str + members: dict[User, TeamStatus] + join_password: str diff --git a/src/ezgg_lan_manager/types/User.py b/src/ezgg_lan_manager/types/User.py index a397962..d91f6d5 100644 --- a/src/ezgg_lan_manager/types/User.py +++ b/src/ezgg_lan_manager/types/User.py @@ -19,4 +19,9 @@ class User: last_updated_at: datetime def __hash__(self) -> int: - return hash(f"{self.user_id}{self.user_name}{self.user_mail}") + return hash(self.user_id) + + def __eq__(self, other): + if not isinstance(other, User): + return NotImplemented + return self.user_id == other.user_id -- 2.45.2 From 82fc0e87a859996c066ba29f8b9336a588d30ecb Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 16 Feb 2026 23:23:32 +0100 Subject: [PATCH 13/17] use rio 0.12 --- requirements.txt | Bin 228 -> 218 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 2a749b4f5ef783a7645599d1596d7db63e3b2a89..d2dea2e3f934aebec47d8fe918599e0abf17e341 100644 GIT binary patch delta 11 ScmaFDc#CmD9HY_1%xVA|galUr delta 21 ccmcb`_=Isn9Je8Z9)l4>5koSA*~Iv207H%i!2kdN -- 2.45.2 From a390e4bd10648b6cc5a7f18bcf599649c4ea14d8 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Fri, 20 Feb 2026 07:04:37 +0100 Subject: [PATCH 14/17] Bugfix: Trying to remove participant from empty tournament leads to crash --- .../pages/ManageTournamentsPage.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ezgg_lan_manager/pages/ManageTournamentsPage.py b/src/ezgg_lan_manager/pages/ManageTournamentsPage.py index b4dceb4..18a4c65 100644 --- a/src/ezgg_lan_manager/pages/ManageTournamentsPage.py +++ b/src/ezgg_lan_manager/pages/ManageTournamentsPage.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from functools import partial from typing import Optional from from_root import from_root @@ -40,7 +41,11 @@ class ManageTournamentsPage(Component): 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} + cancel_options = {next(filter(lambda u: u.user_id == p.id, users)).user_name: p for p in tournament.participants} + if cancel_options: + self.cancel_options = cancel_options + else: + self.cancel_options = {"": None} except StopIteration as e: logger.error(f"Error trying to find user for participant: {e}") self.tournament_id_selected_for_participant_removal = tournament_id @@ -70,13 +75,12 @@ class ManageTournamentsPage(Component): 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"), + Tooltip(anchor=IconButton("material/play_arrow", min_size=2, margin_right=0.5, on_press=partial(self.on_start_pressed, tournament.id)), tip="Starten"), + Tooltip(anchor=IconButton("material/cancel_schedule_send", min_size=2, margin_right=0.5, on_press=partial(self.on_cancel_pressed, tournament.id)), tip="Absagen"), + Tooltip(anchor=IconButton("material/person_cancel", min_size=2, on_press=partial(self.on_remove_participant_pressed, tournament.id)), tip="Spieler entfernen"), margin=1 ) ) - return Column( Popup( anchor=MainViewContentBox( -- 2.45.2 From 5b6c5d20761927cc06dbc00cc710e84667592901 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Fri, 20 Feb 2026 07:05:09 +0100 Subject: [PATCH 15/17] Deprecation Fix: CursorStyle is deprecated --- src/ezgg_lan_manager/pages/ManageUsersPage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ezgg_lan_manager/pages/ManageUsersPage.py b/src/ezgg_lan_manager/pages/ManageUsersPage.py index e02ce2e..c62896f 100644 --- a/src/ezgg_lan_manager/pages/ManageUsersPage.py +++ b/src/ezgg_lan_manager/pages/ManageUsersPage.py @@ -3,7 +3,7 @@ from dataclasses import field from typing import Optional from rio import Column, Component, event, TextStyle, Text, TextInput, ThemeContextSwitcher, Grid, \ - PointerEventListener, PointerEvent, Rectangle, CursorStyle, Color, TextInputChangeEvent, Spacer, Row, Switch, \ + PointerEventListener, PointerEvent, Rectangle, Color, TextInputChangeEvent, Spacer, Row, Switch, \ SwitchChangeEvent, EventHandler from src.ezgg_lan_manager import ConfigurationService, UserService, AccountingService, SeatingService, MailingService @@ -42,7 +42,7 @@ class ClickableGridContent(Component): grow_x=True ), fill=Color.TRANSPARENT, - cursor=CursorStyle.POINTER + cursor="pointer" ), on_pointer_enter=self.on_mouse_enter, on_pointer_leave=self.on_mouse_leave, -- 2.45.2 From d5b677ab687680bcb84f1fb30d0c70a4960b43c9 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Fri, 20 Feb 2026 07:06:16 +0100 Subject: [PATCH 16/17] Rework Team UI --- VERSION | 2 +- src/EzggLanManager.py | 6 +++ .../components/AdminNavigationCard.py | 36 +++++++++++++++ .../components/DesktopNavigation.py | 21 ++------- .../pages/AdminNavigationPage.py | 44 +++++++++++++++++++ src/ezgg_lan_manager/pages/__init__.py | 1 + 6 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 src/ezgg_lan_manager/components/AdminNavigationCard.py create mode 100644 src/ezgg_lan_manager/pages/AdminNavigationPage.py diff --git a/VERSION b/VERSION index 9325c3c..a2268e2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 \ No newline at end of file +0.3.1 \ No newline at end of file diff --git a/src/EzggLanManager.py b/src/EzggLanManager.py index ce50bc8..c6cc71f 100644 --- a/src/EzggLanManager.py +++ b/src/EzggLanManager.py @@ -157,6 +157,12 @@ if __name__ == "__main__": build=pages.ManageTournamentsPage, guard=team_guard ), + ComponentPage( + name="AdminNavigationPage", + url_segment="admin", + build=pages.AdminNavigationPage, + guard=team_guard + ), ComponentPage( name="DbErrorPage", url_segment="db-error", diff --git a/src/ezgg_lan_manager/components/AdminNavigationCard.py b/src/ezgg_lan_manager/components/AdminNavigationCard.py new file mode 100644 index 0000000..8a83ed2 --- /dev/null +++ b/src/ezgg_lan_manager/components/AdminNavigationCard.py @@ -0,0 +1,36 @@ +from rio import Component, Rectangle, Text, Column, Icon, TextStyle, PointerEventListener, PointerEvent + + +class AdminNavigationCard(Component): + icon_name: str + display_text: str + target_url: str + + def on_press(self, _: PointerEvent) -> None: + if self.target_url: + self.session.navigate_to(self.target_url) + + def build(self) -> Component: + return PointerEventListener( + Rectangle( + content=Column( + Icon( + self.icon_name, + min_width=3.5, + min_height=3.5, + fill="background", + margin=1 + ), + Text(self.display_text, style=TextStyle(fill=self.session.theme.background_color), justify="center", margin=1, margin_top=0) + ), + cursor="pointer", + stroke_width=0.2, + stroke_color=self.session.theme.background_color, + hover_stroke_width=0.2, + hover_stroke_color=self.session.theme.hud_color, + min_width=10, + min_height=10, + corner_radius=0.2 + ), + on_press=self.on_press + ) \ No newline at end of file diff --git a/src/ezgg_lan_manager/components/DesktopNavigation.py b/src/ezgg_lan_manager/components/DesktopNavigation.py index 2a97964..cb8b55d 100644 --- a/src/ezgg_lan_manager/components/DesktopNavigation.py +++ b/src/ezgg_lan_manager/components/DesktopNavigation.py @@ -43,7 +43,7 @@ class DesktopNavigation(Component): lan_info = self.session[ConfigurationService].get_lan_info() user_info_and_login_box = UserInfoAndLoginBox() self.force_login_box_refresh.append(user_info_and_login_box.force_refresh) - user_navigation = [ + navigation = [ DesktopNavigationButton("News", "./news"), Spacer(min_height=0.7), DesktopNavigationButton(f"Über {lan_info.name} {lan_info.iteration}", "./overview"), @@ -60,29 +60,16 @@ class DesktopNavigation(Component): DesktopNavigationButton("Die EZ GG e.V.", "https://ezgg-ev.de/about", open_new_tab=True), Spacer(min_height=0.7) ] - team_navigation = [ - Text("Verwaltung", align_x=0.5, margin_top=0.3, style=TextStyle(fill=Color.from_hex("F0EADE"), font_size=1.2)), - Text("Vorsichtig sein!", align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.danger_color, font_size=0.6)), - DesktopNavigationButton("News", "./manage-news", is_team_navigation=True), - DesktopNavigationButton("Benutzer", "./manage-users", is_team_navigation=True), - DesktopNavigationButton("Catering", "./manage-catering", is_team_navigation=True), - DesktopNavigationButton("Turniere", "./manage-tournaments", is_team_navigation=True), - Spacer(min_height=0.7), - Revealer( - header="Normale Navigation", - content=Column(*user_navigation), - header_style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9) - ) - ] if self.user is not None and self.user.is_team_member else [] - nav_to_use = copy(team_navigation) if self.user is not None and self.user.is_team_member else copy(user_navigation) + if self.user is not None and self.user.is_team_member: + navigation.insert(0, DesktopNavigationButton("Adminbereich", "./admin", is_team_navigation=True)) return Card( Column( Text(lan_info.name, align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.hud_color, font_size=1.9)), Text(f"Edition {lan_info.iteration}", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=1.2), margin_top=0.3, margin_bottom=2), user_info_and_login_box, - *nav_to_use, + *navigation, Text("Unsere Sponsoren", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=0.9), margin_bottom=0.5, margin_top=1), NavigationSponsorBox(img_name="crackz", url="https://www.crackz.gg/"), align_y=0 diff --git a/src/ezgg_lan_manager/pages/AdminNavigationPage.py b/src/ezgg_lan_manager/pages/AdminNavigationPage.py new file mode 100644 index 0000000..8ba07a0 --- /dev/null +++ b/src/ezgg_lan_manager/pages/AdminNavigationPage.py @@ -0,0 +1,44 @@ +from rio import Column, Component, event, Text, TextStyle, Row + +from src.ezgg_lan_manager.components.AdminNavigationCard import AdminNavigationCard +from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ezgg_lan_manager.services.ConfigurationService import ConfigurationService + + +class AdminNavigationPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Admin") + + def build(self) -> Component: + return Column( + MainViewContentBox( + Text( + text="Admin", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ) + ), + MainViewContentBox( + Column( + Row( + AdminNavigationCard(icon_name="material/supervised_user_circle", display_text="Nutzer", target_url="manage-users"), + AdminNavigationCard(icon_name="material/fastfood", display_text="Catering", target_url="manage-catering"), + spacing=1 + ), + Row( + AdminNavigationCard(icon_name="material/text_ad", display_text="News", target_url="manage-news"), + AdminNavigationCard(icon_name="material/trophy", display_text="Turniere", target_url="manage-tournaments"), + spacing=1 + ), + margin=1, + spacing=1 + ) + ), + align_y=0 + ) diff --git a/src/ezgg_lan_manager/pages/__init__.py b/src/ezgg_lan_manager/pages/__init__.py index e682fb2..e900b6f 100644 --- a/src/ezgg_lan_manager/pages/__init__.py +++ b/src/ezgg_lan_manager/pages/__init__.py @@ -24,3 +24,4 @@ from .TournamentDetailsPage import TournamentDetailsPage from .TournamentRulesPage import TournamentRulesPage from .ConwayPage import ConwayPage from .TeamsPage import TeamsPage +from .AdminNavigationPage import AdminNavigationPage -- 2.45.2 From d7df5161d4be452fd0443bdaabea94367c377505 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Fri, 20 Feb 2026 07:16:39 +0100 Subject: [PATCH 17/17] Feature: Inform user that he needs to be logged in to purchase a ticket --- VERSION | 2 +- src/ezgg_lan_manager/pages/BuyTicketPage.py | 32 +++++++++++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/VERSION b/VERSION index a2268e2..9fc80f9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.1 \ No newline at end of file +0.3.2 \ No newline at end of file diff --git a/src/ezgg_lan_manager/pages/BuyTicketPage.py b/src/ezgg_lan_manager/pages/BuyTicketPage.py index 9b01180..4ae6a6f 100644 --- a/src/ezgg_lan_manager/pages/BuyTicketPage.py +++ b/src/ezgg_lan_manager/pages/BuyTicketPage.py @@ -19,14 +19,18 @@ class BuyTicketPage(Component): popup_message: str = "" is_popup_success: bool = False is_buying_enabled: bool = False + is_user_logged_in: bool = False @event.on_populate async def on_populate(self) -> None: + self.session[SessionStorage].subscribe_to_logged_in_or_out_event(str(self.__class__), self.on_populate) await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Ticket kaufen") self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) if self.user is None: # No user logged in self.is_buying_enabled = False + self.is_user_logged_in = False else: # User is logged in + self.is_user_logged_in = True possible_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id) self.user_ticket = possible_ticket if possible_ticket is not None: # User already has a ticket @@ -67,17 +71,29 @@ class BuyTicketPage(Component): def build(self) -> Component: ticket_infos = self.session[ConfigurationService].get_ticket_info() - header = Text( - "Tickets & Preise", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 + header = Column( + Text( + "Tickets & Preise", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + align_x=0.5 ), - margin_top=2, - margin_bottom=2, - align_x=0.5 + spacing=0.2 ) + if not self.is_user_logged_in: + header.add(Text( + "Du musst eingeloggt sein\num ein Ticket zu kaufen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.6 + ), + align_x=0.5 + )) + return Column( MainViewContentBox( Column( -- 2.45.2