Compare commits

...

11 Commits

Author SHA1 Message Date
ead829d322 feature/137-improve-catering-ui (#68)
Co-authored-by: David Rodenkirchen <davidr.develop@gmail.com>
Reviewed-on: #68
2026-05-02 11:01:36 +00:00
David Rodenkirchen
285f9fa09f 0.4.0 -> 0.5.0 (POS Ordering) 2026-04-29 12:10:00 +02:00
David Rodenkirchen
61fd91d9f4 Add POS ordering 2026-04-29 12:09:39 +02:00
David Rodenkirchen
9e3d252634 Hotfix 0.4.0: Disable default account 2026-04-18 17:08:56 +02:00
David Rodenkirchen
46c6c84963 Hotfix 0.4.0: Add volume for tournament data 2026-04-18 16:44:52 +02:00
6666e79178 Enable Team Tournaments, add Tournament Trees, implement temporary tree persistance (#66)
Co-authored-by: David Rodenkirchen <drodenkirchen@linetco.com>
Reviewed-on: #66
2026-04-18 14:42:28 +00:00
David Rodenkirchen
c349fe475b Fix memory leaked caused by RefreshService 2026-04-17 09:33:19 +02:00
David Rodenkirchen
d5cd05c0e5 release 0.3.7 2026-04-16 08:51:21 +02:00
David Rodenkirchen
b8c1df5ff8 Disable commercial PayPal charging 2026-04-16 08:50:11 +02:00
8877de2cef Add EPC QR code to make bank transactions easier (#61)
See https://de.wikipedia.org/wiki/EPC-QR-Code#EPC-QR-Code_f%C3%BCr_%C3%9Cberweisung_erstellen for more information about the EPC coding

Co-authored-by: dusker <dusker@gmx.de>
Reviewed-on: #61
Co-authored-by: dusker <jens.graef+ezgg@posteo.de>
Co-committed-by: dusker <jens.graef+ezgg@posteo.de>
2026-04-16 06:48:46 +00:00
David Rodenkirchen
bd5c142bcf Fix logout not redirecting properly 2026-04-16 07:32:35 +02:00
27 changed files with 1173 additions and 142 deletions

View File

@ -1 +1 @@
0.3.6
0.5.1

View File

@ -31,6 +31,7 @@ services:
- database:/var/lib/mysql
- ./sql/create_database.sql:/docker-entrypoint-initdb.d/init.sql
- ./sql:/sql
- ./tournament_data:/opt/ezgg-lan-manager/tournament_data
volumes:

Binary file not shown.

View File

@ -1,14 +1,14 @@
import logging
from uuid import uuid4
import sys
from pathlib import Path
from uuid import uuid4
from rio import App, Theme, Color, Font, ComponentPage, Session
from from_root import from_root
from src.ezgg_lan_manager import pages, init_services, LocalDataService
from src.ezgg_lan_manager import pages, init_services, LocalDataService, RefreshService
from src.ezgg_lan_manager.helpers.LoggedInGuard import logged_in_guard, not_logged_in_guard, team_guard
from src.ezgg_lan_manager.services.LocalDataService import LocalData
from src.ezgg_lan_manager.types.UserSession import UserSession
@ -38,6 +38,7 @@ if __name__ == "__main__":
# Use this line to fake being any user without having to log in
# session.attach(UserSession(id=uuid4(), user_id=30, is_team_member=True))
await session.set_title(lan_info.name)
session.attach(RefreshService())
if session[LocalData].stored_session_token:
user_session = session[LocalDataService].verify_token(session[LocalData].stored_session_token)
if user_session is not None:
@ -155,6 +156,12 @@ if __name__ == "__main__":
build=pages.ManageCateringPage,
guard=team_guard
),
ComponentPage(
name="NewPosOrderPage",
url_segment="new-pos-order",
build=pages.NewPosOrderPage,
guard=team_guard
),
ComponentPage(
name="ManageTournamentsPage",
url_segment="manage-tournaments",
@ -177,6 +184,11 @@ if __name__ == "__main__":
url_segment="tournament",
build=pages.TournamentDetailsPage,
),
ComponentPage(
name="TournamentTreePage",
url_segment="tournament-tree",
build=pages.TournamentTreePage,
),
ComponentPage(
name="TournamentRulesPage",
url_segment="tournament-rules",
@ -216,7 +228,14 @@ if __name__ == "__main__":
}
)
sys.exit(app.run_as_web_server(
host="0.0.0.0",
port=8000,
))
try:
app.run_as_web_server(
host="0.0.0.0",
port=8000,
)
except (KeyboardInterrupt, SystemExit):
logger.info("EZGG LAN Manager was shut down.")
sys.exit(0)
except Exception as e:
logger.error(e)
sys.exit(1)

View File

@ -20,7 +20,7 @@ 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, TeamService, RefreshService]:
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())
@ -37,4 +37,4 @@ def init_services() -> tuple[AccountingService, CateringService, ConfigurationSe
team_service = TeamService(db_service)
refresh_service = RefreshService()
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, refresh_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

View File

@ -1,29 +1,30 @@
import logging
from asyncio import create_task, sleep
from functools import partial
from typing import Optional, Callable
from rio import Component, Row, Card, Column, Text, TextStyle, Spacer, PointerEventListener, Button
from rio import Component, Row, Card, Column, Text, TextStyle, Spacer, PointerEventListener, Button, Rectangle, Popup, Icon, Color
from src.ezgg_lan_manager import ReceiptPrintingService
from src.ezgg_lan_manager.components.StatusChangePopup import StatusChangePopup
from src.ezgg_lan_manager.services.CateringService import CateringService
from src.ezgg_lan_manager.types.CateringOrder import CateringOrder, CateringOrderStatus
from src.ezgg_lan_manager.types.Seat import Seat
class CateringManagementOrderDisplayStatusButton(Component):
status: CateringOrderStatus
clicked_cb: Callable
def build(self) -> Component:
return Button(
content=Text(
CateringOrder.translate_order_status(self.status)
),
shape="rectangle",
on_press=partial(self.clicked_cb, self.status)
)
logger = logging.getLogger(__name__.split(".")[-1])
class CateringManagementOrderDisplay(Component):
order: CateringOrder
seat: Optional[Seat]
clicked_cb: Callable
status_change_popup_open: bool = False
def reprint_order(self) -> None:
create_task(self.session[ReceiptPrintingService].print_order(self.order.customer, self.order))
def open_status_change_popup(self) -> None:
self.status_change_popup_open = True
def format_order_status(self, status: CateringOrderStatus) -> Text:
status_text = CateringOrder.translate_order_status(status)
@ -36,14 +37,12 @@ class CateringManagementOrderDisplay(Component):
return Text(text=status_text, style=TextStyle(fill=color))
async def status_button_clicked(self, new_status: CateringOrderStatus) -> None:
if self.order.status == CateringOrderStatus.CANCELED:
return
async def change_status(self, new_status: CateringOrderStatus) -> Optional[str]:
await sleep(1)
if new_status == CateringOrderStatus.CANCELED:
# ToDo: Hier sollten wir nochmal nachfragen ob der Bediener sich wirklich sicher ist,
# und anwarnen das eine stornierte Bestellung nicht ent-storniert werden kann.
pass
logger.debug(f"Status of order with ID {self.order.order_id} changing from {self.order.status} to {new_status}")
if self.order.status == CateringOrderStatus.CANCELED: # Can not un-cancel
return "Stornierte Bestellungen können nicht angepasst werden"
if self.order.status != new_status:
if new_status == CateringOrderStatus.CANCELED:
@ -61,43 +60,58 @@ class CateringManagementOrderDisplay(Component):
is_delivery=self.order.is_delivery
)
self.status_change_popup_open = False
def build(self) -> Component:
return PointerEventListener(
content=Card(
content=Column(
Row(
Text(f"ID: {self.order.order_id}", margin_left=0.3, margin_top=0.2, justify="center", style=TextStyle(font_size=1.2)),
),
Row(
Text(f"Status: ", margin_left=0.3, margin_top=0.2),
self.format_order_status(self.order.status),
Spacer(),
Text(self.order.order_date.strftime("%d.%m. - %H:%M Uhr"), margin_right=0.3),
),
Row(
Text(f"Gast: {self.order.customer.user_name}", margin_left=0.3),
Spacer(),
Text(f"Sitzplatz: {'-' if not self.seat else self.seat.seat_id}", margin_right=0.3),
),
Row(
Text("Diese Bestellung wird:", margin_left=0.3, margin_bottom=0.5),
Spacer(),
Text("Geliefert" if self.order.is_delivery else "Abgeholt", margin_right=0.3, margin_bottom=0.5),
),
Row(
CateringManagementOrderDisplayStatusButton(CateringOrderStatus.RECEIVED, self.status_button_clicked),
CateringManagementOrderDisplayStatusButton(CateringOrderStatus.CANCELED, self.status_button_clicked),
CateringManagementOrderDisplayStatusButton(CateringOrderStatus.EN_ROUTE, self.status_button_clicked)
),
Row(
CateringManagementOrderDisplayStatusButton(CateringOrderStatus.READY_FOR_PICKUP, self.status_button_clicked),
CateringManagementOrderDisplayStatusButton(CateringOrderStatus.COMPLETED, self.status_button_clicked),
CateringManagementOrderDisplayStatusButton(CateringOrderStatus.DELAYED, self.status_button_clicked),
)
card = Card(
content=Column(
Row(
Text(f"ID: {self.order.order_id}", margin_left=0.3, margin_top=0.2, justify="center", style=TextStyle(font_size=1.2)),
),
color=self.session.theme.hud_color,
colorize_on_hover=True,
margin=1
Row(
Text(f"Status: ", margin_left=0.3, margin_top=0.2),
self.format_order_status(self.order.status),
Spacer(),
Text(self.order.order_date.strftime("%d.%m. - %H:%M Uhr"), margin_right=0.3),
),
Row(
Text(f"Gast: {self.order.customer.user_name}", margin_left=0.3),
Spacer(),
Text(f"Sitzplatz: {'-' if not self.seat else self.seat.seat_id}", margin_right=0.3),
),
Row(
Text("Diese Bestellung wird:", margin_left=0.3, margin_bottom=0.5),
Spacer(),
Text("Geliefert" if self.order.is_delivery else "Abgeholt", margin_right=0.3, margin_bottom=0.5),
),
Row(
Rectangle(
content=Button(
content=Text("Beleg drucken", justify="left"),
shape="rectangle",
on_press=self.reprint_order
),
stroke_width=0.1
),
Rectangle(
content=Button(
content=Text("Status ändern", justify="right"),
shape="rectangle",
on_press=self.open_status_change_popup
),
stroke_width=0.1
),
)
),
color=self.session.theme.hud_color,
colorize_on_hover=True,
margin=1
)
status_change_popup = StatusChangePopup(card, self.status_change_popup_open, self.change_status)
return PointerEventListener(
content=status_change_popup,
on_press=partial(self.clicked_cb, self.order)
)

View File

@ -0,0 +1,92 @@
from functools import partial
from typing import Callable, Optional
from rio import Column, Row, Text, Button, Component, Icon, Popup, Rectangle, Color, Tooltip, PointerEventListener, PointerEvent, ProgressCircle
from src.ezgg_lan_manager.types.CateringOrder import CateringOrderStatus, CateringOrder
ICONS_BY_STATUS = {
CateringOrderStatus.RECEIVED: "material/move_to_inbox",
CateringOrderStatus.DELAYED: "material/hourglass_top",
CateringOrderStatus.READY_FOR_PICKUP: "material/takeout_dining",
CateringOrderStatus.EN_ROUTE: "material/local_shipping",
CateringOrderStatus.COMPLETED: "material/check_circle",
CateringOrderStatus.CANCELED: "material/cancel",
}
class StatusChangeButton(Component):
status: CateringOrderStatus
clicked_cb: Callable
def build(self) -> Component:
return Tooltip(
anchor=PointerEventListener(
content=Rectangle(
fill=Color.TRANSPARENT,
content=Column(
Icon(
icon=ICONS_BY_STATUS[self.status]
)
),
stroke_width=0.1,
stroke_color=Color.TRANSPARENT,
hover_stroke_width=0.1,
hover_stroke_color=Color.BLACK
),
on_press=partial(self.clicked_cb, self.status)
),
tip=Text(text=CateringOrder.translate_order_status(self.status)),
position="top"
)
class StatusChangePopup(Component):
anchor: Component
popup_open: bool
status_should_change_cb: Callable
response: Optional[str] = None
is_loading: bool = False
async def handle_button_clicked(self, status: CateringOrderStatus, _: PointerEvent) -> None:
self.is_loading = True
self.response = await self.status_should_change_cb(status)
self.is_loading = False
def close(self) -> None:
self.popup_open = False
def build(self) -> Component:
if self.is_loading:
content = Row(
ProgressCircle(margin=1)
)
elif self.response:
content = Row(
Text(text=self.response, justify="center", overflow="wrap", margin=1)
)
else:
content = Row(
StatusChangeButton(CateringOrderStatus.RECEIVED, self.handle_button_clicked),
StatusChangeButton(CateringOrderStatus.DELAYED, self.handle_button_clicked),
StatusChangeButton(CateringOrderStatus.READY_FOR_PICKUP, self.handle_button_clicked),
StatusChangeButton(CateringOrderStatus.EN_ROUTE, self.handle_button_clicked),
StatusChangeButton(CateringOrderStatus.COMPLETED, self.handle_button_clicked),
StatusChangeButton(CateringOrderStatus.CANCELED, self.handle_button_clicked),
spacing=0.5,
margin=0.5
)
return Popup(
anchor=self.anchor,
content=Rectangle(
content=Column(
content,
Button(content=Text(text="Abbrechen", justify="center", fill=self.session.theme.secondary_color), shape="rectangle", style="colored-text", on_press=self.close),
proportions=[2.5, 1]
),
fill=self.session.theme.hud_color,
min_width=34,
min_height=8.3
),
is_open=self.popup_open
)

View File

@ -60,15 +60,18 @@ class UserInfoBox(Component):
self.session[LocalDataService].del_session(self.session[LocalData].stored_session_token)
self.session[LocalData].stored_session_token = None
self.session.attach(self.session[LocalData])
await self.status_change_cb()
if self.status_change_cb is not None:
await self.status_change_cb()
await self.session[RefreshService].trigger_refresh()
self.session.navigate_to("")
@event.on_populate
async def async_init(self) -> None:
self.user = await self.session[UserService].get_user(self.user_id)
self.user_balance = await self.session[AccountingService].get_balance(self.user.user_id)
self.user_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id)
self.user_seat = await self.session[SeatingService].get_user_seat(self.user.user_id)
if self.user is not None:
self.user_balance = await self.session[AccountingService].get_balance(self.user.user_id)
self.user_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id)
self.user_seat = await self.session[SeatingService].get_user_seat(self.user.user_id)
self.session[AccountingService].add_update_hook(self.update)
async def update(self) -> None:

View File

@ -1,7 +1,7 @@
from decimal import Decimal
from typing import Optional
from rio import Column, Component, event, Text, TextStyle, Button, Color, Revealer, Row, ProgressCircle, Link
from rio import Column, Component, event, Text, TextStyle, Button, Color, Revealer, Row, ProgressCircle, Link, Image
from src.ezgg_lan_manager import ConfigurationService, UserService, AccountingService
from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox
@ -14,6 +14,7 @@ class AccountPage(Component):
user: Optional[User] = None
balance: Optional[Decimal] = None
transaction_history: list[Transaction] = list()
payment_qr_image: bytes = None
banking_info_revealer_open: bool = False
paypal_info_revealer_open: bool = False
@ -28,6 +29,11 @@ class AccountPage(Component):
self.user = await self.session[UserService].get_user(user_id)
self.balance = await self.session[AccountingService].get_balance(user_id)
self.transaction_history = await self.session[AccountingService].get_transaction_history(user_id)
self.payment_qr_image = self.session[AccountingService].make_payment_qr_image(
"Einfach Zocken Gaming Gesellschaft",
"GENODE51BIK",
"DE47517624340019856607",
f"AUFLADUNG - {self.user.user_id} - {self.user.user_name}")
async def _on_banking_info_press(self) -> None:
self.banking_info_revealer_open = not self.banking_info_revealer_open
@ -36,7 +42,7 @@ class AccountPage(Component):
self.paypal_info_revealer_open = not self.paypal_info_revealer_open
def build(self) -> Component:
if not self.user and not self.balance:
if not self.user or not self.payment_qr_image:
return Column(
MainViewContentBox(
ProgressCircle(
@ -85,6 +91,10 @@ class AccountPage(Component):
margin=0,
margin_bottom=1,
align_x=0.5
),
Image(self.payment_qr_image,
min_width=20,
min_height=20
)
),
margin=2,
@ -223,19 +233,20 @@ class AccountPage(Component):
on_press=self._on_paypal_info_press
),
paypal_info_revealer,
Link(
content=Button(
content=Text("PAYPAL (3% Gebühr - Gewerblich)", style=TextStyle(fill=Color.from_hex("121212"), font_size=0.8), justify="center"),
shape="rectangle",
style="major",
color="secondary",
grow_x=True,
margin=2,
margin_top=0
),
target_url="https://www.paypal.com/ncp/payment/89YMGVZ4S33RS",
open_in_new_tab=True
)
# Disabled because people did not understand the fee's and kept charging 24.03 € to their accounts
# Link(
# content=Button(
# content=Text("PAYPAL (3% Gebühr - Gewerblich)", style=TextStyle(fill=Color.from_hex("121212"), font_size=0.8), justify="center"),
# shape="rectangle",
# style="major",
# color="secondary",
# grow_x=True,
# margin=2,
# margin_top=0
# ),
# target_url="https://www.paypal.com/ncp/payment/89YMGVZ4S33RS",
# open_in_new_tab=True
# )
)
),
MainViewContentBox(

View File

@ -18,7 +18,6 @@ class CateringPage(Component):
@event.on_populate
async def on_populate(self) -> None:
self.session[RefreshService].subscribe(self.on_populate)
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Catering")
self.all_menu_items = await self.session[CateringService].get_menu()

View File

@ -3,7 +3,7 @@ from dataclasses import field, dataclass
from datetime import datetime
from typing import Optional, Callable
from rio import Column, Component, event, TextStyle, Text, Spacer, PointerEvent, Button, Popup, Card, Row
from rio import Column, Component, event, TextStyle, Text, Spacer, PointerEvent, Button, Popup, Card, Row, Rectangle, Color, PointerEventListener
from src.ezgg_lan_manager import ConfigurationService, CateringService, SeatingService, AccountingService
from src.ezgg_lan_manager.components.CateringManagementOrderDisplay import CateringManagementOrderDisplay
@ -106,6 +106,7 @@ class ManageCateringPage(Component):
return sorted_list
async def order_clicked(self, order: CateringOrder, _: PointerEvent) -> None:
await self.update_orders()
self.order_popup_order = order
self.order_popup_open = True
@ -120,7 +121,7 @@ class ManageCateringPage(Component):
font_size=1.2
),
margin_top=2,
margin_bottom=2,
margin_bottom=1,
align_x=0.5
)
popup = Popup(
@ -132,7 +133,26 @@ class ManageCateringPage(Component):
)
return Column(
MainViewContentBox(
Column(popup)
Column(
popup,
PointerEventListener(
content=Rectangle(
content=Text(text="Neue Bestellung anlegen", fill=Color.WHITE, justify="center", margin=0.3),
margin_bottom=1,
margin_right=5,
margin_left=5,
fill=self.session.theme.secondary_color,
hover_fill=self.session.theme.hud_color,
stroke_width=0.2,
stroke_color=Color.TRANSPARENT,
hover_stroke_width=0.2,
hover_stroke_color=self.session.theme.background_color,
cursor="pointer",
transition_time=0.1
),
on_press=lambda _: self.session.navigate_to("new-pos-order")
)
)
),
MainViewContentBox(
Column(

View File

@ -11,7 +11,7 @@ from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBo
from src.ezgg_lan_manager.types.DateUtil import weekday_to_display_text
from src.ezgg_lan_manager.types.Participant import Participant
from src.ezgg_lan_manager.types.Tournament import Tournament
from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus
from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus, TournamentError
logger = logging.getLogger(__name__.split(".")[-1])
@ -29,7 +29,10 @@ class ManageTournamentsPage(Component):
async def on_start_pressed(self, tournament_id: int) -> None:
logger.info(f"Starting tournament with ID {tournament_id}")
await self.session[TournamentService].start_tournament(tournament_id)
try:
await self.session[TournamentService].start_tournament(tournament_id)
except TournamentError as e:
logger.error(f"Error trying to start tournament: {e}")
async def on_cancel_pressed(self, tournament_id: int) -> None:
logger.info(f"Canceling tournament with ID {tournament_id}")
@ -92,9 +95,17 @@ class ManageTournamentsPage(Component):
font_size=1.2
),
margin_top=2,
margin_bottom=2,
margin_bottom=1,
align_x=0.5
),
Button(
content="Cache erneuern",
shape="rectangle",
style="colored-text",
margin_bottom=2,
align_x=0.5,
on_press=self.session[TournamentService].queue_cache_renewal
),
*tournament_rows
)
),

