diff --git a/src/elm/components/CateringCategoryDisplay.py b/src/elm/components/CateringCategoryDisplay.py new file mode 100644 index 0000000..421c425 --- /dev/null +++ b/src/elm/components/CateringCategoryDisplay.py @@ -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 + ) diff --git a/src/elm/components/CateringItemBox.py b/src/elm/components/CateringItemBox.py new file mode 100644 index 0000000..cf5be33 --- /dev/null +++ b/src/elm/components/CateringItemBox.py @@ -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, + ) + ) diff --git a/src/elm/components/__init__.py b/src/elm/components/__init__.py index 3d6b0c3..ffbc3c5 100644 --- a/src/elm/components/__init__.py +++ b/src/elm/components/__init__.py @@ -9,3 +9,5 @@ from .PersonalInfoBox import PersonalInfoBox from .BuyTicketBox import BuyTicketBox from .SeatingPlanPixels import * from .SeatingPlan import * +from .CateringItemBox import CateringItemBox +from .CateringCategoryDisplay import CateringCategoryDisplay diff --git a/src/elm/pages/CateringPage.py b/src/elm/pages/CateringPage.py new file mode 100644 index 0000000..ed1e217 --- /dev/null +++ b/src/elm/pages/CateringPage.py @@ -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 + ) diff --git a/src/elm/services/DatabaseService.py b/src/elm/services/DatabaseService.py index ebfe018..2de3a32 100644 --- a/src/elm/services/DatabaseService.py +++ b/src/elm/services/DatabaseService.py @@ -4,7 +4,7 @@ from beanie import init_beanie from pymongo import AsyncMongoClient 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 logger = logging.getLogger(__name__.split(".")[-1]) @@ -33,5 +33,5 @@ class DatabaseService: self._users: AsyncCollection = self._database["users"] await init_beanie( database=self._database, - document_models=[User, Transaction, Ticket, Seat] + document_models=[User, Transaction, Ticket, Seat, CateringTypes.CateringMenuItem, CateringTypes.CateringOrder] ) diff --git a/src/elm/types/CateringTypes.py b/src/elm/types/CateringTypes.py new file mode 100644 index 0000000..9fd02b0 --- /dev/null +++ b/src/elm/types/CateringTypes.py @@ -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" diff --git a/src/elm/types/Transaction.py b/src/elm/types/Transaction.py index 3406368..c9f0ce9 100644 --- a/src/elm/types/Transaction.py +++ b/src/elm/types/Transaction.py @@ -4,7 +4,7 @@ from typing import Annotated from beanie import Document, Indexed from bson import Decimal128 -from pydantic import field_validator +from pydantic import field_validator, Field class Transaction(Document): @@ -12,7 +12,9 @@ class Transaction(Document): value: Decimal is_debit: bool title: str - transaction_date: datetime = datetime.now(UTC) + transaction_date: datetime = Field( + default_factory=lambda: datetime.now(UTC) + ) class Settings: name = "transactions" @@ -23,3 +25,4 @@ class Transaction(Document): if isinstance(v, Decimal128): return v.to_decimal() return v + diff --git a/src/elm/types/User.py b/src/elm/types/User.py index e0d552e..9291898 100644 --- a/src/elm/types/User.py +++ b/src/elm/types/User.py @@ -1,7 +1,8 @@ -from datetime import date, datetime +from datetime import date, datetime, UTC from typing import Optional, Annotated from beanie import Document, Indexed +from pydantic import Field class User(Document): @@ -15,7 +16,9 @@ class User(Document): user_birth_day: Optional[date] = None is_active: bool = True is_team_member: bool = False - created_at: datetime = datetime.now() + created_at: datetime = Field( + default_factory=lambda: datetime.now(UTC) + ) class Settings: name = "users" diff --git a/src/elm/types/__init__.py b/src/elm/types/__init__.py index 0d0373f..cbc2c9d 100644 --- a/src/elm/types/__init__.py +++ b/src/elm/types/__init__.py @@ -4,3 +4,4 @@ from .ConfigurationTypes import * from .Transaction import Transaction from .Ticket import Ticket, TicketState from .Seat import Seat +from .ConfigurationTypes import *