Add Catering Management (#4)

Co-authored-by: David Rodenkirchen <davidr.develop@gmail.com>
Reviewed-on: Vereins-IT/ez-lan-manager#4
This commit is contained in:
David Rodenkirchen 2025-02-01 14:23:12 +00:00
parent d86a3da135
commit f0f8a08f87
8 changed files with 222 additions and 10 deletions

View File

@ -171,7 +171,7 @@ if __name__ == "__main__":
"robots": "INDEX,FOLLOW", "robots": "INDEX,FOLLOW",
"description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.", "description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.",
"og:description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.", "og:description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.",
"keywords": "Gaming, Clan, Guild, Verein, Club, Einfach, Zocken, Genuss, Gesellschaft, Videospiele, " "keywords": "Gaming, Clan, Guild, Verein, Club, Einfach, Zocken, Gesellschaft, Videospiele, "
"Videogames, LAN, Party, EZ, LAN, Manager", "Videogames, LAN, Party, EZ, LAN, Manager",
"author": "David Rodenkirchen", "author": "David Rodenkirchen",
"publisher": "EZ GG e.V.", "publisher": "EZ GG e.V.",

View File

@ -25,6 +25,6 @@ class CateringCartItem(Component):
return Row( return Row(
Text(self.ellipsize_string(self.article_name), align_x=0, overflow="wrap", min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), Text(self.ellipsize_string(self.article_name), align_x=0, overflow="wrap", min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)),
Text(AccountingService.make_euro_string_from_int(self.article_price), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), Text(AccountingService.make_euro_string_from_int(self.article_price), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)),
IconButton(icon="material/close", size=2, color=self.session.theme.danger_color, style="plain-text", on_press=lambda: self.remove_item_cb(self.list_id)), IconButton(icon="material/close", min_size=2, color=self.session.theme.danger_color, style="plain-text", on_press=lambda: self.remove_item_cb(self.list_id)),
proportions=(19, 5, 2) proportions=(19, 5, 2)
) )

View File

@ -0,0 +1,100 @@
from functools import partial
from typing import Optional, Callable
from rio import Component, Row, Card, Column, Text, TextStyle, Spacer, PointerEventListener, Button
from src.ez_lan_manager.services.CateringService import CateringService
from src.ez_lan_manager.types.CateringOrder import CateringOrder, CateringOrderStatus
from src.ez_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)
)
class CateringManagementOrderDisplay(Component):
order: CateringOrder
seat: Optional[Seat]
clicked_cb: Callable
def format_order_status(self, status: CateringOrderStatus) -> Text:
status_text = CateringOrder.translate_order_status(status)
color = self.session.theme.warning_color
if status == CateringOrderStatus.DELAYED or status == CateringOrderStatus.CANCELED:
color = self.session.theme.danger_color
elif status == CateringOrderStatus.COMPLETED:
color = self.session.theme.success_color
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
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
if self.order.status != new_status:
if new_status == CateringOrderStatus.CANCELED:
success = await self.session[CateringService].cancel_order(self.order)
else:
success = await self.session[CateringService].update_order_status(self.order.order_id, new_status)
if success:
self.order = CateringOrder(
order_id=self.order.order_id,
order_date=self.order.order_date,
status=new_status,
items=self.order.items,
customer=self.order.customer,
is_delivery=self.order.is_delivery
)
def build(self) -> Component:
return PointerEventListener(
content=Card(
content=Column(
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),
)
),
color=self.session.theme.hud_color,
colorize_on_hover=True,
margin=1
),
on_press=partial(self.clicked_cb, self.order)
)

View File