View File

@ -0,0 +1,417 @@
import logging
from asyncio import sleep, create_task
from decimal import Decimal
from typing import Optional, Callable
from rio import Column, Component, event, TextStyle, Text, Spacer, Revealer, ProgressCircle, ScrollContainer, Row, Popup, List, Rectangle, PointerEventListener, \
PointerEvent, TextInput, TextInputChangeEvent
from src.ezgg_lan_manager import ConfigurationService, CateringService, AccountingService
from src.ezgg_lan_manager.components.CateringCartItem import CateringCartItem
from src.ezgg_lan_manager.components.CateringSelectionItem import CateringSelectionItem
from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ezgg_lan_manager.services.CateringService import CateringError, CateringErrorType
from src.ezgg_lan_manager.types.CateringMenuItem import CateringMenuItemCategory, CateringMenuItem
from src.ezgg_lan_manager.types.CateringOrder import CateringMenuItemsWithAmount
from src.ezgg_lan_manager.types.UserSession import UserSession
POPUP_CLOSE_TIMEOUT_SECONDS = 3
logger = logging.getLogger(__name__.split(".")[-1])
class Cart(Component):
cart: List[CateringMenuItem]
user_id: Optional[int]
clear_cb: Callable
order_button_loading: bool = False
popup_message: str = ""
popup_is_shown: bool = False
popup_is_error: bool = True
async def on_remove_item(self, list_id: int) -> None:
try:
self.cart.pop(list_id)
except IndexError:
return
async def on_empty_cart_pressed(self, _: PointerEvent) -> None:
self.cart.clear()
async def show_popup(self, text: str, is_error: bool) -> None:
self.popup_is_error = is_error
self.popup_message = text
self.popup_is_shown = True
self.force_refresh()
await sleep(POPUP_CLOSE_TIMEOUT_SECONDS)
self.popup_is_shown = False
self.force_refresh()
async def on_order_pressed(self, _: PointerEvent) -> None:
if self.user_id is None:
return
self.order_button_loading = True
self.force_refresh()
show_popup_task = None
if len(self.cart) < 1:
show_popup_task = create_task(self.show_popup("Warenkorb leer", True))
else:
items_with_amounts: CateringMenuItemsWithAmount = {}
for item in self.cart:
try:
items_with_amounts[item] += 1
except KeyError:
items_with_amounts[item] = 1
try:
await self.session[CateringService].place_order(items_with_amounts, self.user_id, is_delivery=False)
except CateringError as catering_error:
logger.error(catering_error)
if catering_error.error_type == CateringErrorType.INCLUDES_DISABLED_ITEM:
show_popup_task = create_task(self.show_popup("Warenkorb enthält gesperrte Artikel", True))
elif catering_error.error_type == CateringErrorType.INSUFFICIENT_FUNDS:
show_popup_task = create_task(self.show_popup("Guthaben nicht ausreichend", True))
else:
show_popup_task = create_task(self.show_popup(f"Unbekannter Fehler: {catering_error}", True))
else:
self.cart.clear()
self.user_id = None
await self.clear_cb()
self.order_button_loading = False
if not show_popup_task:
show_popup_task = create_task(self.show_popup("Bestellung erfolgreich aufgegeben!", False))
def build(self) -> Component:
cart_container = ScrollContainer(
content=Column(
*[CateringCartItem(
article_name=cart_item.name,
article_price=cart_item.price,
article_id=cart_item.item_id,
remove_item_cb=self.on_remove_item,
list_id=idx
) for idx, cart_item in enumerate(self.cart)],
Spacer(grow_y=True)
),
min_height=8,
min_width=33,
margin=1
)
return Column(
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),
is_open=self.popup_is_shown,
position="center",
color=self.session.theme.primary_color
),
Row(
Text(
text=f"Preis: {AccountingService.make_euro_string_from_decimal(sum((cart_item.price for cart_item in self.cart), Decimal(0)))}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.8
),
margin=1
),
PointerEventListener(
content=Rectangle(
content=Text(
"Warenkorb leeren",
style=TextStyle(fill=self.session.theme.danger_color, font_size=0.9),
justify="center",
margin=0.2
),
hover_fill=self.session.theme.hud_color,
transition_time=0.1,
margin=0.5,
cursor="pointer"
),
on_press=self.on_empty_cart_pressed
),
PointerEventListener(
content=Rectangle(
content=Text(
"Bestellen",
style=TextStyle(fill=self.session.theme.success_color, font_size=0.9),
justify="center",
margin=0.2
),
hover_fill=self.session.theme.hud_color if self.user_id is not None else self.session.theme.danger_color,
transition_time=0.1,
margin=0.5,
cursor="pointer" if self.user_id is not None else "not-allowed"
),
on_press=self.on_order_pressed
)
)
)
class NewPosOrderPage(Component):
user_id_input_value: str = ""
user_id: Optional[int] = None
all_menu_items: Optional[list[CateringMenuItem]] = None
cart: List[CateringMenuItem] = List()
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Neue Bestellung anlegen")
self.all_menu_items = await self.session[CateringService].get_menu()
async def on_user_logged_in_status_changed(self) -> None:
self.force_refresh()
async def on_user_id_input_change(self, change_event: TextInputChangeEvent) -> None:
try:
id_ = int(change_event.text)
except ValueError:
return
self.user_id = id_
async def on_add(self, article_id: int) -> None:
try:
menu_item = await self.session[CateringService].get_menu_item_by_id(article_id)
except CateringError as e:
logger.error(e)
return
self.cart.append(menu_item)
@staticmethod
def get_menu_items_by_category(all_menu_items: list[CateringMenuItem], category: Optional[CateringMenuItemCategory]) -> list[CateringMenuItem]:
return list(filter(lambda item: item.category == category, all_menu_items))
async def clear_user_id_input(self) -> None:
self.user_id_input_value = ""
def build(self) -> Component:
try:
is_team_member = self.session[UserSession].is_team_member
except KeyError:
is_team_member = False
shopping_cart_container = MainViewContentBox(
Column(
Text(
text="Neue Bestellung anlegen",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=0.5,
align_x=0.5
),
TextInput(text=self.bind().user_id_input_value, label="Nutzer ID", on_change=self.on_user_id_input_change, change_delay=1, margin_bottom=0.5, margin_left=5, margin_right=5),
Cart(cart=self.cart, user_id=self.user_id, clear_cb=self.clear_user_id_input)
)
) if is_team_member else Spacer()
menu = [MainViewContentBox(
ProgressCircle(
color="secondary",
align_x=0.5,
margin_top=2,
margin_bottom=2
)
)] if not self.all_menu_items else [MainViewContentBox(
Revealer(
header="Snacks",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.on_add,
is_sensitive=not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.SNACK))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Frühstück",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.on_add,
is_sensitive=not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BREAKFAST))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Hauptspeisen",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.on_add,
is_sensitive=not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.MAIN_COURSE))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Desserts",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.on_add,
is_sensitive=not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.DESSERT))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Wasser & Softdrinks",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.on_add,
is_sensitive=not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Alkoholische Getränke",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.on_add,
is_sensitive=not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Cocktails & Longdrinks",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.on_add,
is_sensitive=not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_COCKTAIL))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Shots",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.on_add,
is_sensitive=not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_SHOT))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Sonstiges",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.on_add,
is_sensitive=not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.NON_FOOD))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
)]
return Column(shopping_cart_container, *menu, align_y=0)

