9 Commits

Author SHA1 Message Date
Christian 25d1c70a0b Fix incorrect log message in PayPal config loader 2026-06-21 19:15:20 +02:00
David Rodenkirchen 041ddaa334 Add participants page 2026-05-30 09:43:50 +00:00
David Rodenkirchen bbcf18d790 bump version 2026-05-30 09:43:50 +00:00
root 87eb94045c Add Python 3.11 compatibility 2026-05-29 23:17:28 +02:00
David Rodenkirchen 36418470a6 make login more sturdy 2026-05-28 13:16:00 +02:00
David Rodenkirchen 11724ad0d9 improve error handling 2026-05-28 13:10:01 +02:00
David Rodenkirchen edeefe072d Cleanup requirements 2026-05-28 13:08:48 +02:00
David Rodenkirchen 80f331f86b Fix AccountInfoBox setting wrong password 2026-05-28 02:23:29 +02:00
Typhus 1753d67752 prerelease/0.6.0 (#1)
Co-authored-by: David Rodenkirchen <drodenkirchen@linetco.com>
Reviewed-on: #1
2026-05-27 23:17:52 +00:00
56 changed files with 1404 additions and 84 deletions
+14
View File
@@ -0,0 +1,14 @@
FROM python:3.12-bookworm
RUN apt-get update
RUN apt install dumb-init
COPY requirements.txt .
RUN pip install -r requirements.txt
EXPOSE 8000
EXPOSE 8001
EXPOSE 8090
EXPOSE 8091
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
+29
View File
@@ -0,0 +1,29 @@
# ELM (EZGG LAN Manager)
ELM is the successor of [EZGG LAN Manager](https://git.ezgg-ev.de/Vereins-IT/ezgg-lan-manager) with an overhauled UI and a document-oriented database instead of SQL.
# Development Setup
## Local
- Install Python requirements via `pip install -r requirements.txt`.
- Start A MongoDB (Version 8)
- Load clean dump via `mongorestore --uri="mongodb://localhost:27017" /path/to/clean_dump`
- Create user for DB
```js
use admin
db.createUser({
user: "elm_user",
pwd: "elm_password",
roles: [ { role: "root", db: "admin" } ]
})
```
- Edit config accordingly
## Docker
- Change `docker-compose.yaml` entrypoint as needed (remove `--public` and `--release` usually)
- Build and run `docker-compose.yaml`
- Load clean dump via `mongorestore --uri="mongodb://user:password@localhost:27017" /path/to/clean_dump`
+1 -1
View File
@@ -1 +1 @@
0.6.0-prerelease 0.6.1
+4 -1
View File
@@ -11,7 +11,10 @@
discord_invite_link="" discord_invite_link=""
[database] [database]
db_address="mongodb://localhost:27017" database_host="localhost"
database_port="27017"
database_user="root"
database_password="password"
database_name="elm" database_name="elm"
[mailing] [mailing]
+39
View File
@@ -0,0 +1,39 @@
services:
web:
build: .
depends_on:
db:
condition: service_healthy
environment:
PYTHONPATH: /opt/elm
ports:
- "8000:8000"
- "8001:8001"
volumes:
- ./:/opt/elm
entrypoint: ["/bin/sh", "-c", "cd /opt/elm/src && rio run --release --public --port 8000"]
db:
image: mongo:8
restart: unless-stopped
environment:
MONGO_INITDB_ROOT_USERNAME: elm
MONGO_INITDB_ROOT_PASSWORD: elm
MONGO_INITDB_DATABASE: elm
ports:
- "27017:27017"
volumes:
- database:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
volumes:
database:
+8
View File
@@ -0,0 +1,8 @@
# Clean Dump
Contents:
- Catering Menu Items
- Dev user (Typhus)
- Seats for the Donsbach plan
Import with `mongorestore --db elm path/to/clean_dump`
Binary file not shown.
@@ -0,0 +1 @@
{"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"},{"v":{"$numberInt":"2"},"key":{"name":{"$numberInt":"1"}},"name":"name_1","unique":true}],"uuid":"44e7e14f0d2e4aaa8b9b99ed347f23fd","collectionName":"catering_menu_items","type":"collection"}
@@ -0,0 +1 @@
{"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"}],"uuid":"4832101908374f838652eded40a1bc75","collectionName":"catering_orders","type":"collection"}
+1
View File
@@ -0,0 +1 @@
{"ServerVersion":"8.0.23","ToolVersion":"100.17.0"}
Binary file not shown.
@@ -0,0 +1 @@
{"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"}],"uuid":"7531022a88c143b4b6afa363e48c162a","collectionName":"seats","type":"collection"}
Binary file not shown.
@@ -0,0 +1 @@
{"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"}],"uuid":"9a4862b6e9854a008a7970595887323c","collectionName":"tickets","type":"collection"}
@@ -0,0 +1 @@
{"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"},{"v":{"$numberInt":"2"},"key":{"user_name":{"$numberInt":"1"}},"name":"user_name_1"}],"uuid":"7a12a5a29127409cadb8a500203fb97c","collectionName":"transactions","type":"collection"}
Binary file not shown.
@@ -0,0 +1 @@
{"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"},{"v":{"$numberInt":"2"},"key":{"user_name":{"$numberInt":"1"}},"name":"user_name_1","unique":true},{"v":{"$numberInt":"2"},"key":{"user_mail":{"$numberInt":"1"}},"name":"user_mail_1","unique":true}],"uuid":"95b3ec50ea054fd7b9ef0508b423a41d","collectionName":"users","type":"collection"}
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -8,7 +8,7 @@ from from_root import from_root
from rio import App, Theme, Color, Font, Icon, Session from rio import App, Theme, Color, Font, Icon, Session
from elm.components.RootComponent import RootComponent from elm.components.RootComponent import RootComponent
from elm.services import ConfigurationService, DatabaseService, UserService, LocalData, LocalDataService, MailingService, AccountingService from elm.services import ConfigurationService, DatabaseService, UserService, LocalData, LocalDataService, MailingService, AccountingService, PreloadService, ReceiptPrintingService
from elm.types import UserSession from elm.types import UserSession
logger = logging.getLogger("ELM") logger = logging.getLogger("ELM")
@@ -79,7 +79,7 @@ app = App(
theme=theme, theme=theme,
assets_dir=Path(__file__).parent / "assets", assets_dir=Path(__file__).parent / "assets",
build=RootComponent, build=RootComponent,
default_attachments=[LocalData(), configuration_service, database_service, UserService(), LocalDataService(), mailing_service, AccountingService(configuration_service, mailing_service)], default_attachments=[LocalData(), configuration_service, database_service, UserService(), LocalDataService(), mailing_service, AccountingService(configuration_service, mailing_service), PreloadService(), ReceiptPrintingService(configuration_service.get_receipt_printing_configuration(), configuration_service.DEV_MODE_ACTIVE)],
on_app_start=on_app_start, on_app_start=on_app_start,
on_session_start=on_session_start, on_session_start=on_session_start,
icon=from_root("src/elm/assets/img/favicon.png"), icon=from_root("src/elm/assets/img/favicon.png"),
@@ -0,0 +1,3 @@
# Preloaded content
The files in this directory are loaded upon app start, as they do not change during runtime.
+47
View File
@@ -0,0 +1,47 @@
Wie melde ich mich für die LAN an?
Registriere dich auf dieser Seite, lade dein Guthabenkonto auf und kaufe ein Ticket. Danach such dir einen freien Sitzplatz auf dem Sitzplan aus.
Wie lade ich mein Guthabenkonto auf?
Logge dich in deinen Account ein und klicke oben rechts auf dein Profilbild, von dort auf die Schaltfläche 'Guthaben' in der Navigationsleiste. Dort findest du alle weiteren Informationen.
Wie kann ich mein Ticket stornieren?
Schreibe uns eine Mail an tech@ezgg-ev.de, wir kümmern uns dann Zeitnah um die Stornierung.
Was soll ich zur LAN mitbringen?
Deinen PC inklusive aller zugehörigen Geräte (Maus, Tastatur, Monitor, Headset), sowie aller Anschlusskabel. Wir empfehlen ein LAN Kabel von mindestens 5 Metern Länge mitzubringen. Des weiteren benötigste du eine Mehrfachsteckdose, da dir an deinem Platz nur ein einzelner Steckplatz zugewiesen wird.
Wohin mit technischen Problemen?
Melde dich einfach am Einlass bzw in der Orga-Ecke, wir helfen gerne weiter.
Wo entsorge ich meinen Müll?
Im gesamten Gebäude findest du Mülltüten/Mülleimer.
Darf ich Cannabis konsumieren?
Generell verbieten wir den Konsum von Cannabis nicht. Beachte aber die allgemeine Gesetzeslage und ziehe ggf. die Bubatzkarte zu Rat.
Gibt es einen Discord oder TeamSpeak?
Du kannst gerne unseren Vereins-TeamSpeak3-Server unter ts3.ezgg-ev.de nutzen. Den Link zum offiziellen Discord findest du in der Kopfleiste.
Wo bleibt mein Essen?
Vermutlich ist es auf dem Weg. Du kannst auf der Catering-Seite den Status deiner Bestellung überprüfen. Hast du Bedenken das sie verloren gegangen sein könnte, sprich ein Team-Mitglied an der Theke darauf an.
Wie lange dauert eine Aufladung per Überweißung?
In der Regel wird das Guthaben deinem Konto innerhalb von 2 bis 3 Werktagen gutgeschrieben. In Ausnahmefällen kann es bis zu 7 Tagen dauern.
Wie melde ich meinen Clan an?
Wenn in deiner Gruppe mehr als 3 Personen sind, dann schreib uns bitte eine Mail mit dem Betreff 'Gruppenticket' an tech@ezgg-ev.de. Schreibe uns dort die Nutzer-ID's sowie die Sitzplätze deiner Gruppe auf. Gehe sicher das jede Person in deiner Gruppe entweder bereits ein passendes Ticket besitzt oder über genug Guthaben verfügt um ein Ticket zu kaufen.
Wo kann ich schlafen?
Im Gebäude sind offizielle Schlafbereiche ausgewiesen. Solange du keine Zugangs-, Durchgangs-, oder Rettungswege blockierst, darfst du überall schlafen.
+28
View File
@@ -0,0 +1,28 @@
{
"§1 Allgemeine Bestimmungen": [
"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.",
"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."
],
"§2 Teilnahmevoraussetzungen": [
"Die Teilnahme an der Veranstaltung ist nur Personen gestattet, die mindestens 18 Jahre alt sind. Ein amtlicher Altersnachweis ist erforderlich. Kann dieser Nachweis nicht erbracht werden, wird der Zugang zur Veranstaltung verweigert.",
"Jeder Teilnehmer muss die Teilnahmegebühr entrichtet haben und dies auf Anfrage nachweisen können. Mit der Bezahlung des Eintrittspreises erhält der Teilnehmer einen garantierten Platz auf der Veranstaltung.",
"Alle Teilnehmer sind verpflichtet, vor der Veranstaltung sicherheitsrelevante Patches und Updates für Betriebssysteme und Spiele einzuspielen. Es wird nicht garantiert, dass diese während der Veranstaltung heruntergeladen werden können."
],
"§3 Verhaltensregeln": [
"Innerhalb des Veranstaltungsgebäudes gilt ein striktes Rauchverbot.",
"Jeder Teilnehmer verpflichtet sich, während der Veranstaltung keine illegalen Handlungen durchzuführen.",
"Die unautorisierte Verbreitung von urheberrechtlich geschütztem Material ist strengstens untersagt.",
"Der Veranstalter übernimmt keine Haftung für Schäden an Geräten oder Daten der Teilnehmer, es sei denn, der Veranstalter oder seine Erfüllungsgehilfen haben die Schäden vorsätzlich oder grob fahrlässig verursacht. Ebenso wird keine Haftung bei Diebstahl oder Verlust persönlicher Gegenstände übernommen.",
"Teilnehmer dürfen den Ablauf der Veranstaltung nicht absichtlich stören, insbesondere nicht den Betrieb des Computer- und Stromnetzwerks. Als absichtliche Störung zählt auch die Nutzung von Software, die dem Spieler einen unfairen Vorteil verschafft (z.B. Cheats, Hacks) sowie das Ausnutzen von Bugs in Spielen, um einen Vorteil zu erzielen. Solche Verstöße führen zum sofortigen Ausschluss aus allen Turnieren. Betrifft der Verstoß ein Teammitglied, wird das gesamte Team disqualifiziert, auch wenn die anderen Mitglieder nicht direkt beteiligt waren. Wiederholte oder schwerwiegende Verstöße können zum Ausschluss von der gesamten Veranstaltung führen.",
"Die Nutzung von Aktivlautsprechern ist verboten, Kopfhörer sind Pflicht.",
"Verursacht ein Teilnehmer Schäden, haftet er vollumfänglich für die entstehenden Kosten.",
"Teilnehmer sind dazu verpflichtet, nach der Veranstaltung ihren Platz aufzuräumen und persönliche Gegenstände mitzunehmen."
],
"§4 Internetzugang": [
"Der Veranstalter stellt während der Veranstaltung einen eingeschränkten Internetzugang zur Verfügung. Es wird jedoch keine Garantie für die Verfügbarkeit, Eignung oder Zuverlässigkeit des Zugangs übernommen. Der Veranstalter behält sich das Recht vor, den Zugang zeitweise oder vollständig einzuschränken oder zu sperren sowie bestimmte Dienste oder Websites zu blockieren.",
"Für alle über das Internet getätigten Aktivitäten, Datenübertragungen und Rechtsgeschäfte ist der Teilnehmer allein verantwortlich. Entstehende Kosten durch die Nutzung von Drittanbieterdiensten trägt der Teilnehmer. Es gilt das Einhalten der gesetzlichen Bestimmungen.",
"Der Teilnehmer stellt den Veranstalter von jeglichen Ansprüchen Dritter frei, die aus einer rechtswidrigen Nutzung des Internetzugangs oder einem Verstoß gegen diese Vereinbarung resultieren. Diese Freistellung schließt auch die Kosten für die Abwehr solcher Ansprüche ein.",
"Der Veranstalter behält sich das Recht vor, die Nutzung des Internetzugangs zu protokollieren, um im Bedarfsfall Beweise für die Nutzung durch bestimmte Teilnehmer vorzulegen und den Veranstalter vor Schäden zu schützen."
]
}
@@ -0,0 +1,27 @@
Angaben gemäß § 5 TMG:
Einfach Zocken Gaming Gesellschaft e.V.
Im Elchgrund 18
35080 Bad Endbach - Bottenhorn
Vertreten durch:
1. Vorsitzender: David Rodenkirchen
2. Vorsitzender: Julia Albring
Schatzmeisterin: Jessica Rodenkirchen
Kontakt:
E-Mail: vorstand (at) ezgg-ev.de
Registereintrag:
Eingetragen im Vereinsregister.
Registergericht: Amtsgericht Marburg
Registernummer: VR 5837
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:
David Rodenkirchen
Im Elchgrund 18
35080 Bad Endbach - Bottenhorn
@@ -0,0 +1,8 @@
Respektvolles Verhalten: Sei höflich und respektvoll gegenüber anderen Gästen und dem Team.
Alkohol und Drogen: Konsumiere Alkohol in Maßen und halte dich an die gültige Gesetzeslage.
Sitzplätze: Respektiere die zugewiesenen Plätze und ändere sie nicht ohne Genehmigung.
Notausgänge und Sicherheitsvorschriften: Informiere dich über die Notausgänge und beachte die Sicherheitsanweisungen.
Müllentsorgung: Benutze die vorgesehenen Mülleimer und halte den Veranstaltungsort sauber.
Rauchen: Halte dich an die Rauchverbote und benutze nur die ausgewiesenen Raucherbereiche.
Hausrecht: Folge den Anweisungen des Veranstalters und des Sicherheitspersonals.
Illegales: Das brechen des deutschen Rechts, insbesondere des Urheberrechts, bleibt auch auf LAN verboten.
+22 -12
View File
@@ -4,7 +4,7 @@ from bson import ObjectId
from rio import Component, Rectangle, Column, Text, Row, PointerEventListener, TextInput from rio import Component, Rectangle, Column, Text, Row, PointerEventListener, TextInput
from rio.event import on_populate from rio.event import on_populate
from elm.types import UserSession, Ticket, Seat from elm.types import UserSession, Ticket, Seat, User
from elm.components import ElmButton from elm.components import ElmButton
from elm.services import UserService from elm.services import UserService
@@ -18,19 +18,25 @@ class AccountInfoBox(Component):
password_change_in_progress: bool = False password_change_in_progress: bool = False
ticket: Optional[Ticket] = None ticket: Optional[Ticket] = None
seat: Optional[Seat] = None seat: Optional[Seat] = None
fixed_user: Optional[User] = None
@on_populate @on_populate
async def on_populate(self) -> None: async def on_populate(self) -> None:
try: if self.fixed_user is None:
user = await self.session[UserService].get_user(self.session[UserSession].user_name) try:
if user: user = await self.session[UserService].get_user(self.session[UserSession].user_name)
self.mail = user.user_mail if user:
self.ticket = await Ticket.find_one({"owner.$id": user.id}) self.mail = user.user_mail
self.seat = await Seat.find_one({"user.$id": ObjectId(user.id)}) self.ticket = await Ticket.find_one({"owner.$id": user.id})
else: self.seat = await Seat.find_one({"user.$id": ObjectId(user.id)})
else:
self.session.navigate_to("./login")
except KeyError:
self.session.navigate_to("./login") self.session.navigate_to("./login")
except KeyError: else:
self.session.navigate_to("./login") self.mail = self.fixed_user.user_mail
self.ticket = await Ticket.find_one({"owner.$id": self.fixed_user.id})
self.seat = await Seat.find_one({"user.$id": ObjectId(self.fixed_user.id)})
async def set_new_password(self) -> None: async def set_new_password(self) -> None:
self.password_change_in_progress = True self.password_change_in_progress = True
@@ -43,7 +49,11 @@ class AccountInfoBox(Component):
self.password_change_in_progress = False self.password_change_in_progress = False
return return
user = await self.session[UserService].get_user(self.session[UserSession].user_name) if self.fixed_user is not None:
user = self.fixed_user
else:
user = await self.session[UserService].get_user(self.session[UserSession].user_name)
if not user: if not user:
self.account_info_is_error = True self.account_info_is_error = True
self.account_info_text = "Unbekannter Fehler" self.account_info_text = "Unbekannter Fehler"
@@ -86,7 +96,7 @@ class AccountInfoBox(Component):
stroke_color=self.session.theme.box_border_color, stroke_color=self.session.theme.box_border_color,
), ),
Column( Column(
TextInput(text=self.session[UserSession].user_name, label="Nutzername", is_sensitive=False), TextInput(text=self.fixed_user.user_name if self.fixed_user is not None else self.session[UserSession].user_name, label="Nutzername", is_sensitive=False),
TextInput(text=self.mail, label="E-Mail Adresse", is_sensitive=False), TextInput(text=self.mail, label="E-Mail Adresse", is_sensitive=False),
row_col( row_col(
PointerEventListener( PointerEventListener(
+127
View File
@@ -0,0 +1,127 @@
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, ReceiptPrintingService
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
self.session.create_task(self.session[ReceiptPrintingService].print_order(user, new_order))
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()
) )
+11 -7
View File
@@ -47,14 +47,18 @@ class LandingPageBoxHalf(Component):
stroke_color=self.session.theme.box_border_color, stroke_color=self.session.theme.box_border_color,
), ),
Row( Row(
Rectangle( Column(
content=Image( Rectangle(
from_root(f"src/elm/assets/img/{self.image_name}") content=Image(
from_root(f"src/elm/assets/img/{self.image_name}")
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
margin_right=1,
min_width=4 if self.session.is_mobile() else 10,
min_height=4 if self.session.is_mobile() else 10,
), ),
stroke_width=0.1, Spacer()
stroke_color=self.session.theme.box_border_color,
margin_right=1,
min_width=4 if self.session.is_mobile() else 10
), ),
Text( Text(
self.article_text, self.article_text,
+4 -2
View File
@@ -37,7 +37,8 @@ class NavigationButton(Component):
else: else:
self.session.navigate_to(self.target_url) self.session.navigate_to(self.target_url)
await self.extension_state_changed(None) if self.extension_state_changed is not None:
await self.extension_state_changed(None)
def is_active(self) -> bool: def is_active(self) -> bool:
@@ -93,13 +94,14 @@ class NavigationBar(Component):
NavigationButton("material/house", "Startseite", "/", extension_state_changed=self.on_extension_pressed), NavigationButton("material/house", "Startseite", "/", extension_state_changed=self.on_extension_pressed),
NavigationButton("material/local_activity", "Tickets", "/tickets", extension_state_changed=self.on_extension_pressed), NavigationButton("material/local_activity", "Tickets", "/tickets", extension_state_changed=self.on_extension_pressed),
NavigationButton("material/chair_alt", "Sitzplan", "/seating", extension_state_changed=self.on_extension_pressed), NavigationButton("material/chair_alt", "Sitzplan", "/seating", extension_state_changed=self.on_extension_pressed),
NavigationButton("material/group", "Teilnehmer", "/participants", extension_state_changed=self.on_extension_pressed),
NavigationButton("material/local_dining", "Catering", "/catering", extension_state_changed=self.on_extension_pressed), NavigationButton("material/local_dining", "Catering", "/catering", extension_state_changed=self.on_extension_pressed),
NavigationButton("material/trophy", "Turniere", "/tournaments", extension_state_changed=self.on_extension_pressed), NavigationButton("material/trophy", "Turniere", "/tournaments", extension_state_changed=self.on_extension_pressed),
margin_bottom=6 margin_bottom=6
), ),
Column( Column(
NavigationButton("material/help", "FAQ", "/faq", extension_state_changed=self.on_extension_pressed), NavigationButton("material/help", "FAQ", "/faq", extension_state_changed=self.on_extension_pressed),
NavigationButton("material/contact_page", "Kontakt", "/contact", extension_state_changed=self.on_extension_pressed), NavigationButton("material/contact_page", "Kontakt", "mailto:tech@ezgg-ev.de?subject=Kontaktanfrage", new_tab=True, extension_state_changed=self.on_extension_pressed),
NavigationButton("material/sports_bar", "EZ GG e.V.", "https://ezgg-ev.de", new_tab=True, extension_state_changed=None), NavigationButton("material/sports_bar", "EZ GG e.V.", "https://ezgg-ev.de", new_tab=True, extension_state_changed=None),
NavigationButton("material/article", "Impressum", "/imprint", extension_state_changed=self.on_extension_pressed), NavigationButton("material/article", "Impressum", "/imprint", extension_state_changed=self.on_extension_pressed),
NavigationButton("material/balance", "Regeln & AGB", "/rules", extension_state_changed=self.on_extension_pressed), NavigationButton("material/balance", "Regeln & AGB", "/rules", extension_state_changed=self.on_extension_pressed),
+1 -1
View File
@@ -167,7 +167,7 @@ class SeatingPlan(Component):
grid.add(TextPixel(text="G\ne\nt\nr\nä\nn\nk\ne"), row=20, column=40, width=4, height=12) grid.add(TextPixel(text="G\ne\nt\nr\nä\nn\nk\ne"), row=20, column=40, width=4, height=12)
# Main Entrance # Main Entrance
grid.add(TextPixel(text="H\na\nl\nl\ne\nn\n\nE\ni\nn\ng\na\nn\ng"), row=33, column=76, width=4, height=27) grid.add(TextPixel(text="H\na\nl\nl\ne\nn\n\nE\ni\nn\ng\na\nn\ng"), row=33, column=66, width=4, height=27)
# Sleeping # Sleeping
grid.add(TextPixel(icon_name="material/bed"), row=1, column=1, width=30, height=14) grid.add(TextPixel(icon_name="material/bed"), row=1, column=1, width=30, height=14)
+3 -2
View File
@@ -20,8 +20,9 @@ class SeatPixel(Component):
self.associated_user = await self.seat.user.fetch() self.associated_user = await self.seat.user.fetch()
async def on_press(self, _: PointerEvent) -> None: async def on_press(self, _: PointerEvent) -> None:
self.session.navigate_to(f"./seat-info?seat_id={self.seat_id.replace("\n", "")}") seat_id = self.seat_id.replace("\n", "")
self.session.navigate_to(f"./seat-info?seat_id={seat_id}")
def determine_color(self) -> Color: def determine_color(self) -> Color:
if self.seat is not None: if self.seat is not None:
try: try:
+25 -6
View File
@@ -87,14 +87,33 @@ class UserNavigation(Component):
async def on_populate(self) -> None: async def on_populate(self) -> None:
self.session.create_task(self.update_balance()) self.session.create_task(self.update_balance())
def show_admin_navigation(self) -> bool:
try:
user_session = self.session[UserSession]
except KeyError:
return False
return user_session.is_team_member
def build(self) -> Component: def build(self) -> Component:
base_nav = [
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)
]
if self.show_admin_navigation():
base_nav.extend([
UserNavigationButton("Admin: Benutzer", "/user-admin", self.close_navigation),
UserNavigationButton("Admin: Catering", "/catering-admin", self.close_navigation)
])
base_nav.append(
UserNavigationButton("Ausloggen", "/logout", self.close_navigation)
)
return Rectangle( return Rectangle(
content=Column( content=Column(*base_nav),
UserNavigationButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_decimal(self.balance)}", "/balance", 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)
),
min_width=3.5, min_width=3.5,
min_height=3.5, min_height=3.5,
fill=self.session.theme.background_color fill=self.session.theme.background_color
+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
+224
View File
@@ -0,0 +1,224 @@
from __future__ import annotations
import logging
from asyncio import sleep
from functools import partial
from typing import Optional
from decimal import Decimal
from beanie import PydanticObjectId
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, GuardEvent, FlowContainer, List, PointerEventListener, Overlay, Link, Switch, SwitchChangeEvent
from rio.event import on_populate
from elm.types import UserSession, User, Seat
from elm.services import AccountingService, ReceiptPrintingService
from elm.components import ElmButton
from elm.types.CateringTypes import CateringOrder, CateringOrderStatus, CateringMenuItem, CateringMenuItemCategory
logger = logging.getLogger(__name__.split(".")[-1])
def catering_admin_page_guard(event: GuardEvent) -> Optional[str]:
try:
if event.session[UserSession].is_team_member:
return None
return "/"
except KeyError:
return "/"
@page(name="Cateringverwaltung", url_segment="catering-admin", guard=catering_admin_page_guard)
class CateringAdminPage(Component):
open_orders: List[CateringOrder] = List()
all_users: list[User] = []
all_seats: list[Seat] = []
all_menu_items: list[CateringMenuItem] = []
edited_order: Optional[CateringOrder] = None
@on_populate
async def on_populate(self) -> None:
self.all_users = await User.find_all().to_list()
self.all_seats = await Seat.find_all(fetch_links=True).to_list()
self.all_menu_items = await CateringMenuItem.find_all(fetch_links=True).to_list()
self.open_orders = List(await CateringOrder.find_many(
{
"status": {
"$nin": [
CateringOrderStatus.COMPLETED,
CateringOrderStatus.CANCELED,
]
}
}
).to_list())
await sleep(5)
self.session.create_task(self.on_populate())
def get_name_for_user_id(self, id_: PydanticObjectId) -> str:
return next(filter(lambda user: user.id == id_ ,self.all_users)).user_name
def get_seat_for_user_id(self, id_: PydanticObjectId) -> str:
try:
found_seat: Optional[Seat] = next(filter(lambda seat: seat.user is not None and seat.user.id == id_, self.all_seats), None)
if found_seat:
return found_seat.seat_id
return "-"
except Exception:
return "-"
async def on_order_pressed(self, order: CateringOrder) -> None:
self.edited_order = order
async def change_order_status(self, new_status: CateringOrderStatus) -> None:
if not self.edited_order:
return
if new_status == CateringOrderStatus.CANCELED:
pass
if self.edited_order.status == new_status:
self.edited_order = None
return
if new_status == CateringOrderStatus.CANCELED:
user = await User.find_one(User.id == self.edited_order.customer_id)
if not user:
self.edited_order = None
return
price = Decimal(0)
for item in self.edited_order.items:
price += item.final_unit_price
await self.session[AccountingService].add_balance(user.user_name, price, f"CATERING REFUND - {str(self.edited_order.id)[-5:]}")
self.edited_order.status = new_status
await self.edited_order.save()
self.open_orders = List(await CateringOrder.find_many(
{
"status": {
"$nin": [
CateringOrderStatus.COMPLETED,
CateringOrderStatus.CANCELED,
]
}
}
).to_list())
self.edited_order = None
async def print_receipt(self) -> None:
if not self.edited_order:
return
user = await User.find_one(User.id == self.edited_order.customer_id)
if not user:
self.edited_order = None
return
self.session.create_task(self.session[ReceiptPrintingService].print_order(user, self.edited_order))
self.edited_order = None
@staticmethod
async def change_item_active(event: SwitchChangeEvent, item: CateringMenuItem) -> None:
item.active = event.is_on
await item.save()
def build(self) -> Component:
if self.edited_order:
overlay = [
Overlay(
content=Rectangle(
content=Column(
Text(f"Status ändern - Bestellung {str(self.edited_order.id)[-5:]}", margin_bottom=1),
*[ElmButton(text=CateringOrder.translate_order_status(status), on_press=partial(self.change_order_status, status)) for status in CateringOrderStatus],
Row(ElmButton(text="Bon drucken", on_press=self.print_receipt), ElmButton(text="Abbrechen", on_press=lambda: self.__setattr__("edited_order", None)), spacing=1, margin_top=2),
spacing=0.5,
margin=1
),
fill=self.session.theme.box_color,
stroke_width=0.2,
stroke_color=self.session.theme.box_border_color,
align_x=0.5,
align_y=0.5
)
)
]
else:
overlay = []
return Row(
*overlay,
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text("Offene Bestellungen", margin=0.5, selectable=False, overflow="wrap"),
Link(content="Neue Bestellung", target_url="./new-pos-order")
),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
FlowContainer(
*[PointerEventListener(
content=Rectangle(
content=Column(
Row(Text(f"ID:", font_size=1.2), Text(str(order.id)[-5:], justify="right", font_size=1.2)),
Row(Text("Nutzer:", font_size=1.2), Text(self.get_name_for_user_id(order.customer_id), font_size=1.2, justify="right")),
Row(Text(f"Sitzplatz:", font_size=1.2), Text(self.get_seat_for_user_id(order.customer_id), font_size=1.2, justify="right")),
Row(Text(f"Status:", font_size=1.2), Text(CateringOrder.translate_order_status(order.status), font_size=1.2, justify="right"), margin_bottom=2),
*[Text(item.name, overflow="ellipsize") for item in order.items],
margin=0.5,
spacing=0.2
),
stroke_color=self.session.theme.primary_color,
stroke_width=0.1,
cursor="pointer",
hover_stroke_color=self.session.theme.warning_color,
hover_stroke_width=0.1,
min_width=30
),
on_press=lambda event, order=order: self.on_order_pressed(order),
) for order in self.open_orders],
Spacer(),
spacing=1,
margin=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_width=25,
grow_x=True,
margin_right=1
),
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Speisekarte", 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,
),
Column(
*[Column(
Text(text=category.value, margin_bottom=1, margin_top=0.5, fill=self.session.theme.primary_color),
*[Rectangle(
content=Row(Text(text=item.name, overflow="ellipsize", grow_x=True), Switch(is_on=item.active, on_change=lambda event, item=item: self.change_item_active(event, item)), margin=0.1),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
) for item in filter(lambda i: i.category == category, self.all_menu_items)],
spacing=0.5
) for category in CateringMenuItemCategory],
spacing=0.5,
margin=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_width=25
),
margin=1
)
+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
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from rio import Component, Column, Row, Text, Spacer, page, Rectangle
from elm.services import PreloadService
@page(name="FAQ", url_segment="faq")
class FaqPage(Component):
def build(self) -> Component:
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("F.A.Q.", 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,
),
*[Column(
Text(q_a[0], font_weight="bold", overflow="wrap", fill=self.session.theme.primary_color),
Text(q_a[1], italic=True, overflow="wrap"),
spacing=0.5,
margin_bottom=1.5,
margin=1
) for q_a in self.session[PreloadService].faq],
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
grow_x=True,
grow_y=True,
margin=0 if self.session.is_mobile() else 1
)
+1 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from _sha2 import sha256 from hashlib import sha256
from random import choices from random import choices
from typing import Any, Optional from typing import Any, Optional
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from rio import Component, Column, Row, Text, page, Rectangle, CodeBlock, Link
from elm.services import PreloadService
@page(name="Imprint", url_segment="imprint")
class ImprintPage(Component):
def build(self) -> Component:
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Impressum & DSGVO", 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,
),
CodeBlock(
code=self.session[PreloadService].imprint,
show_controls=False,
language="text",
scroll_code_x="auto",
margin=1,
grow_y=True
),
Link(
content="Datenschutzerklärung",
target_url="https://ezgg-ev.de/privacy", # ToDo: Move into this app and make preloaded content
open_in_new_tab=True,
margin=1
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
grow_x=True,
grow_y=True,
margin=0 if self.session.is_mobile() else 1
)
+1 -1
View File
@@ -11,7 +11,7 @@ class LandingPage(Component):
full_box = LandingPageBoxFull( full_box = LandingPageBoxFull(
image_name="news_image.jpg", image_name="news_image.jpg",
heading_text="EZGG LAN geht in die 2. Runde", heading_text="EZGG LAN geht in die 2. Runde",
article_text="Am 23.04.2027 ist es soweit. Dann findet die EZGG LAN in der zweiten Edition statt. Es erwarten euch viele Verbesserungen zur letzten Edition und wir hoffen euch auch dieses mal begrüßen zu dürfen.", article_text="Am 04.06.2027 ist es soweit. Dann findet die EZGG LAN in der zweiten Edition statt. Es erwarten euch viele Verbesserungen zur letzten Edition und wir hoffen euch auch dieses mal begrüßen zu dürfen.",
date="15.05.26" date="15.05.26"
) )
half_box_1 = LandingPageBoxHalf( half_box_1 = LandingPageBoxHalf(
+3
View File
@@ -32,6 +32,9 @@ class LoginPage(Component):
self.login_in_progress = True self.login_in_progress = True
user_name = copy(self.user_name) # Prevents race condition name swap user_name = copy(self.user_name) # Prevents race condition name swap
is_valid = await self.session[UserService].is_login_valid(user_name, self.password) is_valid = await self.session[UserService].is_login_valid(user_name, self.password)
if not is_valid: # Migrated users
user_name = user_name.lower().capitalize()
is_valid = await self.session[UserService].is_login_valid(user_name, self.password)
if is_valid: if is_valid:
user: User = await self.session[UserService].get_user(user_name) user: User = await self.session[UserService].get_user(user_name)
self.error_on_last_attempt = False self.error_on_last_attempt = False
+1 -1
View File
@@ -26,7 +26,7 @@ class TransactionRow(Component):
return Rectangle( return Rectangle(
content=Row( content=Row(
Text( Text(
f"{self.transaction_time.strftime("%d.%m.%y")} /", f"{self.transaction_time.strftime('%d.%m.%y')} /",
justify="left", justify="left",
font_size=0.8, font_size=0.8,
margin_left=0.5, margin_left=0.5,
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from typing import Optional
from rio import Component, Column, Spacer, page, GuardEvent, Rectangle, Text, FlowContainer, Color, ProgressCircle
from rio.event import on_populate
from elm.services import UserService
from elm.types import UserSession
from elm.types.CateringTypes import CateringOrder, CateringOrderStatus
def my_orders_page_guard(event: GuardEvent) -> Optional[str]:
try:
_ = event.session[UserSession].user_name
return None
except KeyError:
return "/"
@page(name="My Orders", url_segment="my-orders", guard=my_orders_page_guard)
class MyOrdersPage(Component):
orders: list[CateringOrder] = []
is_loading: bool = True
@on_populate
async def on_populate(self) -> None:
user = await self.session[UserService].get_user(self.session[UserSession].user_name)
if not user:
self.is_loading = False
return
self.orders = await CateringOrder.find_many(CateringOrder.customer_id == user.id).to_list()
self.is_loading = False
def get_status_color(self, status: CateringOrderStatus) -> Color:
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 color
def build(self) -> Component:
if self.is_loading:
return ProgressCircle(margin=self.session.screen_width // 5)
return Column(
FlowContainer(
*[Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text(f"Bestellung\n\n{str(order.id)[-5:]}", margin=0.5, selectable=False, overflow="wrap", justify="center"),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
*[Text(item.name, overflow="wrap") for item in order.items],
spacing=1,
margin=1
),
Spacer(),
Text(CateringOrder.translate_order_status(order.status), fill=self.get_status_color(order.status), margin=1, font_weight="bold", font_size=1, justify="center"),
),
fill=self.session.theme.box_color,
min_width=18
) for order in self.orders],
spacing=1
),
Spacer(),
spacing=1,
margin=1
)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from copy import copy
from typing import Any, Optional
from uuid import uuid4
from rio import Component, Column, Row, Text, Spacer, page, Color, Rectangle, TextInput, GuardEvent
from rio.event import on_populate
from elm.types import UserSession, User, Ticket, Seat
from elm.services import UserService, LocalData, LocalDataService, ConfigurationService
from elm.components import ElmButton
@page(name="Participants", url_segment="participants")
class ParticipantsPage(Component):
participants: list[tuple[User, Seat]] = []
@on_populate
async def on_populate(self) -> None:
seats = await Seat.find_many(
Seat.user != None,
fetch_links=True
).to_list()
self.participants = [(seat.user, seat) for seat in seats]
def build(self) -> Component:
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Teilnehmer", 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,
),
Column(
Row(
Text("Nutzer", grow_x=True, font_weight="bold"),
Text("Sitzplatz", font_weight="bold"),
margin=0.5
),
*[
Rectangle(
content=Row(
Text(user.user_name, grow_x=True, font_size=0.8),
Text(seat.seat_id, font_size=0.8),
margin=0.5
),
hover_fill=self.session.theme.secondary_color,
transition_time=0.2
) for user, seat in self.participants],
margin=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
margin=1,
grow_x=True
)
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from rio import Component, Column, Text, page, Rectangle
from elm.services import PreloadService
@page(name="Rules", url_segment="rules")
class RulesPage(Component):
def build(self) -> Component:
return Column(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Regeln", 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,
),
*[Column(
Text(
rule.split(":")[0],
font_size=0.8 if self.session.is_mobile() else 1.2,
fill=self.session.theme.primary_color,
overflow="wrap"
),
Text(
rule.split(":")[1].strip(),
font_size=0.7 if self.session.is_mobile() else 1,
overflow="wrap",
margin_left=1
),
margin=1,
spacing=1
) for rule in self.session[PreloadService].rules]
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("AGB", 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,
),
*[Column(
Text(
text=paragraph,
font_size=0.8 if self.session.is_mobile() else 1.2,
fill=self.session.theme.primary_color,
overflow="wrap"
),
*[Text(
section,
font_size=0.7 if self.session.is_mobile() else 1,
overflow="wrap",
margin_left=1
) for section in self.session[PreloadService].gtc[paragraph]],
margin=1,
spacing=1
) for paragraph in self.session[PreloadService].gtc]
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
grow_x=True,
grow_y=True,
spacing=1,
margin=0 if self.session.is_mobile() else 1
)
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import logging
from functools import partial
from typing import Optional
from decimal import Decimal
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, TextInput, GuardEvent, Button, TextInputChangeEvent, NumberInput, IconButton
from rio.event import on_populate
from elm.types import UserSession, User, Transaction
from elm.services import AccountingService, MailingService
from elm.components import AccountInfoBox
logger = logging.getLogger(__name__.split(".")[-1])
def user_admin_page_guard(event: GuardEvent) -> Optional[str]:
try:
if event.session[UserSession].is_team_member:
return None
return "/"
except KeyError:
return "/"
@page(name="Benutzerverwaltung", url_segment="user-admin", guard=user_admin_page_guard)
class UserAdminPage(Component):
all_users: list[User] = list()
user_list: list[User] = list()
search_bar_text: str = ""
active_user: Optional[User] = None
transaction_value: float = 0.0
transaction_reason: str = ""
active_user_balance: str = "0.00 €"
@on_populate
async def on_populate(self) -> None:
user_list = await User.find_all().to_list()
self.all_users = sorted(user_list, key=lambda u: u.user_name)
self.user_list = sorted(user_list, key=lambda u: u.user_name)
async def on_search_bar_text_changed(self, e: TextInputChangeEvent) -> None:
self.user_list = list(filter(lambda user: (e.text.lower() in user.user_name.lower()), self.all_users))
async def on_user_clicked(self, user: User) -> None:
self.active_user = user
self.active_user_balance = self.session[AccountingService].make_euro_string_from_decimal(
await self.session[AccountingService].get_balance(user.user_name)
)
async def create_debit_transaction(self) -> None:
if not self.active_user:
return
logger.info(f"Trying to remove {self.transaction_value} to {self.active_user.user_name} ({self.transaction_reason})")
new_transaction = Transaction(
user_name=self.active_user.user_name,
value=Decimal(str(self.transaction_value)),
is_debit=True,
title=self.transaction_reason
)
try:
await new_transaction.save()
except Exception as e:
logger.error(e)
self.transaction_value = 0.0
self.transaction_reason = ""
self.active_user_balance = self.session[AccountingService].make_euro_string_from_decimal(
await self.session[AccountingService].get_balance(self.active_user.user_name)
)
async def create_credit_transaction(self) -> None:
if not self.active_user:
return
logger.info(f"Trying to add {self.transaction_value} to {self.active_user.user_name} ({self.transaction_reason})")
value = Decimal(str(self.transaction_value))
new_transaction = Transaction(
user_name=self.active_user.user_name,
value=value,
is_debit=False,
title=self.transaction_reason
)
try:
await new_transaction.save()
except Exception as e:
logger.error(e)
self.transaction_value = 0.0
self.transaction_reason = ""
total_balance = await self.session[AccountingService].get_balance(self.active_user.user_name)
self.active_user_balance = self.session[AccountingService].make_euro_string_from_decimal(total_balance)
self.session.create_task(self.session[MailingService].send_email(
subject="Dein Guthaben wurde aufgeladen!",
body=self.session[MailingService].generate_account_balance_added_mail_body(user=self.active_user, added_balance=value, total_balance=total_balance),
receiver=self.active_user.user_mail
))
def build(self) -> Component:
right_panel_contents = []
if not self.active_user:
right_panel_contents.append(Spacer())
else:
right_panel_contents.extend([
AccountInfoBox(fixed_user=self.active_user),
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text(f"LAN Konto - Kontostand: {self.active_user_balance}", 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,
),
Column(
NumberInput(
value=self.bind().transaction_value,
label="Betrag",
suffix_text="",
decimals=2,
margin=1,
margin_bottom=0
),
TextInput(
text=self.bind().transaction_reason,
label="Beschreibung",
margin=1,
margin_bottom=0
),
Row(
Button(
content="Entfernen",
shape="rectangle",
color="danger",
margin=1,
on_press=self.create_debit_transaction
),
Button(
content="Hinzufügen",
shape="rectangle",
color="success",
margin=1,
on_press=self.create_credit_transaction
)
),
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
])
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Nutzerliste", 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,
),
Column(
TextInput(label="Nutzername", text=self.bind().search_bar_text, on_change=self.on_search_bar_text_changed, margin_bottom=1),
*[Button(content=user.user_name, shape="rectangle", style="plain-text", on_press=partial(self.on_user_clicked, user)) for user in self.user_list],
margin=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_width=25
),
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text(f"Nutzer bearbeiten{': ' if self.active_user else ''}{self.active_user.user_name if self.active_user else ''}", margin=0.5, selectable=False, overflow="wrap", grow_x=True),
IconButton("material/cancel", min_size=1.5, color="primary", margin_right=1, on_press=lambda: self.__setattr__("active_user", None)),
),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
*right_panel_contents,
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
grow_x=True
),
spacing=1,
margin=1
)
+5 -2
View File
@@ -44,7 +44,7 @@ class ConfigurationService:
secret=self._config["paypal"]["secret"] secret=self._config["paypal"]["secret"]
) )
except KeyError: except KeyError:
logger.fatal("Error loading DatabaseConfiguration, exiting...") logger.fatal("Error loading PayPalConfiguration, exiting...")
sys.exit(1) sys.exit(1)
def get_ticket_info(self) -> tuple[TicketInfo, ...]: def get_ticket_info(self) -> tuple[TicketInfo, ...]:
@@ -66,8 +66,11 @@ class ConfigurationService:
def get_database_configuration(self) -> DatabaseConfiguration: def get_database_configuration(self) -> DatabaseConfiguration:
try: try:
return DatabaseConfiguration( return DatabaseConfiguration(
database_address=self._config["database"]["database_address"], database_host=self._config["database"]["database_host"],
database_port=self._config["database"]["database_port"],
database_name=self._config["database"]["database_name"], database_name=self._config["database"]["database_name"],
database_user=self._config["database"]["database_user"],
database_password=self._config["database"]["database_password"],
) )
except KeyError: except KeyError:
logger.fatal("Error loading DatabaseConfiguration, exiting...") logger.fatal("Error loading DatabaseConfiguration, exiting...")
+26 -5
View File
@@ -1,8 +1,10 @@
import logging import logging
import sys
from beanie import init_beanie 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 pymongo.errors import ServerSelectionTimeoutError, OperationFailure
from elm.types import User, Transaction, Ticket, Seat, CateringTypes from elm.types import User, Transaction, Ticket, Seat, CateringTypes
from elm.types.ConfigurationTypes import DatabaseConfiguration from elm.types.ConfigurationTypes import DatabaseConfiguration
@@ -24,14 +26,33 @@ class DatabaseService:
self._db_config = db_config self._db_config = db_config
self._client = None self._client = None
self._database = None self._database = None
self._users = None
async def initialize(self) -> None: async def initialize(self) -> None:
mongo_uri = f"mongodb://{self._db_config.database_user}:{self._db_config.database_password}@{self._db_config.database_host}:{self._db_config.database_port}/{self._db_config.database_name}?authSource=admin"
if self._client is None: if self._client is None:
self._client = AsyncMongoClient(self._db_config.database_address) self._client = AsyncMongoClient(mongo_uri)
self._database = self._client[self._db_config.database_name]
self._users: AsyncCollection = self._database["users"] try:
await self._client.admin.command("ping")
except ServerSelectionTimeoutError:
print("Could not connect to mongodb")
sys.exit(1)
except OperationFailure:
print("Authentication with mongodb failed")
sys.exit(1)
self._database = self._client[
self._db_config.database_name
]
await init_beanie( await init_beanie(
database=self._database, database=self._database,
document_models=[User, Transaction, Ticket, Seat, CateringTypes.CateringMenuItem, CateringTypes.CateringOrder] document_models=[
User,
Transaction,
Ticket,
Seat,
CateringTypes.CateringMenuItem,
CateringTypes.CateringOrder
]
) )
+44
View File
@@ -0,0 +1,44 @@
import json
from from_root import from_root
class PreloadService:
def __init__(self) -> None:
self._faq: list[tuple[str, str]] = []
self._imprint: str = ""
self._rules: list[str] = []
self._gtc: dict[str, list[str]] = {}
with open(from_root("src/elm/assets/preloaded_content/faq.txt"), "r") as faq_file:
faq = faq_file.read()
faq = faq.split("\n\n")
for i in range(0, len(faq), 2):
q, a = faq[i], faq[i + 1]
self._faq.append((q, a))
with open(from_root("src/elm/assets/preloaded_content/imprint.txt"), "r") as imprint_file:
self._imprint = imprint_file.read()
with open(from_root("src/elm/assets/preloaded_content/rules.txt"), "r") as rules_file:
self._rules = rules_file.read().split("\n")
with open(from_root("src/elm/assets/preloaded_content/gtc.json"), "r") as gtc_file:
self._gtc = json.load(gtc_file)
@property
def faq(self) -> list[tuple[str, str]]:
return self._faq
@property
def imprint(self) -> str:
return self._imprint
@property
def rules(self) -> list[str]:
return self._rules
@property
def gtc(self) -> dict[str, list[str]]:
return self._gtc
@@ -0,0 +1,60 @@
import logging
from datetime import timezone
from zoneinfo import ZoneInfo
import requests
from bson import ObjectId
from elm.types import Seat
from elm.types.CateringTypes import CateringOrder
from elm.types.ConfigurationTypes import ReceiptPrintingConfiguration
from elm.types.User import User
logger = logging.getLogger(__name__.split(".")[-1])
logging.getLogger("urllib3").setLevel(logging.FATAL) # Disable logging for urllib3
class ReceiptPrintingService:
def __init__(self, config: ReceiptPrintingConfiguration, dev_mode_enabled: bool) -> None:
self._config = config
self._dev_mode_enabled = dev_mode_enabled
self._url = f"http://{self._config.host}:{self._config.port}/{self._config.order_print_endpoint}"
async def print_order(self, user: User, order: CateringOrder) -> None:
seat = await Seat.find_one({"user.$id": ObjectId(user.id)})
if seat is None:
seat_id = " - "
else:
seat_id = str(seat.seat_id)
menu_items_payload = []
for item in order.items:
menu_items_payload.append({
"menu_item_name": item.name,
"mods": [f"{'MIT' if mod.selected else 'OHNE'} {mod.label}" for mod in item.selected_modifiers],
"amount": 1
})
payload = {
"order_id": str(order.id)[-5:],
"order_date": order.created_at.replace(tzinfo=timezone.utc).astimezone(ZoneInfo("Europe/Berlin")).strftime('%Y-%m-%dT%H:%M:%S.%f')[:-3] + 'Z',
"customer_name": user.user_name,
"seat_id": seat_id,
"items": menu_items_payload
}
logger.info(f"Sending print order to {self._url}: {payload}")
try:
response = requests.post(
self._url,
json=payload,
headers={"x-password": self._config.password},
timeout=2.0
)
if response.status_code != 200:
logger.error(f"Received an error with code {response.status_code}: {response.text}")
except Exception as e:
if self._dev_mode_enabled:
logger.info("An error occurred trying to print a receipt: %s", e)
return
logger.error("An error occurred trying to print a receipt: %s", e)
-1
View File
@@ -1,4 +1,3 @@
from asyncio import sleep
from hashlib import sha256 from hashlib import sha256
from typing import Optional from typing import Optional
from string import ascii_letters, digits from string import ascii_letters, digits
+2
View File
@@ -4,3 +4,5 @@ from .UserService import UserService, NameNotAllowedError, MailAlreadyInUseError
from .LocalDataService import LocalData, LocalDataService from .LocalDataService import LocalData, LocalDataService
from .MailingService import MailingService from .MailingService import MailingService
from .AccountingService import AccountingService from .AccountingService import AccountingService
from .PreloadService import PreloadService
from .ReceiptPrintingService import ReceiptPrintingService
+63
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
@@ -137,3 +183,20 @@ class CateringOrder(Document):
class Settings: class Settings:
name = "catering_orders" name = "catering_orders"
@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)
+4 -1
View File
@@ -16,8 +16,11 @@ class MailingServiceConfiguration:
@dataclass(frozen=True) @dataclass(frozen=True)
class DatabaseConfiguration: class DatabaseConfiguration:
database_address: str database_host: str
database_port: str
database_name: str database_name: str
database_user: str
database_password :str
@dataclass(frozen=True) @dataclass(frozen=True)
class LanInfo: class LanInfo: