WIP: Catering

This commit is contained in:
David Rodenkirchen
2026-05-24 15:56:16 +02:00
parent 4803607e3b
commit 974ac7af3f
9 changed files with 350 additions and 6 deletions
@@ -0,0 +1,53 @@
from typing import Literal
from rio import Component, Rectangle, Column, Spacer, Text, Row, TextInput, FlowContainer
from rio.event import on_populate
from elm.components import ElmButton, 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]] = {
"Frühstück": [CateringMenuItemCategory.BREAKFAST],
"Hauptspeisen": [CateringMenuItemCategory.MAIN_COURSE],
"Snacks & Dessert": [CateringMenuItemCategory.SNACK, CateringMenuItemCategory.DESSERT],
"Softdrinks": [CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC],
"Alkohol": [CateringMenuItemCategory.BEVERAGE_ALCOHOLIC, CateringMenuItemCategory.BEVERAGE_COCKTAIL, CateringMenuItemCategory.BEVERAGE_SHOT]
}
class CateringCategoryDisplay(Component):
active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"]
catering_menu_items: list[CateringMenuItem] = []
@on_populate
async def on_populate(self) -> None:
self.catering_menu_items = await CateringMenuItem.find(
{
"category": {
"$in": ITEM_CATEGORY_BY_DISPLAY_CATEGORY[self.active_category]
}
}
).to_list()
def build(self) -> Component:
if len(self.catering_menu_items) <= 0:
return Spacer()
return Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text(self.active_category, margin=0.5, selectable=False, overflow="wrap"),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
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]),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
)
+67
View File
@@ -0,0 +1,67 @@
from decimal import Decimal
from rio import Component, Rectangle, Column, Text, Row, Separator, Color, Checkbox, FlowContainer, IconButton, Icon, Spacer
from elm.services import AccountingService
from elm.components import ElmButton
from elm.types.CateringTypes import CateringMenuItem, CateringModificationKey
class CateringItemBox(Component):
item: CateringMenuItem
def make_money_string(self, money: Decimal) -> str:
return self.session[AccountingService].make_euro_string_from_decimal(money)
def build(self) -> Component:
base_mods = []
extra_mods = []
if self.item.active:
for modifier_group in self.item.modifier_groups:
if modifier_group.key == CateringModificationKey.BASE:
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))
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))
container = FlowContainer(spacing=2.5)
for option in modifier_group.options:
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))
extra_mods.append(container)
return Rectangle(
content=Column(
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.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),
*base_mods,
*extra_mods,
spacing=0.5,
margin=0.5,
grow_x=True
),
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
),
Spacer()
)
),
Separator(color=self.session.theme.box_border_color),
spacing=0.5,
)
)
+2
View File
@@ -9,3 +9,5 @@ from .PersonalInfoBox import PersonalInfoBox
from .BuyTicketBox import BuyTicketBox from .BuyTicketBox import BuyTicketBox
from .SeatingPlanPixels import * from .SeatingPlanPixels import *
from .SeatingPlan import * from .SeatingPlan import *
from .CateringItemBox import CateringItemBox
from .CateringCategoryDisplay import CateringCategoryDisplay
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import csv
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 elm.types import UserSession, User
from elm.types.CateringTypes import *
from elm.services import UserService, LocalData, LocalDataService, ConfigurationService
from elm.components import ElmButton, CateringCategoryDisplay
@page(name="Catering", url_segment="catering")
class CateringPage(Component):
active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"] = "Hauptspeisen"
async def on_switcher_bar_change(self, event: SwitcherBarChangeEvent) -> None:
self.active_category = event.value
print(event)
def build(self) -> Component:
return Row(
Column(
Rectangle(
content=Column(
Rectangle(
content=SwitcherBar("Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol", margin=0.5, selected_value=self.bind().active_category, on_change=self.on_switcher_bar_change),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
CateringCategoryDisplay(active_category=self.active_category, grow_y=True),
grow_x=True,
spacing=1
),
Column(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Warenkorb", margin=0.5, selectable=False, overflow="wrap"),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
)
),
fill=self.session.theme.box_color,
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
),
spacing=1,
min_width=18
),
spacing=1,
margin=1
)
+2 -2
View File
@@ -4,7 +4,7 @@ from beanie import init_beanie
from pymongo import AsyncMongoClient from pymongo import AsyncMongoClient
from pymongo.asynchronous.collection import AsyncCollection from pymongo.asynchronous.collection import AsyncCollection
from elm.types import User, Transaction, Ticket, Seat from elm.types import User, Transaction, Ticket, Seat, CateringTypes
from elm.types.ConfigurationTypes import DatabaseConfiguration from elm.types.ConfigurationTypes import DatabaseConfiguration
logger = logging.getLogger(__name__.split(".")[-1]) logger = logging.getLogger(__name__.split(".")[-1])
@@ -33,5 +33,5 @@ class DatabaseService:
self._users: AsyncCollection = self._database["users"] self._users: AsyncCollection = self._database["users"]
await init_beanie( await init_beanie(
database=self._database, database=self._database,
document_models=[User, Transaction, Ticket, Seat] document_models=[User, Transaction, Ticket, Seat, CateringTypes.CateringMenuItem, CateringTypes.CateringOrder]
) )
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
from datetime import datetime, UTC
from decimal import Decimal
from enum import StrEnum
from typing import Optional, Annotated
from beanie import Document, PydanticObjectId, Indexed
from bson import Decimal128
from pydantic import BaseModel, Field, field_validator, ConfigDict
class CateringMenuItemCategory(StrEnum):
MAIN_COURSE = "MAIN_COURSE"
DESSERT = "DESSERT"
BEVERAGE_NON_ALCOHOLIC = "BEVERAGE_NON_ALCOHOLIC"
BEVERAGE_ALCOHOLIC = "BEVERAGE_ALCOHOLIC"
BEVERAGE_COCKTAIL = "BEVERAGE_COCKTAIL"
BEVERAGE_SHOT = "BEVERAGE_SHOT"
BREAKFAST = "BREAKFAST"
SNACK = "SNACK"
NON_FOOD = "NON_FOOD"
class CateringOrderStatus(StrEnum):
RECEIVED = "RECEIVED"
DELAYED = "DELAYED"
READY_FOR_PICKUP = "READY_FOR_PICKUP"
EN_ROUTE = "EN_ROUTE"
COMPLETED = "COMPLETED"
CANCELED = "CANCELED"
class CateringModificationKey(StrEnum):
BASE = "base" # For base ingredients that can be deselected, like butter on bread
EXTRA = "extra" # For ingredients that can be added, like ketchup on fries
class MongoDecimalModel(BaseModel):
model_config = ConfigDict(
arbitrary_types_allowed=True,
json_encoders={
Decimal: lambda v: str(v)
}
)
@field_validator("*", mode="before", check_fields=False)
@classmethod
def convert_decimal128(cls, v):
if isinstance(v, Decimal128):
return v.to_decimal()
return v
class CateringModifierOption(MongoDecimalModel):
key: str
label: str
default_selected: bool = False
price_delta: Decimal = Decimal("0.00")
class CateringModifierGroup(MongoDecimalModel):
key: CateringModificationKey
label: str
# True = checkbox group
# False = radio button group
multi_select: bool = True
min_selected: int = 0
max_selected: Optional[int] = None
options: list[CateringModifierOption] = Field(default_factory=list)
class CateringMenuItem(MongoDecimalModel, Document):
name: Annotated[str, Indexed(unique=True)]
category: CateringMenuItemCategory
description: Optional[str] = None
base_price: Decimal = Decimal("0.00")
modifier_groups: list[CateringModifierGroup] = Field(default_factory=list)
active: bool = True
created_at: datetime = Field(
default_factory=lambda: datetime.now(UTC)
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(UTC)
)
class Settings:
name = "catering_menu_items"
class CateringSelectedModifier(MongoDecimalModel):
group_key: str
option_key: str
label: str
selected: bool
price_delta: Decimal = Decimal("0.00")
class CateringOrderedItem(MongoDecimalModel):
menu_item_id: PydanticObjectId
# Snapshot fields
name: str
quantity: int = 1
base_price: Decimal
selected_modifiers: list[CateringSelectedModifier] = Field(
default_factory=list
)
# Final calculated price INCLUDING modifiers
final_unit_price: Decimal
notes: Optional[str] = None
class CateringOrder(Document):
customer_id: PydanticObjectId
items: list[CateringOrderedItem] = Field(default_factory=list)
status: CateringOrderStatus = CateringOrderStatus.RECEIVED
created_at: datetime = Field(
default_factory=lambda: datetime.now(UTC)
)
class Settings:
name = "catering_orders"
+5 -2
View File
@@ -4,7 +4,7 @@ from typing import Annotated
from beanie import Document, Indexed from beanie import Document, Indexed
from bson import Decimal128 from bson import Decimal128
from pydantic import field_validator from pydantic import field_validator, Field
class Transaction(Document): class Transaction(Document):
@@ -12,7 +12,9 @@ class Transaction(Document):
value: Decimal value: Decimal
is_debit: bool is_debit: bool
title: str title: str
transaction_date: datetime = datetime.now(UTC) transaction_date: datetime = Field(
default_factory=lambda: datetime.now(UTC)
)
class Settings: class Settings:
name = "transactions" name = "transactions"
@@ -23,3 +25,4 @@ class Transaction(Document):
if isinstance(v, Decimal128): if isinstance(v, Decimal128):
return v.to_decimal() return v.to_decimal()
return v return v
+5 -2
View File
@@ -1,7 +1,8 @@
from datetime import date, datetime from datetime import date, datetime, UTC
from typing import Optional, Annotated from typing import Optional, Annotated
from beanie import Document, Indexed from beanie import Document, Indexed
from pydantic import Field
class User(Document): class User(Document):
@@ -15,7 +16,9 @@ class User(Document):
user_birth_day: Optional[date] = None user_birth_day: Optional[date] = None
is_active: bool = True is_active: bool = True
is_team_member: bool = False is_team_member: bool = False
created_at: datetime = datetime.now() created_at: datetime = Field(
default_factory=lambda: datetime.now(UTC)
)
class Settings: class Settings:
name = "users" name = "users"
+1
View File
@@ -4,3 +4,4 @@ from .ConfigurationTypes import *
from .Transaction import Transaction from .Transaction import Transaction
from .Ticket import Ticket, TicketState from .Ticket import Ticket, TicketState
from .Seat import Seat from .Seat import Seat
from .ConfigurationTypes import *