View File

@ -7,13 +7,13 @@ 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, Popup, Dropdown
from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService, TicketingService, TeamService
from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService, TicketingService, TeamService, RefreshService
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.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, ParticipantType
from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus, tournament_status_to_display_text, tournament_format_to_display_texts, ParticipantType, TournamentFormat
from src.ezgg_lan_manager.types.User import User
from src.ezgg_lan_manager.types.UserSession import UserSession
@ -44,7 +44,7 @@ class TournamentDetailsPage(Component):
tournament_id = None
if tournament_id is not None:
self.tournament = await self.session[TournamentService].get_tournament_by_id(tournament_id)
if self.tournament is not None:
if isinstance(self.tournament, Tournament):
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.tournament.name}")
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)
@ -61,6 +61,8 @@ class TournamentDetailsPage(Component):
self.user = None
self.user_teams = []
self.session[RefreshService].subscribe(self.on_populate)
self.loading_done()
@staticmethod
@ -68,13 +70,14 @@ class TournamentDetailsPage(Component):
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)
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)
if isinstance(self.tournament, Tournament):
self.tournament = await self.session[TournamentService].get_tournament_by_id(self.tournament.id)
if self.tournament is None or isinstance(self.tournament, str):
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
@ -88,6 +91,9 @@ class TournamentDetailsPage(Component):
if user_ticket is None:
self.is_success = False
self.message = "Turnieranmeldung nur mit Ticket"
elif not isinstance(self.tournament, Tournament):
self.is_success = False
self.message = "Fehler bei der Anmeldung"
else:
# Register single player
if self.tournament.participant_type == ParticipantType.PLAYER:
@ -125,12 +131,15 @@ class TournamentDetailsPage(Component):
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
if isinstance(self.tournament, Tournament):
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
else:
raise ValueError("Turnier nicht gefunden")
except Exception as e:
logger.error(e)
self.message = f"Fehler: {e}"
@ -149,9 +158,9 @@ class TournamentDetailsPage(Component):
return
try:
if self.tournament.participant_type == ParticipantType.PLAYER:
if isinstance(self.tournament, Tournament) and 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:
elif isinstance(self.tournament, Tournament) and 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:
@ -166,7 +175,8 @@ class TournamentDetailsPage(Component):
self.loading = False
async def tree_button_clicked(self) -> None:
pass # ToDo: Implement tournament tree view
if isinstance(self.tournament, Tournament):
self.session.navigate_to(f"tournament-tree?id={self.tournament.id}")
def loading_done(self) -> None:
if self.tournament is None:
@ -205,16 +215,17 @@ class TournamentDetailsPage(Component):
tournament_status_color = self.session.theme.danger_color
elif self.tournament.status == TournamentStatus.ONGOING or self.tournament.status == TournamentStatus.COMPLETED:
tournament_status_color = self.session.theme.warning_color
tree_button = Button(
content="Turnierbaum anzeigen",
shape="rectangle",
style="minor",
color="hud",
margin_left=4,
margin_right=4,
margin_top=1,
on_press=self.tree_button_clicked
)
if self.tournament.format != TournamentFormat.FFA:
tree_button = Button(
content="Turnierbaum anzeigen",
shape="rectangle",
style="minor",
color="hud",
margin_left=4,
margin_right=4,
margin_top=1,
on_press=self.tree_button_clicked
)
ids_of_participants = [p.id for p in self.tournament.participants]
color_key: Literal["hud", "danger"] = "hud"
@ -349,7 +360,7 @@ class TournamentDetailsPage(Component):
button
)
if self.tournament and self.tournament.participant_type == ParticipantType.TEAM:
if isinstance(self.tournament, Tournament) and self.tournament.participant_type == ParticipantType.TEAM:
content = Popup(
anchor=content,
content=Rectangle(

View File

@ -0,0 +1,280 @@
import json
import logging
from typing import Optional, Union
from from_root import from_root
from rio import Column, Component, event, TextStyle, Text, Row, Spacer, ProgressCircle, Rectangle, Stack
from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService, TeamService, RefreshService, SeatingService
from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox
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 ParticipantType, TournamentFormat
from src.ezgg_lan_manager.types.User import User
from src.ezgg_lan_manager.types.UserSession import UserSession
logger = logging.getLogger(__name__.split(".")[-1])
class MatchInfo(Component):
opponent_1: str = ""
opponent_2: str = ""
opponent_1_seat: str = ""
opponent_2_seat: str = ""
winner: str = ""
def build(self) -> Component:
return Rectangle(
content=Column(
Stack(
Row(
Row(
Text(
text=self.opponent_1,
style=TextStyle(fill=self.session.theme.success_color if self.winner == self.opponent_1 else self.session.theme.background_color),
justify="left",
margin_right=0.6,
font_size=0.9
),
Text(
text=f"({self.opponent_1_seat})" if self.opponent_1_seat else "Freilos",
style=TextStyle(fill=self.session.theme.background_color),
justify="left",
font_size=0.9
)
),
Spacer(),
Row(
Text(
text=self.opponent_2,
style=TextStyle(fill=self.session.theme.success_color if self.winner == self.opponent_2 else self.session.theme.background_color),
justify="right",
margin_right=0.6,
font_size=0.9
),
Text(
text=f"({self.opponent_2_seat})" if self.opponent_2_seat else "Freilos",
style=TextStyle(fill=self.session.theme.background_color),
justify="right",
font_size=0.9
)
),
margin=0.3
),
Row(
Text(
text=f"vs.",
style=TextStyle(fill=self.session.theme.background_color),
justify="center"
),
margin=0.3
)
)
),
margin=1,
stroke_width=0.2,
stroke_color=self.session.theme.background_color,
fill=self.session.theme.hud_color,
)
class TournamentTreePage(Component):
tournament: Optional[Union[Tournament, str]] = None
user: Optional[User] = None
teams: list[Team] = []
id_to_username_map: dict[int, str] = {}
id_to_seat_map: dict[int, str] = {}
is_fully_loaded: bool = False
@event.on_populate
async def on_populate(self) -> None:
try:
tournament_id = int(self.session.active_page_url.query_string.split("id=")[-1])
except (ValueError, AttributeError, TypeError):
tournament_id = None
if tournament_id is not None:
self.tournament = await self.session[TournamentService].get_tournament_by_id(tournament_id)
if isinstance(self.tournament, Tournament):
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.tournament.name}")
else:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turniere")
try:
user_id = self.session[UserSession].user_id
self.user = await self.session[UserService].get_user(user_id)
except KeyError:
self.user = None
self.teams = await self.session[TeamService].get_all_teams()
all_users = await self.session[UserService].get_all_users()
id_to_username_map = {}
id_to_seat_map = {}
for user in all_users:
id_to_username_map[user.user_id] = user.user_name
seat = await self.session[SeatingService].get_user_seat(user.user_id)
if seat is not None:
id_to_seat_map[user.user_id] = seat.seat_id
self.id_to_username_map = id_to_username_map
self.id_to_seat_map = id_to_seat_map
self.session[RefreshService].subscribe(self.on_populate)
self.is_fully_loaded = True
def _get_seat_for_team(self, team: Team) -> str:
# Retrieves seat id for leader of a team
leader = list(team.members.keys())[0]
for member, rank in team.members.items():
if rank == TeamStatus.LEADER:
leader = member
break
return self.id_to_seat_map[leader.user_id]
def build(self) -> Component:
if self.tournament is None or not self.is_fully_loaded:
return Column(
MainViewContentBox(
Column(
Spacer(min_height=1),
Column(
ProgressCircle(
color="secondary",
align_x=0.5,
margin_top=0,
margin_bottom=0
),
min_height=10
),
Spacer(min_height=1)
)
),
align_y=0
)
elif isinstance(self.tournament, str):
content = Row(
Text(
text=self.tournament,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin_top=2,
margin_bottom=2,
align_x=0.5
)
)
else:
if self.tournament.format == TournamentFormat.FFA:
content = Column(
Text(
text=f"Dieses Turnier hat keinen Turnierbaum.",
style=TextStyle(fill=self.session.theme.background_color),
margin_top=1,
margin_bottom=1,
align_x=0.5,
overflow="wrap",
min_width=30,
justify="center"
)
)
else:
try:
file_name = self.tournament.name.replace(" ", "_") + ".json"
games_per_matchup = int(self.tournament.format.name[-1])
logger.info(f"Trying to read tournament data from {file_name}")
with open(from_root("tournament_data", file_name), "r") as f:
json_data = json.load(f)
last_valid_round = None
round_num = 0
for round_ in json_data["rounds"]:
if all(
match["opponent_1_id"] is not None or match["opponent_2_id"] is not None
for match in round_
):
last_valid_round = round_
round_num += 1
if last_valid_round is None:
last_valid_round = json_data["rounds"][0]
match_infos = []
if self.tournament.participant_type == ParticipantType.PLAYER:
match_infos = [MatchInfo(
opponent_1=self.id_to_username_map.get(match["opponent_1_id"], ""),
opponent_2=self.id_to_username_map.get(match["opponent_2_id"], ""),
winner=self.id_to_username_map.get(match["winner"], ""),
opponent_1_seat=self.id_to_seat_map.get(match["opponent_1_id"], ""),
opponent_2_seat=self.id_to_seat_map.get(match["opponent_2_id"], ""),
) for match in last_valid_round]
elif self.tournament.participant_type == ParticipantType.TEAM:
for match in last_valid_round:
team_1: Optional[Team] = next(filter(lambda t: t.id == match["opponent_1_id"], self.teams), None)
team_2: Optional[Team] = next(filter(lambda t: t.id == match["opponent_2_id"], self.teams), None)
winner: Union[str, Team] = next(filter(lambda t: t.id == match["winner"], self.teams), "")
match_infos.append(
MatchInfo(
opponent_1=team_1.name if team_1 is not None else "",
opponent_2=team_2.name if team_2 is not None else "",
winner=winner if isinstance(winner, str) else winner.name,
opponent_1_seat=self._get_seat_for_team(team_1) if team_1 is not None else "",
opponent_2_seat=self._get_seat_for_team(team_2) if team_2 is not None else "",
)
)
else:
raise ValueError("Unknown participant type")
content = Column(
Text(
text=f"{self.tournament.name}",
style=TextStyle(fill=self.session.theme.background_color),
justify="center",
font_size=1.2
),
Text(
text="Finale" if len(json_data["rounds"]) == round_num else f"Runde {round_num}",
style=TextStyle(fill=self.session.theme.background_color),
justify="center",
font_size=0.9,
margin_bottom=1
),
Text(
text=f"Spiele pro Matchup: {games_per_matchup}",
style=TextStyle(fill=self.session.theme.background_color),
justify="center",
font_size=0.8
),
Text(
text=f"Melde als Verlierer deinen Matchausgang\nim Discord oder an der Orga-Ecke",
style=TextStyle(fill=self.session.theme.background_color),
justify="center",
font_size=0.8
),
*match_infos
)
except (FileNotFoundError, ValueError, AttributeError):
content = Column(
Text(
text=f"Der Turnierbaum für dieses Turnier steht leider nicht zur Verfügung.\n\nBitte melde sich beim Orga-Team.",
style=TextStyle(fill=self.session.theme.background_color),
margin_top=1,
margin_bottom=1,
align_x=0.5,
overflow="wrap",
min_width=30,
justify="center"
)
)
return Column(
MainViewContentBox(
Column(
Spacer(min_height=1),
content,
Spacer(min_height=1)
)
),
align_y=0
)

View File

@ -25,3 +25,5 @@ from .TournamentRulesPage import TournamentRulesPage
from .ConwayPage import ConwayPage
from .TeamsPage import TeamsPage
from .AdminNavigationPage import AdminNavigationPage
from .TournamentTreePage import TournamentTreePage
from .NewPosOrderPage import NewPosOrderPage

View File

@ -1,4 +1,6 @@
import io
import logging
import qrcode
from collections.abc import Callable
from datetime import datetime
from decimal import Decimal, ROUND_DOWN
@ -74,3 +76,29 @@ class AccountingService:
return "0.00 €"
rounded_decimal = str(euros.quantize(Decimal(".01"), rounding=ROUND_DOWN))
return f"{rounded_decimal}"
@staticmethod
def make_payment_qr_image(beneficiary_name, beneficiary_bic, beneficiary_iban, text, amount_euros=None) -> bytes:
text = text.replace("\n",";")
amount_formatted = "EUR{:.2f}".format(amount_euros) if amount_euros else ""
epc_text = f"""BCD
002
1
SCT
{beneficiary_bic}
{beneficiary_name}
{beneficiary_iban}
{amount_formatted}
{text}
"""
qr = qrcode.QRCode(
version=6,
error_correction=qrcode.constants.ERROR_CORRECT_M,
)
qr.add_data(epc_text)
img = qr.make_image()
img_bytes = io.BytesIO()
img.save(img_bytes)
return img_bytes.getvalue()

View File

@ -1,6 +1,6 @@
import logging
from datetime import date, datetime
from datetime import date, datetime, UTC
from typing import Optional
from decimal import Decimal
@ -16,7 +16,7 @@ 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
from src.ezgg_lan_manager.types.TournamentBase import GameTitle, TournamentFormat, TournamentStatus, ParticipantType, MatchStatus
from src.ezgg_lan_manager.types.Transaction import Transaction
from src.ezgg_lan_manager.types.User import User
@ -1185,3 +1185,18 @@ class DatabaseService:
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.remove_user_from_team(team, user)
async def change_tournament_status(self, tournament_id: int, status: TournamentStatus) -> None:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"UPDATE tournaments SET status = %s WHERE (id = %s)",
(status.name, tournament_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.change_tournament_status(tournament_id, status)

View File

@ -21,5 +21,6 @@ class LocalDataService:
self._session[key] = session
return key
def del_session(self, token: str) -> None:
self._session.pop(token, None)
def del_session(self, token: Optional[str]) -> None:
if token is not None:
self._session.pop(token, None)

View File

@ -15,11 +15,15 @@ class ReceiptPrintingService:
self._seating_service = seating_service
self._config = config
self._dev_mode_enabled = dev_mode_enabled
self._url = f"http://{self._config.host}:{self._config.port}/{self._config.order_print_endpoint}"
async def print_order(self, user: User, order: CateringOrder) -> None:
seat_id = await self._seating_service.get_user_seat(user.user_id)
if not seat_id:
seat = await self._seating_service.get_user_seat(user.user_id)
if seat is None:
seat_id = " - "
else:
seat_id = str(seat.seat_id)
menu_items_payload = []
for item, amount in order.items.items():
@ -35,14 +39,19 @@ class ReceiptPrintingService:
"seat_id": seat_id,
"items": menu_items_payload
}
logger.info(f"Sending print order to {self._url}: {payload}")
try:
requests.post(
f"http://{self._config.host}:{self._config.port}/{self._config.order_print_endpoint}",
response = requests.post(
self._url,
json=payload,
headers={"x-password": self._config.password}
headers={"x-password": self._config.password},
timeout=2.0
)
if response.status_code != 200:
logger.error(f"Received an error with code {response.status_code}: {response.text}")
except Exception as e:
if self._dev_mode_enabled:
logger.info("An error occurred trying to print a receipt:", e)
logger.info("An error occurred trying to print a receipt: %s", e)
return
logger.error("An error occurred trying to print a receipt:", e)
logger.error("An error occurred trying to print a receipt: %s", e)

View File

@ -1,17 +1,17 @@
from typing import Callable
from typing import Callable, Optional
class RefreshService:
"""
rio.Components can subscribe to this service with their on_populate method.
Those methods get called whenever a overall refresh is needed. Usually when the user logs in or out.
The active rio.Components can subscribe to this service with their on_populate method.
This methods get called whenever a overall refresh is needed. Usually when the user logs in or out.
"""
def __init__(self) -> None:
self.subscribers: set[Callable] = set()
self.subscriber: Optional[Callable] = None
def subscribe(self, refresh_cb: Callable) -> None:
self.subscribers.add(refresh_cb)
self.subscriber = refresh_cb
async def trigger_refresh(self) -> None:
for refresh_cb in self.subscribers:
await refresh_cb()
if self.subscriber is not None:
await self.subscriber()

View File

@ -1,5 +1,9 @@
import json
from pprint import pprint
from typing import Optional
from from_root import from_root
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
@ -17,6 +21,10 @@ class TournamentService:
# Crude cache mechanism. If performance suffers, maybe implement a queue with Single-Owner-Pattern or a Lock
self._cache: dict[int, Tournament] = {}
self._cache_dirty: bool = True # Setting this flag invokes cache update on next read
async def queue_cache_renewal(self) -> None:
# Used in admin UI to provoke cache renewal after direct database access
self._cache_dirty = True
async def _update_cache(self) -> None:
tournaments = await self._db_service.get_all_tournaments()
@ -90,12 +98,47 @@ class TournamentService:
tournament = await self.get_tournament_by_id(tournament_id)
if tournament:
tournament.start()
# ToDo: Write matches/round to database
await self._generate_initial_json_file(tournament)
await self._db_service.change_tournament_status(tournament_id, tournament.status)
self._cache_dirty = True
async def cancel_tournament(self, tournament_id: int):
tournament = await self.get_tournament_by_id(tournament_id)
if tournament:
tournament.cancel()
# ToDo: Update to database
await self._db_service.change_tournament_status(tournament_id, tournament.status)
self._cache_dirty = True
async def _generate_initial_json_file(self, tournament: Tournament) -> None:
"""
Generates the initial JSON file for the tournament. Won't generate a new one if one already exists.
ToDo: Remove this method when final tournament system is completed.
"""
p = tournament.participants
pairs = [
(p[i], p[i + 1]) if i + 1 < len(p) else (p[i], None)
for i in range(0, len(p), 2)
]
data = {
"rounds": [
[
{
"opponent_1_id": pair[0].id if pair[0] is not None else None,
"opponent_2_id": pair[1].id if pair[1] is not None else None,
"winner": None
} for pair in pairs
]
]
}
# Resolve byes
for match in data["rounds"][0]:
if match["opponent_2_id"] is None:
match["winner"] = match["opponent_1_id"]
file_name = tournament.name.replace(" ", "_") + ".json"
try:
with open(from_root("tournament_data", file_name), "x") as f:
json.dump(data, f, indent=4)
except FileExistsError:
pass

View File

@ -49,6 +49,14 @@ class Match:
games.append(Game(game_id, self._match_id, game_number, None, None, False))
return tuple(games)
@property
def round_number(self) -> int:
return self._round_number
@property
def best_of(self) -> int:
return self._best_of
@property
def status(self) -> MatchStatus:
if self._status == MatchStatus.COMPLETED:

View File

@ -1,3 +1,4 @@
import logging
import uuid
from datetime import datetime
from typing import Optional
@ -7,6 +8,7 @@ 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, ParticipantType
logger = logging.getLogger(__name__.split(".")[-1])
class Tournament:
def __init__(self,
@ -353,6 +355,7 @@ class Tournament:
raise TournamentError(f"Unknown bracket type: {bracket_type}")
self._status = TournamentStatus.ONGOING
logger.info(f"New tournament status for {self._name}: {self._status}")
for match in self._matches:
match.check_completion()

View File

@ -1,9 +1,9 @@
from dataclasses import dataclass
from uuid import UUID
from rio import Dataclass
@dataclass
class UserSession:
class UserSession(Dataclass):
id: UUID
user_id: int
is_team_member: bool

1
tournament_data/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.json

43
tournament_data/README.md Normal file
View File

@ -0,0 +1,43 @@
# Tournament data
This directory contains JSON files for tournament trees.
This is a temporary solution until the automatic tournament tree generation is completed.
# Structure
## Naming
Tournament name with `_` as separators and `.json` suffix.
## JSON structure
```json
{
"rounds": [
[
{
"opponent_1_id": 1,
"opponent_2_id": 2,
"winner": 1
},
{
"opponent_1_id": 3,
"opponent_2_id": 4,
"winner": null
}
],
[
{
"opponent_1_id": 1,
"opponent_2_id": null,
"winner": null
}
]
]
}
```
## ToDo
- Make start button in UI generate initial `.json` file for started tournament