@ -65,8 +65,9 @@ class ShoppingCartAndOrders(Component):
user_id = self.session[SessionStorage].user_id user_id = self.session[SessionStorage].user_id
cart = self.session[CateringService].get_cart(user_id) cart = self.session[CateringService].get_cart(user_id)
show_popup_task = None
if len(cart) < 1: if len(cart) < 1:
_ = create_task(self.show_popup("Warenkorb leer", True)) show_popup_task = create_task(self.show_popup("Warenkorb leer", True))
else: else:
items_with_amounts: CateringMenuItemsWithAmount = {} items_with_amounts: CateringMenuItemsWithAmount = {}
for item in cart: for item in cart:
@ -78,14 +79,15 @@ class ShoppingCartAndOrders(Component):
await self.session[CateringService].place_order(items_with_amounts, user_id) await self.session[CateringService].place_order(items_with_amounts, user_id)
except CateringError as catering_error: except CateringError as catering_error:
if catering_error.error_type == CateringErrorType.INCLUDES_DISABLED_ITEM: if catering_error.error_type == CateringErrorType.INCLUDES_DISABLED_ITEM:
_ = create_task(self.show_popup("Warenkorb enthält gesperrte Artikel", True)) show_popup_task = create_task(self.show_popup("Warenkorb enthält gesperrte Artikel", True))
elif catering_error.error_type == CateringErrorType.INSUFFICIENT_FUNDS: elif catering_error.error_type == CateringErrorType.INSUFFICIENT_FUNDS:
_ = create_task(self.show_popup("Guthaben nicht ausreichend", True)) show_popup_task = create_task(self.show_popup("Guthaben nicht ausreichend", True))
else: else:
_ = create_task(self.show_popup("Unbekannter Fehler", True)) show_popup_task = create_task(self.show_popup("Unbekannter Fehler", True))
self.session[CateringService].save_cart(self.session[SessionStorage].user_id, []) self.session[CateringService].save_cart(self.session[SessionStorage].user_id, [])
self.order_button_loading = False self.order_button_loading = False
_ = create_task(self.show_popup("Bestellung erfolgreich aufgegeben!", False)) if not show_popup_task:
show_popup_task = create_task(self.show_popup("Bestellung erfolgreich aufgegeben!", False))
async def _create_order_info_modal(self, order: CateringOrder) -> None: async def _create_order_info_modal(self, order: CateringOrder) -> None:
def build_dialog_content() -> rio.Component: def build_dialog_content() -> rio.Component:

View File

@ -65,6 +65,8 @@ class UserInfoBox(Component):
self.session[AccountingService].add_update_hook(self.update) self.session[AccountingService].add_update_hook(self.update)
async def update(self) -> None: async def update(self) -> None:
if not self.user:
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
self.user_balance = await self.session[AccountingService].get_balance(self.user.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_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.user_seat = await self.session[SeatingService].get_user_seat(self.user.user_id)

View File

@ -1,16 +1,60 @@
import logging import logging
from dataclasses import field, dataclass
from datetime import datetime
from typing import Optional
from rio import Column, Component, event, TextStyle, Text, Spacer from rio import Column, Component, event, TextStyle, Text, Spacer, PointerEvent, Button
from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager import ConfigurationService, CateringService, SeatingService
from src.ez_lan_manager.components.CateringManagementOrderDisplay import CateringManagementOrderDisplay
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ez_lan_manager.types.CateringOrder import CateringOrder, CateringOrderStatus
from src.ez_lan_manager.types.Seat import Seat
logger = logging.getLogger(__name__.split(".")[-1]) logger = logging.getLogger(__name__.split(".")[-1])
@dataclass
class CateringOrderWithSeat:
catering_order: CateringOrder
seat: Optional[Seat]
class ManageCateringPage(Component): class ManageCateringPage(Component):
all_orders: list[CateringOrderWithSeat] = field(default_factory=list)
last_updated: Optional[datetime] = None
@event.on_populate @event.on_populate
async def on_populate(self) -> None: async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Catering Verwaltung") await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Catering Verwaltung")
self.all_orders = await self.populate_seating(await self.session[CateringService].get_orders())
self.last_updated = datetime.now()
@event.periodic(30)
async def update_orders(self) -> None:
polled_orders = await self.session[CateringService].get_orders()
self.all_orders = await self.populate_seating(polled_orders)
self.last_updated = datetime.now()
async def populate_seating(self, orders: list[CateringOrder]) -> list[CateringOrderWithSeat]:
result = []
for order in orders:
seat = await self.session[SeatingService].get_user_seat(order.customer.user_id)
result.append(CateringOrderWithSeat(catering_order=order, seat=seat))
return result
def get_all_pending_orders(self) -> list[CateringOrderWithSeat]:
filtered_list = list(filter(lambda o: o.catering_order.status != CateringOrderStatus.COMPLETED and o.catering_order.status != CateringOrderStatus.CANCELED, self.all_orders))
sorted_list = sorted(filtered_list, key=lambda o: o.catering_order.order_date)
return sorted_list
def get_all_completed_orders(self) -> list[CateringOrderWithSeat]:
filtered_list = list(filter(lambda o: o.catering_order.status == CateringOrderStatus.COMPLETED or o.catering_order.status == CateringOrderStatus.CANCELED, self.all_orders))
sorted_list = sorted(filtered_list, key=lambda o: o.catering_order.order_date)
return sorted_list
async def order_clicked(self, order: CateringOrder, _: PointerEvent) -> None:
pass
def build(self) -> Component: def build(self) -> Component:
return Column( return Column(
@ -28,5 +72,51 @@ class ManageCateringPage(Component):
) )
) )
), ),
MainViewContentBox(
Column(
Text(
text="Offene Bestellungen",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=0.2,
align_x=0.5
),
Text(
text=f"Letzte Aktualisierung: {'-' if not self.last_updated else self.last_updated.strftime('%H:%M:%S')}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.7
),
margin_top=0.2,
margin_bottom=0.2,
align_x=0.5
),
Button(
content=Text("Jetzt aktualisieren", style=TextStyle(font_size=0.7), justify="center"),
shape="rectangle",
margin_bottom=1,
on_press=self.update_orders
),
*[CateringManagementOrderDisplay(v.catering_order, v.seat, self.order_clicked) for v in self.get_all_pending_orders()],
)
),
MainViewContentBox(
Column(
Text(
text="Abgeschlossene Bestellungen",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=0.2,
align_x=0.5
),
*[CateringManagementOrderDisplay(v.catering_order, v.seat, self.order_clicked) for v in self.get_all_completed_orders()],
)
),
Spacer() Spacer()
) )

