Implement catering order flow

This commit is contained in:
David Rodenkirchen
2026-05-26 15:51:39 +02:00
parent 2290072820
commit 3c3e601d3a
8 changed files with 218 additions and 39 deletions
+1 -1
View File
@@ -48,7 +48,7 @@ qrcode==8.2
RapidFuzz==3.14.5 RapidFuzz==3.14.5
readchar==4.2.2 readchar==4.2.2
revel==0.9.2.post1 revel==0.9.2.post1
rio-ui==0.12 rio-ui==0.12.1
sentinel==1.0.0 sentinel==1.0.0
six==1.17.0 six==1.17.0
starlette==0.52.1 starlette==0.52.1
+125
View File
@@ -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
)
@@ -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 rio.event import on_populate
from elm.components import ElmButton, CateringItemBox from elm.components import CateringItemBox
from elm.types.CateringTypes import CateringMenuItem, CateringMenuItemCategory from elm.types.CateringTypes import CateringMenuItem, CateringMenuItemCategory
ITEM_CATEGORY_BY_DISPLAY_CATEGORY: dict[Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"], list[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): class CateringCategoryDisplay(Component):
active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"] active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"]
add_to_cart_pressed_callback: Callable
catering_menu_items: list[CateringMenuItem] = [] catering_menu_items: list[CateringMenuItem] = []
@on_populate @on_populate
@@ -28,6 +29,10 @@ class CateringCategoryDisplay(Component):
} }
} }
).to_list() ).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: def build(self) -> Component:
if len(self.catering_menu_items) <= 0: if len(self.catering_menu_items) <= 0:
return Spacer() return Spacer()
@@ -44,7 +49,7 @@ class CateringCategoryDisplay(Component):
stroke_color=self.session.theme.box_border_color, stroke_color=self.session.theme.box_border_color,
), ),
# Items here # 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() Spacer()
), ),
fill=self.session.theme.box_color, fill=self.session.theme.box_color,
+23 -11
View File
@@ -1,18 +1,27 @@
from decimal import Decimal 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.services import AccountingService
from elm.components import ElmButton from elm.types.CateringTypes import CateringMenuItem, CateringModificationKey, CateringModifierOption
from elm.types.CateringTypes import CateringMenuItem, CateringModificationKey
class CateringItemBox(Component): class CateringItemBox(Component):
item: CateringMenuItem item: CateringMenuItem
add_to_cart_pressed_callback: Callable
changed_options: Dict[str, bool] = Dict()
def make_money_string(self, money: Decimal) -> str: def make_money_string(self, money: Decimal) -> str:
return self.session[AccountingService].make_euro_string_from_decimal(money) 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: def build(self) -> Component:
base_mods = [] base_mods = []
extra_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)) base_mods.append(Text("Basis:", fill=self.session.theme.secondary_color, font_size=0.8, margin_top=0.5))
container = FlowContainer(spacing=2.5) container = FlowContainer(spacing=2.5)
for option in modifier_group.options: 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) base_mods.append(container)
if modifier_group.key == CateringModificationKey.EXTRA: 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)) 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}" text = f"{option.label}"
if option.price_delta > Decimal("0"): if option.price_delta > Decimal("0"):
text += f" (+ {self.make_money_string(option.price_delta)})" 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) extra_mods.append(container)
return Rectangle( return Rectangle(
@@ -39,7 +48,7 @@ class CateringItemBox(Component):
Row( Row(
Column( Column(
Row( 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(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), Text(self.item.description, font_size=0.7, margin_left=2),
@@ -51,11 +60,14 @@ class CateringItemBox(Component):
), ),
Column( Column(
Spacer(), Spacer(),
Rectangle( PointerEventListener(
content=Icon(icon="material/add_shopping_cart", min_width=2, min_height=2, margin=1), content=Rectangle(
hover_fill=Color.TRANSPARENT if not self.item.active else self.session.theme.hud_color, content=Icon(icon="material/add_shopping_cart", min_width=2, min_height=2, margin=1),
cursor="not-allowed" if not self.item.active else "pointer", hover_fill=Color.TRANSPARENT if not self.item.active else self.session.theme.hud_color,
transition_time=0.2 cursor="not-allowed" if not self.item.active else "pointer",
transition_time=0.2
),
on_press=self.add_to_cart_pressed
), ),
Spacer() Spacer()
) )
+1
View File
@@ -91,6 +91,7 @@ class UserNavigation(Component):
return Rectangle( return Rectangle(
content=Column( content=Column(
UserNavigationButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_decimal(self.balance)}", "/balance", self.close_navigation), 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 Profil", "/my-profile", self.close_navigation),
UserNavigationButton("Mein Clan", "/my-clans", self.close_navigation), UserNavigationButton("Mein Clan", "/my-clans", self.close_navigation),
UserNavigationButton("Ausloggen", "/logout", self.close_navigation) UserNavigationButton("Ausloggen", "/logout", self.close_navigation)
+1
View File
@@ -11,3 +11,4 @@ from .SeatingPlanPixels import *
from .SeatingPlan import * from .SeatingPlan import *
from .CateringItemBox import CateringItemBox from .CateringItemBox import CateringItemBox
from .CateringCategoryDisplay import CateringCategoryDisplay from .CateringCategoryDisplay import CateringCategoryDisplay
from .CateringCart import CateringCart
+12 -23
View File
@@ -1,26 +1,24 @@
from __future__ import annotations from __future__ import annotations
import csv from typing import Literal
import io
from copy import copy
from typing import Any, Optional, Literal
from uuid import uuid4
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.components import CateringCategoryDisplay, CateringCart
from elm.types.CateringTypes import * from elm.types.CateringTypes import CateringMenuItem, CateringOrderedItem
from elm.services import UserService, LocalData, LocalDataService, ConfigurationService
from elm.components import ElmButton, CateringCategoryDisplay
@page(name="Catering", url_segment="catering") @page(name="Catering", url_segment="catering")
class CateringPage(Component): class CateringPage(Component):
active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"] = "Hauptspeisen" 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: async def on_switcher_bar_change(self, event: SwitcherBarChangeEvent) -> None:
self.active_category = event.value 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: def build(self) -> Component:
return Row( return Row(
@@ -37,7 +35,7 @@ class CateringPage(Component):
stroke_width=0.1, stroke_width=0.1,
stroke_color=self.session.theme.box_border_color 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, grow_x=True,
spacing=1 spacing=1
), ),
@@ -58,18 +56,9 @@ class CateringPage(Component):
stroke_width=0.1, stroke_width=0.1,
stroke_color=self.session.theme.box_border_color stroke_color=self.session.theme.box_border_color
), ),
Rectangle( CateringCart(cart=self.cart, grow_y=True),
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
),
spacing=1, spacing=1,
min_width=18 min_width=20
), ),
spacing=1, spacing=1,
margin=1 margin=1
+46
View File
@@ -124,6 +124,52 @@ class CateringOrderedItem(MongoDecimalModel):
notes: Optional[str] = None 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): class CateringOrder(Document):
customer_id: PydanticObjectId customer_id: PydanticObjectId