From 3c3e601d3a3ca90c876315a12d57d90f96d69c41 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 26 May 2026 15:51:39 +0200 Subject: [PATCH] Implement catering order flow --- requirements.txt | 2 +- src/elm/components/CateringCart.py | 125 ++++++++++++++++++ src/elm/components/CateringCategoryDisplay.py | 13 +- src/elm/components/CateringItemBox.py | 34 +++-- src/elm/components/UserNavigation.py | 1 + src/elm/components/__init__.py | 1 + src/elm/pages/CateringPage.py | 35 ++--- src/elm/types/CateringTypes.py | 46 +++++++ 8 files changed, 218 insertions(+), 39 deletions(-) create mode 100644 src/elm/components/CateringCart.py diff --git a/requirements.txt b/requirements.txt index 13e9117..8fd7c0f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -48,7 +48,7 @@ qrcode==8.2 RapidFuzz==3.14.5 readchar==4.2.2 revel==0.9.2.post1 -rio-ui==0.12 +rio-ui==0.12.1 sentinel==1.0.0 six==1.17.0 starlette==0.52.1 diff --git a/src/elm/components/CateringCart.py b/src/elm/components/CateringCart.py new file mode 100644 index 0000000..5b866b0 --- /dev/null +++ b/src/elm/components/CateringCart.py @@ -0,0 +1,125 @@ +from decimal import Decimal +from functools import partial + +from rio import Component, Rectangle, Column, Text, Spacer, List, Row, IconButton + +from elm.components import ElmButton +from elm.services import AccountingService +from elm.types import User, UserSession +from elm.types.CateringTypes import CateringOrderedItem, CateringOrder + + +class CateringCart(Component): + cart: List[CateringOrderedItem] + order_button_loading: bool = False + status_text: str = " " + status_is_error: bool = False + + def remove_item(self, item: CateringOrderedItem) -> None: + self.cart.remove(item) + + def get_cart_price(self) -> str: + price = Decimal(0) + for item in self.cart: + price += item.final_unit_price + return self.session[AccountingService].make_euro_string_from_decimal(price) + + async def order_pressed(self) -> None: + self.order_button_loading = True + + if len(self.cart) == 0: + self.status_text = "Warenkorb leer!" + self.status_is_error = True + self.order_button_loading = False + return None + + try: + user = await User.find_one(User.user_name == self.session[UserSession].user_name) + except KeyError: + self.status_text = "Nicht eingeloggt!" + self.status_is_error = True + self.order_button_loading = False + return None + if not user: + self.status_text = "Nicht eingeloggt!" + self.status_is_error = True + self.order_button_loading = False + return None + + total_price = Decimal(0) + for item in self.cart: + total_price += item.final_unit_price + + balance = await self.session[AccountingService].get_balance(user.user_name) + + if total_price > balance: + self.status_text = "Guthaben nicht ausreichend!" + self.status_is_error = True + self.order_button_loading = False + return None + + try: + new_order = await CateringOrder( + customer_id=user.id, + items=list(self.cart) + ).save() + except Exception as e: + self.status_text = f"Fehler: {e}" + self.status_is_error = True + self.order_button_loading = False + return None + + await self.session[AccountingService].remove_balance( + user.user_name, + total_price, + f"Catering: {new_order.id}" + ) + + self.cart.clear() + self.order_button_loading = False + self.status_text = "Bestellt!" + self.status_is_error = False + return None + + + def build(self) -> Component: + return Rectangle( + content=Column( + *[Rectangle( + content=Column( + Row( + Text(text=item.name, overflow="ellipsize", grow_x=True), + IconButton(icon="material/cancel", style="plain-text", min_size=2, on_press=partial(self.remove_item, item)), + margin=0.5 + ), + *[ + Row(Text(f"{'MIT' if modifier.selected else 'OHNE'} {modifier.label}", overflow="ellipsize", grow_x=True, font_size=0.8, margin_left=1), margin=0.5) + for modifier in item.selected_modifiers + ], + Row(Text(self.session[AccountingService].make_euro_string_from_decimal(item.final_unit_price), overflow="ellipsize", grow_x=True, margin_left=1, justify="center"), margin=0.5), + ), + margin=0.5, + stroke_width=0.1, + stroke_color=self.session.theme.primary_color + ) for item in self.cart], + Spacer(), + Rectangle( + content=Column( + Row( + Text(text="Preis:", overflow="nowrap"), + Text(text=self.get_cart_price(), overflow="ellipsize", grow_x=True, justify="right") + ), + ElmButton(text="Bestellen", is_loading=self.order_button_loading, on_press=self.order_pressed), + Text(text=self.status_text, fill=self.session.theme.danger_color if self.status_is_error else self.session.theme.success_color, overflow="wrap", justify="center"), + margin=0.5, + spacing=1 + ), + margin=0.5, + stroke_width=0.1, + stroke_color=self.session.theme.primary_color + ) + ), + fill=self.session.theme.box_color, + stroke_width=0.1, + stroke_color=self.session.theme.box_border_color + ) diff --git a/src/elm/components/CateringCategoryDisplay.py b/src/elm/components/CateringCategoryDisplay.py index 421c425..be0b0ae 100644 --- a/src/elm/components/CateringCategoryDisplay.py +++ b/src/elm/components/CateringCategoryDisplay.py @@ -1,9 +1,9 @@ -from typing import Literal +from typing import Literal, Callable -from rio import Component, Rectangle, Column, Spacer, Text, Row, TextInput, FlowContainer +from rio import Component, Rectangle, Column, Spacer, Text from rio.event import on_populate -from elm.components import ElmButton, CateringItemBox +from elm.components import CateringItemBox from elm.types.CateringTypes import CateringMenuItem, CateringMenuItemCategory ITEM_CATEGORY_BY_DISPLAY_CATEGORY: dict[Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"], list[CateringMenuItemCategory]] = { @@ -17,6 +17,7 @@ ITEM_CATEGORY_BY_DISPLAY_CATEGORY: dict[Literal["Frühstück", "Hauptspeisen", " class CateringCategoryDisplay(Component): active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"] + add_to_cart_pressed_callback: Callable catering_menu_items: list[CateringMenuItem] = [] @on_populate @@ -28,6 +29,10 @@ class CateringCategoryDisplay(Component): } } ).to_list() + + async def add_to_cart_pressed(self, item: CateringMenuItem, changed_options: dict[str, bool]) -> None: + await self.add_to_cart_pressed_callback(item, changed_options) + def build(self) -> Component: if len(self.catering_menu_items) <= 0: return Spacer() @@ -44,7 +49,7 @@ class CateringCategoryDisplay(Component): stroke_color=self.session.theme.box_border_color, ), # Items here - Column(*[CateringItemBox(i, margin=0.5, grow_y=True) for i in self.catering_menu_items]), + Column(*[CateringItemBox(item=i, add_to_cart_pressed_callback=self.add_to_cart_pressed, margin=0.5, grow_y=True) for i in self.catering_menu_items]), Spacer() ), fill=self.session.theme.box_color, diff --git a/src/elm/components/CateringItemBox.py b/src/elm/components/CateringItemBox.py index cf5be33..ef550fb 100644 --- a/src/elm/components/CateringItemBox.py +++ b/src/elm/components/CateringItemBox.py @@ -1,18 +1,27 @@ from decimal import Decimal +from typing import Callable -from rio import Component, Rectangle, Column, Text, Row, Separator, Color, Checkbox, FlowContainer, IconButton, Icon, Spacer +from rio import Component, Rectangle, Column, Text, Row, Separator, Color, Checkbox, FlowContainer, Icon, Spacer, CheckboxChangeEvent, PointerEventListener, PointerEvent, Dict, TextStyle from elm.services import AccountingService -from elm.components import ElmButton -from elm.types.CateringTypes import CateringMenuItem, CateringModificationKey +from elm.types.CateringTypes import CateringMenuItem, CateringModificationKey, CateringModifierOption class CateringItemBox(Component): item: CateringMenuItem + add_to_cart_pressed_callback: Callable + changed_options: Dict[str, bool] = Dict() def make_money_string(self, money: Decimal) -> str: return self.session[AccountingService].make_euro_string_from_decimal(money) + def on_option_change(self, e: CheckboxChangeEvent, option: CateringModifierOption) -> None: + self.changed_options[option.key] = e.is_on + + async def add_to_cart_pressed(self, _: PointerEvent) -> None: + if self.item.active: + await self.add_to_cart_pressed_callback(self.item, self.changed_options) + def build(self) -> Component: base_mods = [] extra_mods = [] @@ -22,7 +31,7 @@ class CateringItemBox(Component): base_mods.append(Text("Basis:", fill=self.session.theme.secondary_color, font_size=0.8, margin_top=0.5)) container = FlowContainer(spacing=2.5) for option in modifier_group.options: - container.children.append(Row(Checkbox(is_on=option.default_selected), Text(option.label), spacing=0.6)) + container.children.append(Row(Checkbox(is_on=self.changed_options.get(option.key, option.default_selected), on_change=lambda event, option=option: self.on_option_change(event, option)), Text(option.label), spacing=0.6)) base_mods.append(container) if modifier_group.key == CateringModificationKey.EXTRA: extra_mods.append(Text("Extras:", fill=self.session.theme.secondary_color, font_size=0.8, margin_top=0.8)) @@ -31,7 +40,7 @@ class CateringItemBox(Component): text = f"{option.label}" if option.price_delta > Decimal("0"): text += f" (+ {self.make_money_string(option.price_delta)})" - container.children.append(Row(Checkbox(is_on=option.default_selected), Text(text), spacing=0.6)) + container.children.append(Row(Checkbox(is_on=self.changed_options.get(option.key, option.default_selected), on_change=lambda event, option=option: self.on_option_change(event, option)), Text(text), spacing=0.6)) extra_mods.append(container) return Rectangle( @@ -39,7 +48,7 @@ class CateringItemBox(Component): Row( Column( Row( - Text(text=self.item.name, overflow="nowrap", justify="left", font_size=1.1, margin_right=0.8, font_weight="bold", strikethrough=not self.item.active), + Text(text=self.item.name, overflow="nowrap", justify="left", font_size=1.1, margin_right=0.8, font_weight="bold", style=TextStyle(strikethrough=not self.item.active)), Text(text=self.make_money_string(self.item.base_price), overflow="ellipsize", justify="left", font_size=0.8, grow_x=True, fill=self.session.theme.primary_color, align_y=1.2) ), Text(self.item.description, font_size=0.7, margin_left=2), @@ -51,11 +60,14 @@ class CateringItemBox(Component): ), Column( Spacer(), - Rectangle( - content=Icon(icon="material/add_shopping_cart", min_width=2, min_height=2, margin=1), - hover_fill=Color.TRANSPARENT if not self.item.active else self.session.theme.hud_color, - cursor="not-allowed" if not self.item.active else "pointer", - transition_time=0.2 + PointerEventListener( + content=Rectangle( + content=Icon(icon="material/add_shopping_cart", min_width=2, min_height=2, margin=1), + hover_fill=Color.TRANSPARENT if not self.item.active else self.session.theme.hud_color, + cursor="not-allowed" if not self.item.active else "pointer", + transition_time=0.2 + ), + on_press=self.add_to_cart_pressed ), Spacer() ) diff --git a/src/elm/components/UserNavigation.py b/src/elm/components/UserNavigation.py index ea06479..c8b60fe 100644 --- a/src/elm/components/UserNavigation.py +++ b/src/elm/components/UserNavigation.py @@ -91,6 +91,7 @@ class UserNavigation(Component): return Rectangle( content=Column( UserNavigationButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_decimal(self.balance)}", "/balance", self.close_navigation), + UserNavigationButton("Meine Bestellungen", "/my-orders", self.close_navigation), UserNavigationButton("Mein Profil", "/my-profile", self.close_navigation), UserNavigationButton("Mein Clan", "/my-clans", self.close_navigation), UserNavigationButton("Ausloggen", "/logout", self.close_navigation) diff --git a/src/elm/components/__init__.py b/src/elm/components/__init__.py index ffbc3c5..2b74669 100644 --- a/src/elm/components/__init__.py +++ b/src/elm/components/__init__.py @@ -11,3 +11,4 @@ from .SeatingPlanPixels import * from .SeatingPlan import * from .CateringItemBox import CateringItemBox from .CateringCategoryDisplay import CateringCategoryDisplay +from .CateringCart import CateringCart diff --git a/src/elm/pages/CateringPage.py b/src/elm/pages/CateringPage.py index ed1e217..217b812 100644 --- a/src/elm/pages/CateringPage.py +++ b/src/elm/pages/CateringPage.py @@ -1,26 +1,24 @@ from __future__ import annotations -import csv -import io -from copy import copy -from typing import Any, Optional, Literal -from uuid import uuid4 +from typing import Literal -from rio import Component, Column, Row, Text, Spacer, page, Color, Rectangle, TextInput, GuardEvent, SwitcherBar, SwitcherBarChangeEvent +from rio import Component, Column, Row, Text, page, Rectangle, SwitcherBar, SwitcherBarChangeEvent, List -from elm.types import UserSession, User -from elm.types.CateringTypes import * -from elm.services import UserService, LocalData, LocalDataService, ConfigurationService -from elm.components import ElmButton, CateringCategoryDisplay +from elm.components import CateringCategoryDisplay, CateringCart +from elm.types.CateringTypes import CateringMenuItem, CateringOrderedItem @page(name="Catering", url_segment="catering") class CateringPage(Component): active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"] = "Hauptspeisen" + cart: List[CateringOrderedItem] = List() async def on_switcher_bar_change(self, event: SwitcherBarChangeEvent) -> None: self.active_category = event.value - print(event) + + async def add_to_cart_pressed(self, item: CateringMenuItem, changed_options: dict[str, bool]) -> None: + order_item = CateringOrderedItem.from_menu_item(item, changed_options) + self.cart.append(order_item) def build(self) -> Component: return Row( @@ -37,7 +35,7 @@ class CateringPage(Component): stroke_width=0.1, stroke_color=self.session.theme.box_border_color ), - CateringCategoryDisplay(active_category=self.active_category, grow_y=True), + CateringCategoryDisplay(active_category=self.active_category, add_to_cart_pressed_callback=self.add_to_cart_pressed, grow_y=True), grow_x=True, spacing=1 ), @@ -58,18 +56,9 @@ class CateringPage(Component): stroke_width=0.1, stroke_color=self.session.theme.box_border_color ), - Rectangle( - content=Column( - Text("ToDo", margin=1), - Spacer() - ), - fill=self.session.theme.box_color, - stroke_width=0.1, - stroke_color=self.session.theme.box_border_color, - grow_y=True - ), + CateringCart(cart=self.cart, grow_y=True), spacing=1, - min_width=18 + min_width=20 ), spacing=1, margin=1 diff --git a/src/elm/types/CateringTypes.py b/src/elm/types/CateringTypes.py index 9fd02b0..6aa7f56 100644 --- a/src/elm/types/CateringTypes.py +++ b/src/elm/types/CateringTypes.py @@ -124,6 +124,52 @@ class CateringOrderedItem(MongoDecimalModel): notes: Optional[str] = None + @classmethod + def from_menu_item(cls, menu_item: CateringMenuItem, changed_modifiers: dict[str, bool], quantity: int = 1) -> "CateringOrderedItem": + selected_modifiers: list[CateringSelectedModifier] = [] + + for modifier_group in menu_item.modifier_groups: + for option in modifier_group.options: + selected = changed_modifiers.get(option.key) + + if selected is not None: + + selected_modifiers.append( + CateringSelectedModifier( + group_key=modifier_group.key, + option_key=option.key, + label=option.label, + selected=selected, + price_delta=option.price_delta + ) + ) + + final_unit_price = cls._calculate_final_unit_price( + menu_item.base_price, + selected_modifiers + ) + + return cls( + menu_item_id=menu_item.id, + name=menu_item.name, + quantity=quantity, + base_price=menu_item.base_price, + selected_modifiers=selected_modifiers, + final_unit_price=final_unit_price + ) + + @staticmethod + def _calculate_final_unit_price(base_price: Decimal, modifiers: list[CateringSelectedModifier]) -> Decimal: + + return ( + base_price + + sum( + modifier.price_delta + for modifier in modifiers + if modifier.selected + ) + ) + class CateringOrder(Document): customer_id: PydanticObjectId