View File

@ -16,7 +16,7 @@ RULES: list[str] = [
AGB: dict[str, list[str]] = { AGB: dict[str, list[str]] = {
"§1": [ "§1": [
"Die Veranstaltung wird von der Einfach Zocken Genuss Gesellschaft e.V. organisiert.", "Die Veranstaltung wird von der Einfach Zocken Gaming Gesellschaft e.V. organisiert.",
"Unser Event verfolgt gemeinnützige Ziele und ist nicht auf Profit ausgerichtet. Die erhobenen Teilnahmebeiträge dienen lediglich der Kostendeckung. Überschüsse werden für die Organisation und Durchführung zukünftiger ähnlicher Veranstaltungen verwendet.", "Unser Event verfolgt gemeinnützige Ziele und ist nicht auf Profit ausgerichtet. Die erhobenen Teilnahmebeiträge dienen lediglich der Kostendeckung. Überschüsse werden für die Organisation und Durchführung zukünftiger ähnlicher Veranstaltungen verwendet.",
"Die Organisatoren haben das Recht, unerwünschte oder störende Personen jederzeit von der Veranstaltung auszuschließen (siehe §3). Im Falle eines Ausschlusses aufgrund eines Regelverstoßes erfolgt keine Rückerstattung des Eintrittspreises." "Die Organisatoren haben das Recht, unerwünschte oder störende Personen jederzeit von der Veranstaltung auszuschließen (siehe §3). Im Falle eines Ausschlusses aufgrund eines Regelverstoßes erfolgt keine Rückerstattung des Eintrittspreises."
], ],

View File

@ -1,6 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from enum import StrEnum from enum import StrEnum
from typing import Optional, Iterable, Self
from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory
from src.ez_lan_manager.types.User import User from src.ez_lan_manager.types.User import User
@ -31,3 +32,20 @@ class CateringOrder:
for item, amount in self.items.items(): for item, amount in self.items.items():
total += (item.price * amount) total += (item.price * amount)
return total return total
@staticmethod
def translate_order_status(status: CateringOrderStatus) -> str:
if status == CateringOrderStatus.RECEIVED:
return "Eingegangen"
elif status == CateringOrderStatus.DELAYED:
return "Verzögert"
elif status == CateringOrderStatus.READY_FOR_PICKUP:
return "Abholbereit"
elif status == CateringOrderStatus.EN_ROUTE:
return "In Zustellung"
elif status == CateringOrderStatus.COMPLETED:
return "Abgeschlossen"
elif status == CateringOrderStatus.CANCELED:
return "Storniert"
else:
raise RuntimeError("Unknown CateringOrderStatus:", status)