From 61fd91d9f4445bc14e942cf90a1117dc2bd985e4 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 29 Apr 2026 12:09:39 +0200 Subject: [PATCH 1/2] Add POS ordering --- src/EzggLanManager.py | 6 + .../pages/ManageCateringPage.py | 25 +- src/ezgg_lan_manager/pages/NewPosOrderPage.py | 417 ++++++++++++++++++ src/ezgg_lan_manager/pages/__init__.py | 1 + .../services/ReceiptPrintingService.py | 23 +- 5 files changed, 462 insertions(+), 10 deletions(-) create mode 100644 src/ezgg_lan_manager/pages/NewPosOrderPage.py diff --git a/src/EzggLanManager.py b/src/EzggLanManager.py index 39442e9..6410f42 100644 --- a/src/EzggLanManager.py +++ b/src/EzggLanManager.py @@ -156,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", diff --git a/src/ezgg_lan_manager/pages/ManageCateringPage.py b/src/ezgg_lan_manager/pages/ManageCateringPage.py index 258cab4..0f078e7 100644 --- a/src/ezgg_lan_manager/pages/ManageCateringPage.py +++ b/src/ezgg_lan_manager/pages/ManageCateringPage.py @@ -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 @@ -120,7 +120,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 +132,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( diff --git a/src/ezgg_lan_manager/pages/NewPosOrderPage.py b/src/ezgg_lan_manager/pages/NewPosOrderPage.py new file mode 100644 index 0000000..2d8c1c9 --- /dev/null +++ b/src/ezgg_lan_manager/pages/NewPosOrderPage.py @@ -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) + 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) diff --git a/src/ezgg_lan_manager/pages/__init__.py b/src/ezgg_lan_manager/pages/__init__.py index aee08b0..0bb0be5 100644 --- a/src/ezgg_lan_manager/pages/__init__.py +++ b/src/ezgg_lan_manager/pages/__init__.py @@ -26,3 +26,4 @@ from .ConwayPage import ConwayPage from .TeamsPage import TeamsPage from .AdminNavigationPage import AdminNavigationPage from .TournamentTreePage import TournamentTreePage +from .NewPosOrderPage import NewPosOrderPage diff --git a/src/ezgg_lan_manager/services/ReceiptPrintingService.py b/src/ezgg_lan_manager/services/ReceiptPrintingService.py index 095d10a..8e2b70d 100644 --- a/src/ezgg_lan_manager/services/ReceiptPrintingService.py +++ b/src/ezgg_lan_manager/services/ReceiptPrintingService.py @@ -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) -- 2.45.2 From 285f9fa09fb3cefe34069d1d5f1172f617b43d43 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 29 Apr 2026 12:10:00 +0200 Subject: [PATCH 2/2] 0.4.0 -> 0.5.0 (POS Ordering) --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 60a2d3e..79a2734 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 \ No newline at end of file +0.5.0 \ No newline at end of file -- 2.45.2