6 Commits

Author SHA1 Message Date
dusker d31ac25524 Fix typo in Balance Page 2026-05-29 19:44:24 +00: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
93 changed files with 5302 additions and 2 deletions
+154
View File
@@ -0,0 +1,154 @@
# CONFIG
*.toml
# Chatch-all default Python gitignore
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# plaintext files
*.txt
# csv-files
*.csv
# certificates
*.crt
# icons
*.ico
# C extensions
*.so
# PyCharm
.idea/
# Excel tables
*.ods
*.xlsx
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# JSON containing project data
projects.json
# Authentication directoy
auth/
View File
+22
View File
@@ -0,0 +1,22 @@
<html>
<head>
<title>EZGG LAN Manager - Wartungsmodus</title>
<style>
body { text-align: center; padding: 150px; }
h1 { font-size: 50px; }
body { font: 20px Helvetica, sans-serif; color: #333; }
article { display: block; text-align: left; width: 650px; margin: 0 auto; }
a { color: #dc8100; text-decoration: none; }
a:hover { color: #333; text-decoration: none; }
</style>
</head>
<body>
<article>
<h1>Wir sind bald wieder da!</h1>
<div>
<p>Wir f&uuml;hren zurzeit Wartungsarbeiten durch und sind in k&uuml;rze wieder f&uuml;r euch da.</p>
<p>&mdash; Euer EZGG LAN Team</p>
</div>
</article>
</body>
</html>
+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", "--"]
+28 -2
View File
@@ -1,3 +1,29 @@
# ELM # ELM (EZGG LAN Manager)
The 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
View File
@@ -0,0 +1 @@
0.6.0-prerelease
+57
View File
@@ -0,0 +1,57 @@
[lan]
name="EZGG LAN"
iteration="Edition 2.0"
date_from="2026-05-08 16:00:00"
date_till="2026-05-10 14:00:00"
organizer_mail="tech@ezgg-ev.de"
internet_speed_mbs=400
has_wifi=true
has_showers=false
ts3_address=""
discord_invite_link=""
[database]
database_host="localhost"
database_port="27017"
database_user="root"
database_password="password"
database_name="elm"
[mailing]
smtp_server="smtp.protonmail.ch"
smtp_port=587
sender="EZGG LAN Manager <no-reply@ezgg-ev.de>"
username="no-reply@ezgg-ev.de"
password="some_password"
[tickets]
[tickets."NORMAL"]
total_tickets=38
price="25.00"
description="Normales Ticket"
additional_info="Berechtigt zur Nutzung eines regulären Platzes für die gesamte Dauer der LAN"
can_be_sold=true
[tickets."DELUXE"]
total_tickets=30
price="30.00"
description="Deluxe Ticket"
additional_info="Wie das normale Ticket, aber mit doppelt so breitem Tisch (160cm)"
can_be_sold=true
[receipt_printing]
host="10.0.0.103"
port="5000"
order_print_endpoint="print_order"
password="Alkohol1"
[misc]
base_url="https://ezgg-lan.de" # In dev mode, this is localhost
default_profile_picture="src/elm/assets/img/anon.png"
dev_mode_active=true # Supresses E-Mail sending, activates PayPal sandbox API
[paypal]
client_id_sandbox=""
secret_sandbox=""
client_id=""
secret=""
+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.
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
import logging
from pathlib import Path
from uuid import uuid4
from from_root import from_root
from rio import App, Theme, Color, Font, Icon, Session
from elm.components.RootComponent import RootComponent
from elm.services import ConfigurationService, DatabaseService, UserService, LocalData, LocalDataService, MailingService, AccountingService, PreloadService, ReceiptPrintingService
from elm.types import UserSession
logger = logging.getLogger("ELM")
logger.setLevel(logging.DEBUG)
theme = Theme.from_colors(
primary_color=Color.from_hex("02DAC5"),
secondary_color=Color.from_hex("018786"),
neutral_color=Color.from_hex("0D1117"),
background_color=Color.from_hex("0D1117"),
hud_color=Color.from_hex("003736"),
text_color=Color.from_hex("b9ccb2"),
mode="dark",
corner_radius_small=0,
corner_radius_medium=0,
corner_radius_large=0,
font=Font(
regular=from_root("src/elm/assets/PixelOperatorMono8.ttf"),
bold=from_root("src/elm/assets/PixelOperatorMono8-Bold.ttf")
)
)
theme.primary_color_darker = Color.from_hex("008679")
theme.primary_color_dark = Color.from_hex("003731")
theme.danger_color_dark = Color.from_hex("7f1b15")
theme.box_color = Color.from_hex("181c22")
theme.box_border_color = Color.from_hex("354333")
theme.header_box_background_color = Color.from_hex("30343b")
theme.text_color = Color.from_hex("b9ccb2")
theme.MAX_MOBILE_SCREEN_WIDTH = 28
Icon.register_single_icon(
icon_source=from_root("src/elm/assets/custom_icons/ts3.svg"),
set_name="custom",
icon_name="ts3"
)
configuration_service = ConfigurationService(from_root("config.toml"))
database_service = DatabaseService(configuration_service.get_database_configuration())
mailing_service = MailingService(configuration_service)
lan_info = configuration_service.get_lan_info()
def is_mobile(self: Session) -> bool:
return self.screen_width < self.theme.MAX_MOBILE_SCREEN_WIDTH
Session.is_mobile = is_mobile
async def on_session_start(session: Session) -> None:
if configuration_service.DEV_MODE_ACTIVE:
# Use this line to fake being any user without having to log in
dev_user = await session[UserService].get_user("Typhus")
if not dev_user:
logger.fatal("DEV MODE USER DOES NOT EXIST")
exit(1)
session.attach(UserSession(id=uuid4(), user_name=dev_user.user_name, is_team_member=True, profile_picture=dev_user.user_picture))
await session.set_title(f"{lan_info.name} - {lan_info.iteration}")
if session[LocalData].stored_session_token:
user_session = session[LocalDataService].verify_token(session[LocalData].stored_session_token)
if user_session is not None:
session.attach(user_session)
async def on_app_start(a: App) -> None:
logger.info("Initializing mongodb...")
await a.default_attachments[2].initialize()
app = App(
name="elm",
theme=theme,
assets_dir=Path(__file__).parent / "assets",
build=RootComponent,
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_session_start=on_session_start,
icon=from_root("src/elm/assets/img/favicon.png"),
meta_tags={
"robots": "INDEX,FOLLOW",
"description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.",
"og:description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.",
"keywords": "Gaming, Clan, Guild, Verein, Club, Einfach, Zocken, Gesellschaft, Videospiele, "
"Videogames, LAN, Party, EZ, EZGG, LAN, Manager",
"author": "David Rodenkirchen",
"publisher": "EZ GG e.V.",
"copyright": "EZ GG e.V.",
"audience": "Alle",
"page-type": "Management Application",
"page-topic": "LAN Party",
"expires": "",
"revisit-after": "2 days"
}
)
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>Teamspeak SVG Icon</title><path d="M12.202 13.027c.087.125.208.326.148.47c-.036.089-.207.137-.318.175l-.36.122l-.442.163c-.263.14-.269.393-.239.559l.108.32c.05.128.145.262.116.41c-.037.196-.411.21-.413.47c0 .119.076.176.158.236l.159.12l-.123.153l-.205.297l-.014.032l-.047.157l.016.22l.027.1v.002c.106.404.224.907.04 1.238c-.473.85-2.376.578-3.383.379c-.01-.06.061-.084.095-.113c.648-.543 1.187-1.278 1.56-2.105a6.93 6.93 0 0 0 .625-3.22c-.09-1.847-.662-3.189-1.58-4.253l-.753-.715l-.233-.181V3.656c1.102.428 1.879 1.213 2.513 2.176c.461.7.843 1.488 1.145 2.392c.15.445.317.999.074 1.42c-.102.178-.336.3-.37.542c-.028.188.08.347.158.49c.465.849.988 1.564 1.538 2.351zm9.321-3.83a7.83 7.83 0 0 1 .477 2.7a8.593 8.593 0 0 1-.426 2.75c-1.046 3.117-3.45 5.097-6.401 6.273c-1.49.593-3.09.954-5.204.954c-2.06 0-3.723-.483-5.336-1.015l.091-.081c.091-.059.315-.242.426-.244l.305.091c1.433.435 3.285.708 5.163.528c1.907-.183 3.352-.699 4.768-1.41c1.344-.676 2.538-1.569 3.449-2.741c.456-.588.814-1.228 1.167-1.928c.346-.69.532-1.55.598-2.487a9.17 9.17 0 0 0-.223-2.832a6.571 6.571 0 0 0-.964-2.131c-.843-1.252-1.82-2.292-3.084-3.096c-1.266-.805-2.678-1.432-4.433-1.746c-1.759-.314-3.797-.31-5.427.102V6.5h.001v1.602a2.987 2.987 0 0 1 1.431 2.556c0 1.564-1.19 2.846-2.706 2.972h-.006l-.12.008l-.073.001l-.045.002h-.016l-.121-.004h-.02l-.192-.016a2.978 2.978 0 0 1-1.714-.812c-.428-.4-.744-.97-.852-1.675L2 10.67L2 10.66c0-1.266.78-2.346 1.88-2.78l.732-3.464c.077-.368.143-.83.345-1.086c.41-.517 1.264-.67 2.02-.852c.796-.193 1.57-.301 2.515-.345c1.012-.047 2.11.072 2.992.213c2.751.44 4.81 1.542 6.442 3.086c1.076 1.017 2.03 2.203 2.597 3.765zM6.588 10.852c0-1.13-.914-2.046-2.042-2.046c-1.127 0-2.04.916-2.04 2.046s.913 2.046 2.04 2.046a2.044 2.044 0 0 0 2.042-2.046zm4.38-7.242c4.556.624 8.023 3.78 8.023 7.581c0 3.407-2.784 6.294-6.64 7.307c-.362.135-1.006.187-.975.115l-.002.003l.16-.573c.048-.274.08-.609.026-.902l-.013-.064c0-.004-.12-.239.083-.425v-.001l.03-.023c.076-.067.27-.271.191-.562l-.044-.082c-.092-.1-.065-.192-.007-.264l.13-.111c.198-.156.141-.373.075-.508l-.138-.195l-.019-.023l-.001-.001c-.334-.419.15-.615.492-.697l.092-.02c.246-.062.891-.277.735-.819l-.12-.216l-.47-.706h.015L11.32 10.69c-.135-.171-.076-.312-.014-.392l.319-.562c.096-.288.091-.588.07-.792a5.244 5.244 0 0 1 1.643 1.277c.456.528.842 1.204.842 2.145c0 .955-.39 1.618-.842 2.156a4.733 4.733 0 0 1-1.457 1.16l-.08.039c-.052.065-.078.146-.017.234l.127-.027a7.381 7.381 0 0 0 1.766-.831c.888-.577 1.719-1.279 2.258-2.197c.178-.301.315-.674.442-1.047c.257-.757.34-1.685.102-2.556c-.205-.75-.58-1.402-.995-1.98c-1.227-1.71-3.407-2.583-6.066-2.792v.004a8.958 8.958 0 0 0-1.06-.995s-.067-.087.303-.087s1.684.063 2.27.157l.038.007z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -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.
+151
View File
@@ -0,0 +1,151 @@
from typing import Optional
from bson import ObjectId
from rio import Component, Rectangle, Column, Text, Row, PointerEventListener, TextInput
from rio.event import on_populate
from elm.types import UserSession, Ticket, Seat, User
from elm.components import ElmButton
from elm.services import UserService
class AccountInfoBox(Component):
mail: str = ""
new_password: str = ""
account_info_text: str = " "
account_info_is_error: bool = False
password_input_blocked: bool = False
password_change_in_progress: bool = False
ticket: Optional[Ticket] = None
seat: Optional[Seat] = None
fixed_user: Optional[User] = None
@on_populate
async def on_populate(self) -> None:
if self.fixed_user is None:
try:
user = await self.session[UserService].get_user(self.session[UserSession].user_name)
if user:
self.mail = user.user_mail
self.ticket = await Ticket.find_one({"owner.$id": user.id})
self.seat = await Seat.find_one({"user.$id": ObjectId(user.id)})
else:
self.session.navigate_to("./login")
except KeyError:
self.session.navigate_to("./login")
else:
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:
self.password_change_in_progress = True
self.password_input_blocked = True
if len(self.new_password) == 0:
self.account_info_is_error = True
self.account_info_text = "Kein Passwort gesetzt"
self.password_input_blocked = False
self.password_change_in_progress = False
return
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:
self.account_info_is_error = True
self.account_info_text = "Unbekannter Fehler"
self.password_input_blocked = False
self.password_change_in_progress = False
return
result = await self.session[UserService].change_user_password(user.user_name, self.new_password)
if result:
self.account_info_is_error = False
self.account_info_text = "Passwort geändert"
self.password_input_blocked = False
self.password_change_in_progress = False
else:
self.account_info_is_error = True
self.account_info_text = "Unbekannter Fehler"
self.password_input_blocked = False
self.password_change_in_progress = False
def build(self) -> Component:
row_col = Row
ticket_text = "-"
seat_text = "-"
if self.ticket:
ticket_text = self.ticket.category
if self.seat:
seat_text = self.seat.seat_id
if self.session.is_mobile():
row_col = Column
return Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Account Informationen", 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(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),
row_col(
PointerEventListener(
Rectangle(
content=Row(Text("Ticket:", margin=1, overflow="wrap", justify="left"), Text(ticket_text, margin=1, overflow="wrap", justify="right")),
fill=self.session.theme.success_color if self.ticket else self.session.theme.danger_color_dark,
stroke_width=0.1,
stroke_color=self.session.theme.success_color if self.ticket else self.session.theme.danger_color,
hover_fill=self.session.theme.success_color if self.ticket else self.session.theme.danger_color,
hover_stroke_width=0.1,
hover_stroke_color=self.session.theme.success_color if self.ticket else self.session.theme.danger_color_dark,
transition_time=0.2,
cursor="pointer"
),
on_press=lambda _: self.session.navigate_to("./tickets")
),
PointerEventListener(
Rectangle(
content=Row(Text("Sitzplatz:", margin=1, overflow="wrap", justify="left"), Text(seat_text, margin=1, overflow="wrap", justify="right")),
fill=self.session.theme.success_color if self.seat else self.session.theme.danger_color_dark,
stroke_width=0.1,
stroke_color=self.session.theme.success_color if self.seat else self.session.theme.danger_color,
hover_fill=self.session.theme.success_color if self.seat else self.session.theme.danger_color,
hover_stroke_width=0.1,
hover_stroke_color=self.session.theme.success_color if self.seat else self.session.theme.danger_color_dark,
transition_time=0.2,
cursor="pointer"
),
on_press=lambda _: self.session.navigate_to("./seating")
),
spacing=1
),
row_col(
TextInput(text=self.bind().new_password, label="Neues Passwort", is_secret=True, is_sensitive=not self.password_input_blocked, grow_x=True),
ElmButton(
text="Speichern",
style="normal",
wrap=self.session.is_mobile(),
on_press=self.set_new_password,
is_loading=self.password_change_in_progress
),
spacing=1
),
Text(text=self.account_info_text, fill=self.session.theme.danger_color if self.account_info_is_error else self.session.theme.success_color),
spacing=1,
margin=1
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
)
+87
View File
@@ -0,0 +1,87 @@
from rio import Component, Rectangle, Color, Column, Image, Text, Spacer, NoFileSelectedError
from elm.components import ElmButton
from elm.types import UserSession
from elm.services import ConfigurationService, UserService
class AvatarEditBox(Component):
avatar_info_text: str = ""
avatar_info_text_is_error: bool = False
async def upload_new_pfp(self) -> None:
try:
new_pfp = await self.session.pick_file(file_types=("png", "jpg", "jpeg"), multiple=False)
except NoFileSelectedError:
self.avatar_info_text = "Keine Datei ausgewählt!"
self.avatar_info_text_is_error = True
return
if new_pfp.size_in_bytes > 2 * 1_000_000:
self.avatar_info_text = "Bild zu groß! (> 2MB)"
self.avatar_info_text_is_error = True
return
image_data = await new_pfp.read_bytes()
user = await self.session[UserService].get_user(self.session[UserSession].user_name)
if user is not None:
user.user_picture = image_data
await user.save()
self.session[UserSession].profile_picture = image_data
self.avatar_info_text = "Erfolgreich aktualisiert"
self.avatar_info_text_is_error = False
else:
self.avatar_info_text = "Unbekannter Fehler"
self.avatar_info_text_is_error = True
async def delete_current_pfp(self) -> None:
user = await self.session[UserService].get_user(self.session[UserSession].user_name)
if user is not None:
user.user_picture = None
await user.save()
self.session[UserSession].profile_picture = None
self.avatar_info_text = "Erfolgreich gelöscht"
self.avatar_info_text_is_error = False
else:
self.avatar_info_text = "Unbekannter Fehler"
self.avatar_info_text_is_error = True
def build(self) -> Component:
return Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Avatar", 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(
Image(
image=self.session[UserSession].profile_picture if self.session[UserSession].profile_picture is not None else self.session[ConfigurationService].DEFAULT_PROFILE_PICTURE,
min_width=10,
min_height=10,
grow_x=False,
grow_y=False,
corner_radius=0.3
),
Text(
text=self.avatar_info_text,
overflow="wrap",
justify="center",
fill=self.session.theme.danger_color if self.avatar_info_text_is_error else self.session.theme.success_color
),
ElmButton(text="Bild hochladen", style="small" if self.session.is_mobile() else "normal", on_press=self.upload_new_pfp),
ElmButton(text="Bild löschen", style="small" if self.session.is_mobile() else "normal", on_press=self.delete_current_pfp),
Spacer(),
margin=1,
spacing=1
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_height=15
)
+137
View File
@@ -0,0 +1,137 @@
from asyncio import sleep
from datetime import datetime
from typing import Optional
from rio import Component, Column, Row, Text, Spacer, Rectangle, ProgressBar, Tooltip
from rio.event import on_populate
from elm.services import AccountingService
from elm.components import ElmButton
from elm.services.AccountingService import InsufficientFundsError
from elm.types import Ticket, UserSession, User, TicketInfo, TicketState
class BuyTicketBox(Component):
ticket_info: TicketInfo
user_ticket: Optional[Ticket] = None
ticket_state: TicketState = TicketState.UNAVAILABLE
sold_tickets: int = 0
purchase_in_process: bool = False
purchase_status: str = ""
purchase_error_message: str = ""
@on_populate
async def on_populate(self) -> None:
self.sold_tickets = len(await Ticket.find_many(Ticket.category == self.ticket_info.category).to_list())
if self.sold_tickets >= self.ticket_info.total_tickets:
self.ticket_state = TicketState.SOLD_OUT
elif self.ticket_info.can_be_sold:
self.ticket_state = TicketState.AVAILABLE
else:
self.ticket_state = TicketState.UNAVAILABLE
self.user_ticket = await self.get_user_ticket()
async def get_user_ticket(self) -> Optional[Ticket]:
try:
user = await User.find_one(User.user_name == self.session[UserSession].user_name)
if not user:
return None
return await Ticket.find_one({"owner.$id": user.id})
except KeyError:
return None
def is_logged_in(self) -> bool:
try:
return bool(self.session[UserSession].user_name)
except KeyError:
return False
def get_available_tickets(self) -> int:
return self.ticket_info.total_tickets - self.sold_tickets
async def buy_ticket(self) -> None:
self.purchase_in_process = True
self.purchase_status = "Ticket wird gekauft..."
await sleep(1)
try:
user = await User.find_one(User.user_name == self.session[UserSession].user_name)
if not user:
raise KeyError
except KeyError:
self.session.navigate_to("./login")
return
try:
await self.session[AccountingService].remove_balance(user.user_name, self.ticket_info.price, f"Ticketkauf - {self.ticket_info.category}")
except InsufficientFundsError:
self.purchase_in_process = False
self.purchase_status = ""
self.purchase_error_message = "Ungenügendes Guthaben!"
return
new_ticket = Ticket(category=self.ticket_info.category, purchase_date=datetime.now(), owner=user)
await new_ticket.save()
self.user_ticket = new_ticket
self.purchase_in_process = False
self.purchase_status = ""
self.sold_tickets = len(await Ticket.find_many(Ticket.category == self.ticket_info.category).to_list())
def build(self) -> Component:
ticket_owned_text = ""
if self.purchase_error_message:
button_row_content = Row(
Text(self.purchase_error_message, justify="center", margin=0.5, fill=self.session.theme.danger_color, overflow="wrap"),
)
elif self.purchase_in_process:
button_row_content = Row(
Text(self.purchase_status, justify="center", margin=0.5, overflow="wrap")
)
else:
if self.user_ticket:
button_row_content = Tooltip(anchor=ElmButton(text="Kaufen", is_disabled=True), tip="Du hast bereits ein Ticket")
ticket_owned_text = "Du besitzt dieses Ticket!" if self.user_ticket.category == self.ticket_info.category else ""
elif self.is_logged_in():
if self.ticket_state == TicketState.UNAVAILABLE:
button_row_content = Tooltip(anchor=ElmButton(text="Kaufen", is_disabled=True), tip="Aktuell nicht verfügbar")
elif self.ticket_state == TicketState.SOLD_OUT:
button_row_content = Tooltip(anchor=ElmButton(text="Kaufen", is_disabled=True), tip="Ausverkauft")
elif self.ticket_state == TicketState.AVAILABLE:
button_row_content = ElmButton(text="Kaufen", on_press=self.buy_ticket)
else:
button_row_content = Tooltip(anchor=ElmButton(text="Kaufen", is_disabled=True), tip="Entwickler hauen!")
else:
button_row_content = ElmButton(text="Kaufen", on_press=lambda: self.session.navigate_to("./login"))
return Column(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text(self.ticket_info.description, margin=0.5, selectable=False, overflow="wrap", grow_x=True),
Text(self.session[AccountingService].make_euro_string_from_decimal(self.ticket_info.price), justify="right", margin_right=0.5, fill=self.session.theme.warning_color)
),
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(self.ticket_info.additional_info, overflow="wrap", margin_bottom=1),
Text(ticket_owned_text, margin_bottom=3, overflow="wrap", fill=self.session.theme.success_color),
Row(Text("Verfügbar:", font_size=0.8 if self.session.is_mobile() else 1), Text(f"{self.get_available_tickets()} / {self.ticket_info.total_tickets}", justify="right", font_size=0.8 if self.session.is_mobile() else 1)),
ProgressBar(progress=self.get_available_tickets() / self.ticket_info.total_tickets, min_height=1),
button_row_content,
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
Spacer()
)
+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
)
@@ -0,0 +1,58 @@
from typing import Literal, Callable
from rio import Component, Rectangle, Column, Spacer, Text
from rio.event import on_populate
from elm.components import 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"]
add_to_cart_pressed_callback: Callable
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()
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:
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(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()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
)
+79
View File
@@ -0,0 +1,79 @@
from decimal import Decimal
from typing import Callable
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.types.CateringTypes import CateringMenuItem, CateringModificationKey, CateringModifierOption
class CateringItemBox(Component):
item: CateringMenuItem
add_to_cart_pressed_callback: Callable
changed_options: Dict[str, bool] = Dict()
def make_money_string(self, money: Decimal) -> str:
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:
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=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)
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=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)
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", 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(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(),
PointerEventListener(
content=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
),
on_press=self.add_to_cart_pressed
),
Spacer()
)
),
Separator(color=self.session.theme.box_border_color),
spacing=0.5,
)
)
+66
View File
@@ -0,0 +1,66 @@
from inspect import iscoroutinefunction
from typing import Callable, Awaitable, Literal
from rio import Component, PointerEventListener, Rectangle, Text, Color, Row, Icon, PointerEvent
from elm.types.helpers import async_noop
class ElmButton(Component):
icon_name: str = ""
text: str = ""
on_press: (
Callable[[], None]
| Callable[[], Awaitable[None]]
) = async_noop
is_active: bool = False
style: Literal["small", "normal"] = "normal"
wrap: bool = False
is_loading: bool = False
is_disabled: bool = False
async def _on_press(self, event: PointerEvent) -> None:
if self.is_disabled:
return
if iscoroutinefunction(self.on_press):
await self.on_press()
else:
self.on_press()
def build(self) -> Component:
button_contents = []
if self.is_loading:
txt_len = len(self.text)
loading = "Lade"
missing_space = txt_len - len(loading)
loading = loading + "." * missing_space
button_contents.append(
Text(loading, fill=Color.WHITE, selectable=False, overflow="wrap" if self.wrap else "nowrap", justify="center")
)
else:
if self.icon_name:
button_contents.append(
Icon(icon=self.icon_name, margin_right=0.5)
)
if self.text:
button_contents.append(
Text(self.text, fill=Color.WHITE, selectable=False, overflow="wrap" if self.wrap else "nowrap", justify="center")
)
return PointerEventListener(
content=Rectangle(
content=Row(
*button_contents,
margin=1 if self.style == "normal" else 0.5,
),
fill=self.session.theme.hud_color if self.is_active else Color.TRANSPARENT,
stroke_width=0.1,
stroke_color=self.session.theme.secondary_color,
hover_stroke_width=0.1,
hover_stroke_color=self.session.theme.secondary_color if self.is_disabled else self.session.theme.hud_color,
hover_fill=Color.TRANSPARENT if self.is_disabled else self.session.theme.hud_color,
transition_time=0,
cursor="not-allowed" if self.is_disabled else "pointer"
),
on_press=self._on_press
)
+186
View File
@@ -0,0 +1,186 @@
from from_root import from_root
from rio import Component, Row, Rectangle, Text, Color, Spacer, PointerEventListener, Column, Image, TextStyle, Icon, Popup, PointerEvent, Link
from elm.services import ConfigurationService, UserService
from elm.components.UserNavigation import UserNavigation
from elm.types import UserSession
class HeaderBar(Component):
is_user_navigation_open: bool = False
async def user_navigation_pressed(self, _: PointerEvent):
self.is_user_navigation_open = not self.is_user_navigation_open
async def close_navigation(self) -> None:
self.is_user_navigation_open = False
def is_logged_in(self) -> bool:
try:
return bool(self.session[UserSession].user_name)
except KeyError:
return False
def build(self) -> Component:
lan_info = self.session[ConfigurationService].get_lan_info()
if self.session.is_mobile():
return Rectangle(
content=Row(
Column(
Text("EZGG LAN", style=TextStyle(fill=self.session.theme.primary_color, font_size=1.4), justify="center", margin_bottom=0.5),
Text("Edition 2.0", style=TextStyle(fill=self.session.theme.primary_color_darker, font_size=1.1), justify="center"),
min_width=(self.session.screen_width // 3) * 2
),
Spacer(),
Popup(
anchor=PointerEventListener(
content=Rectangle(
content=Image(
image=self.session[UserSession].profile_picture if self.session[UserSession].profile_picture is not None else self.session[ConfigurationService].DEFAULT_PROFILE_PICTURE,
min_width=3.5,
min_height=3.5,
grow_x=False,
grow_y=False,
corner_radius=0.3
),
stroke_width=0.1,
stroke_color=self.session.theme.primary_color,
corner_radius=0.3,
margin_right=1.1,
cursor="pointer"
),
on_press=self.user_navigation_pressed
),
content=UserNavigation(self.close_navigation),
position="bottom",
is_open=self.is_user_navigation_open,
alignment=1
) if self.is_logged_in() else PointerEventListener(
content=Rectangle(
content=Icon(
icon="material/login",
min_width=3.5,
min_height=3.5,
grow_x=False,
grow_y=False
),
stroke_width=0.1,
stroke_color=self.session.theme.primary_color,
corner_radius=0.3,
margin_right=1.1,
cursor="pointer"
),
on_press=lambda _: self.session.navigate_to("./login")
),
margin=0.5,
margin_right=1.1,
margin_left=1.1
),
fill=Color.from_hex("0a0e14"),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_width=self.session.screen_width
)
else: # Tablet & Desktop
return Rectangle(
content=Row(
Text(lan_info.name, style=TextStyle(fill=self.session.theme.primary_color, font_size=1.4), margin_right=1),
Text("|", style=TextStyle(fill=Color.GRAY, font_size=1.1), margin_right=1),
Text(lan_info.iteration, style=TextStyle(fill=self.session.theme.primary_color_darker, font_size=1.1), margin_right=1),
Spacer(grow_y=True),
Spacer(grow_y=True),
Link(
content=Rectangle(
content=Row(
Icon(
"brand/discord",
min_width=1.3,
min_height=1.3,
margin_right=1,
fill=self.session.theme.primary_color_darker
),
Text("Discord", selectable=False),
margin=0.3,
margin_left=0.8,
margin_right=0.8
),
cursor="pointer",
hover_shadow_color=self.session.theme.primary_color,
hover_shadow_radius=1,
margin_right=3,
corner_radius=0.3
),
target_url=lan_info.discord_invite_link,
open_in_new_tab=True
) if lan_info.discord_invite_link else Spacer(),
Link(
content=Rectangle(
content=Row(
Icon(
"custom/ts3",
min_width=1.5,
min_height=1.5,
margin_right=1
),
Text("Teamspeak", selectable=False),
margin=0.3,
margin_left=0.8,
margin_right=0.8
),
cursor="pointer",
hover_shadow_color=self.session.theme.primary_color,
hover_shadow_radius=1,
margin_right=3,
corner_radius=0.3
),
target_url=lan_info.ts3_address,
open_in_new_tab=True
) if lan_info.ts3_address else Spacer(),
Spacer(grow_y=True),
Popup(
anchor=PointerEventListener(
content=Rectangle(
content=Image(
image=self.session[UserSession].profile_picture if self.session[UserSession].profile_picture is not None else self.session[ConfigurationService].DEFAULT_PROFILE_PICTURE,
min_width=3.5,
min_height=3.5,
grow_x=False,
grow_y=False,
corner_radius=0.3
),
stroke_width=0.1,
stroke_color=self.session.theme.primary_color,
corner_radius=0.3,
margin_right=1.1,
cursor="pointer"
),
on_press=self.user_navigation_pressed
),
content=UserNavigation(self.close_navigation),
position="bottom",
is_open=self.is_user_navigation_open,
alignment=1
) if self.is_logged_in() else PointerEventListener(
content=Rectangle(
content=Icon(
icon="material/login",
min_width=3.5,
min_height=3.5,
grow_x=False,
grow_y=False
),
stroke_width=0.1,
stroke_color=self.session.theme.primary_color,
corner_radius=0.3,
margin_right=1.1,
cursor="pointer"
),
on_press=lambda _: self.session.navigate_to("./login")
),
margin=0.5,
margin_right=2,
margin_left=2
),
fill=Color.from_hex("0a0e14"),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
)
+135
View File
@@ -0,0 +1,135 @@
from datetime import datetime
from rio import Component, Rectangle, Row, Text, Spacer, Column, Color, TextStyle
from rio.event import on_populate, periodic
from elm.services import ConfigurationService
class LanCountdownBox(Component):
days_until_lan: str = "0"
hours_until_lan: str = "0"
minutes_until_lan: str = "0"
@on_populate
async def on_populate(self) -> None:
await self.update_time_until_lan()
@periodic(60)
async def update_time_until_lan(self) -> None:
td = self.session[ConfigurationService].get_lan_info().date_from - datetime.now()
total_seconds = int(td.total_seconds())
days = total_seconds // (24 * 3600)
remainder = total_seconds % (24 * 3600)
hours = remainder // 3600
remainder %= 3600
minutes = remainder // 60
self.days_until_lan = f"{days:02}"
self.hours_until_lan = f"{hours:02}"
self.minutes_until_lan = f"{minutes:02}"
def build(self) -> Component:
if self.session.is_mobile():
return Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text("", margin=0.5, margin_top=0.2, style=TextStyle(font_size=1.2, fill=self.session.theme.primary_color_darker)),
Text("LAN Countdown", margin=0.5, margin_top=0.6, selectable=False, justify="left", grow_x=True, fill=Color.WHITE),
),
fill=self.session.theme.header_box_background_color,
margin=0
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
Column(
Rectangle(
content=Column(Text(self.days_until_lan, justify="center", font_size=2, fill=self.session.theme.primary_color, font_weight="bold"),
Text("Tage", justify="center"), margin=1, spacing=0.5),
fill=self.session.theme.background_color,
stroke_width=0.1,
stroke_color=self.session.theme.primary_color
),
Rectangle(
content=Column(Text(self.hours_until_lan, justify="center", font_size=2, fill=self.session.theme.primary_color, font_weight="bold"),
Text("Stunden", justify="center"), margin=1, spacing=0.5),
fill=self.session.theme.background_color,
stroke_width=0.1,
stroke_color=self.session.theme.primary_color
),
Rectangle(
content=Column(Text(self.minutes_until_lan, justify="center", font_size=2, fill=self.session.theme.primary_color, font_weight="bold"),
Text("Minuten", justify="center"), margin=1, spacing=0.5),
fill=self.session.theme.background_color,
stroke_width=0.1,
stroke_color=self.session.theme.primary_color
),
spacing=1,
proportions=[1, 1, 1]
),
Spacer(),
margin=2,
spacing=1,
grow_y=True
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
)
else: # Desktop & Tablet
return Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text("", margin=0.5, margin_top=0.2, style=TextStyle(font_size=1.2, fill=self.session.theme.primary_color_darker)),
Text("LAN Countdown", margin=0.5, margin_top=0.6, selectable=False, justify="left", grow_x=True, fill=Color.WHITE),
),
fill=self.session.theme.header_box_background_color,
margin=0
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
Row(
Rectangle(
content=Column(Text(self.days_until_lan, justify="center", font_size=2, fill=self.session.theme.primary_color, font_weight="bold"), Text("Tage", justify="center", font_size=0.7), margin=1, spacing=0.5),
fill=self.session.theme.background_color,
stroke_width=0.1,
stroke_color=self.session.theme.primary_color
),
Rectangle(
content=Column(Text(self.hours_until_lan, justify="center", font_size=2, fill=self.session.theme.primary_color, font_weight="bold"), Text("Stunden", justify="center", font_size=0.7), margin=1, spacing=0.5),
fill=self.session.theme.background_color,
stroke_width=0.1,
stroke_color=self.session.theme.primary_color
),
Rectangle(
content=Column(Text(self.minutes_until_lan, justify="center", font_size=2, fill=self.session.theme.primary_color, font_weight="bold"), Text("Minuten", justify="center", font_size=0.7), margin=1, spacing=0.5),
fill=self.session.theme.background_color,
stroke_width=0.1,
stroke_color=self.session.theme.primary_color,
margin_right=0.1
),
spacing=1,
proportions=[1, 1, 1]
),
Spacer(),
margin=2,
spacing=1,
grow_y=True
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
)
+111
View File
@@ -0,0 +1,111 @@
from rio import Component, Rectangle, Row, Text, Spacer, ProgressBar, Column, Color, TextStyle
from rio.event import on_populate
from elm.services import ConfigurationService
from elm.types import Ticket
class LanInfoBox(Component):
total_tickets: int = 1
available_tickets: int = 0
@on_populate
async def on_populate(self) -> None:
total_tickets = 0
for ticket_info in self.session[ConfigurationService].get_ticket_info():
total_tickets += ticket_info.total_tickets
self.total_tickets = total_tickets
self.available_tickets = total_tickets - len(await Ticket.find_all().to_list())
def build(self) -> Component:
lan_info = self.session[ConfigurationService].get_lan_info()
if self.session.is_mobile():
return Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text("", margin=0.5, margin_top=0.2, style=TextStyle(font_size=1.2, fill=self.session.theme.primary_color_darker)),
Text("LAN Info", margin=0.5, margin_top=0.6, selectable=False, justify="left", grow_x=True, fill=Color.WHITE),
),
fill=self.session.theme.header_box_background_color,
margin=0
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
Row(Text("Start:", font_size=0.7), Spacer(), Text(lan_info.date_from.strftime("%d.%m.%Y"), fill=self.session.theme.primary_color, font_size=0.8, overflow="nowrap")),
Row(Text("Ende:", font_size=0.7), Spacer(), Text(lan_info.date_till.strftime("%d.%m.%Y"), fill=self.session.theme.primary_color, font_size=0.8, overflow="nowrap")),
Row(Text("Einlass:", font_size=0.7), Spacer(), Text(lan_info.date_from.strftime("%H:%M Uhr"), fill=self.session.theme.primary_color, font_size=0.8, overflow="nowrap")),
Row(Text("Abbau:", font_size=0.7), Spacer(), Text(lan_info.date_till.strftime("%H:%M Uhr"), fill=self.session.theme.primary_color, font_size=0.8, overflow="nowrap")),
Row(Text("Internet:", font_size=0.7), Spacer(), Text(f"{lan_info.internet_speed_mbs} MBit/s", fill=self.session.theme.primary_color, font_size=0.8, overflow="nowrap")),
Row(Text("Turniere:", font_size=0.7), Spacer(), Text("n/A", fill=self.session.theme.primary_color, font_size=0.8, overflow="nowrap")),
Row(Text("WLAN:", font_size=0.7), Spacer(), Text("Ja" if lan_info.has_wifi else "Nein", fill=self.session.theme.primary_color, font_size=0.8, overflow="nowrap")),
Row(Text("Duschen:", font_size=0.7), Spacer(), Text("Ja" if lan_info.has_showers else "Nein", fill=self.session.theme.primary_color, font_size=0.8, overflow="nowrap")),
Spacer(),
Column(
Row(Text("Verfügbare Tickets", font_size=0.7, overflow="wrap"), Spacer(), Text(f"{self.available_tickets} / {self.total_tickets}", fill=self.session.theme.primary_color, font_size=0.8),
margin_bottom=0.4),
ProgressBar(
progress=self.available_tickets / self.total_tickets,
margin=0.5,
rounded=False,
min_height=0.4,
color=self.session.theme.primary_color
)
),
margin=2,
spacing=1,
grow_y=True
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
)
else: # Tablet & Desktop
return Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text("", margin=0.5, margin_top=0.2, style=TextStyle(font_size=1.2, fill=self.session.theme.primary_color_darker)),
Text("LAN Info", margin=0.5, margin_top=0.6, selectable=False, justify="left", grow_x=True, fill=Color.WHITE),
),
fill=self.session.theme.header_box_background_color,
margin=0
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
Row(Text("Start:", font_size=0.9), Spacer(), Text(lan_info.date_from.strftime("%d.%m.%Y"), fill=self.session.theme.primary_color, font_size=0.9, overflow="nowrap")),
Row(Text("Ende:", font_size=0.9), Spacer(), Text(lan_info.date_till.strftime("%d.%m.%Y"), fill=self.session.theme.primary_color, font_size=0.9, overflow="nowrap")),
Row(Text("Einlass:", font_size=0.9), Spacer(), Text(lan_info.date_from.strftime("%H:%M Uhr"), fill=self.session.theme.primary_color, font_size=0.9, overflow="nowrap")),
Row(Text("Abbau:", font_size=0.9), Spacer(), Text(lan_info.date_till.strftime("%H:%M Uhr"), fill=self.session.theme.primary_color, font_size=0.9, overflow="nowrap")),
Row(Text("Internet:", font_size=0.9), Spacer(), Text(f"{lan_info.internet_speed_mbs} MBit/s", fill=self.session.theme.primary_color, font_size=0.9, overflow="nowrap")),
Row(Text("Turniere:", font_size=0.9), Spacer(), Text("n/A", fill=self.session.theme.primary_color, font_size=0.9, overflow="nowrap")), # ToDo: Grab from DB
Row(Text("WLAN:", font_size=0.9), Spacer(), Text("Ja" if lan_info.has_wifi else "Nein", fill=self.session.theme.primary_color, font_size=0.9, overflow="nowrap")),
Row(Text("Duschen:", font_size=0.9), Spacer(), Text("Ja" if lan_info.has_showers else "Nein", fill=self.session.theme.primary_color, font_size=0.9, overflow="nowrap")),
Spacer(),
Column(
Row(Text("Verfügbare Tickets", font_size=0.9, overflow="wrap"), Spacer(), Text(f"{self.available_tickets} / {self.total_tickets}", fill=self.session.theme.primary_color, font_size=0.9), margin_bottom=0.4),
ProgressBar(
progress=self.available_tickets / self.total_tickets,
margin=0.5,
rounded=False,
min_height=0.4,
color=self.session.theme.primary_color
)
),
margin=2,
spacing=1,
grow_y=True
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
)
+81
View File
@@ -0,0 +1,81 @@
from rio import Component, Rectangle, Color, Column, Text, Image, Row, PointerEventListener, Spacer
from from_root import from_root
class LandingPageBoxFull(Component):
image_name: str
heading_text: str
article_text: str
date: str
def build(self) -> Component:
return Rectangle(
content=Column(
Image(from_root(f"src/elm/assets/img/{self.image_name}"), fill_mode="zoom"),
Column(
Text(self.heading_text, style="heading3", fill=Color.from_hex("51ffef"), overflow="wrap" if self.session.is_mobile() else "ellipsize", margin_bottom=1.5),
Text(self.article_text, overflow="wrap", margin_bottom=1.5),
Rectangle(content=Row(), shadow_color=self.session.theme.text_color, shadow_radius=0.1, min_height=0.1, margin_bottom=1.5),
Text(self.date, overflow="wrap", justify="right"),
margin=2
),
proportions=[0.8, 1]
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_height=20
)
class LandingPageBoxHalf(Component):
image_name: str
heading_text: str
article_text: str
link: str
def build(self) -> Component:
return PointerEventListener(
content=Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text(self.heading_text, 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,
),
Row(
Column(
Rectangle(
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,
),
Spacer()
),
Text(
self.article_text,
overflow="wrap",
grow_x=True,
selectable=False,
font_size=0.8
),
margin=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_height=12,
cursor="pointer"
),
on_press=lambda _: self.session.open_url_in_browser(self.link)
)
+144
View File
@@ -0,0 +1,144 @@
from functools import partial
from typing import Optional, Callable
import rio
from from_root import from_root
from rio import Component, Row, Rectangle, Text, Color, Spacer, PointerEventListener, Column, Image, TextStyle, Icon, PointerEvent, EventHandler
from elm.services import ConfigurationService
class NavigationButton(Component):
icon: str
text: str
target_url: str
extension_state_changed: Optional[Callable]
new_tab: bool = False
current_rectangle_fill_color = Color.TRANSPARENT
current_text_fill_color = Color.from_hex("b9ccb2")
COLOR_ACTIVE_RECTANGLE = Color.from_hex("4EFBE5")
COLOR_ACTIVE_TEXT = Color.from_hex("4EFBE5")
COLOR_INACTIVE_RECTANGLE = Color.TRANSPARENT
COLOR_INACTIVE_TEXT = Color.from_hex("b9ccb2")
async def on_pointer_enter(self, _: PointerEvent) -> None:
self.current_rectangle_fill_color = self.COLOR_ACTIVE_RECTANGLE
self.current_text_fill_color = self.COLOR_ACTIVE_TEXT
self.force_refresh()
async def on_pointer_leave(self, _: PointerEvent) -> None:
self.current_rectangle_fill_color = self.COLOR_INACTIVE_RECTANGLE
self.current_text_fill_color = self.COLOR_INACTIVE_TEXT
self.force_refresh()
async def on_press(self, _: PointerEvent) -> None:
if self.new_tab:
self.session.open_url_in_browser(self.target_url)
else:
self.session.navigate_to(self.target_url)
if self.extension_state_changed is not None:
await self.extension_state_changed(None)
def is_active(self) -> bool:
return self.session.active_page_url.path == self.target_url
def build(self) -> Component:
return PointerEventListener(
Rectangle(
content=Row(
Rectangle(fill=self.COLOR_ACTIVE_RECTANGLE if self.is_active() else self.current_rectangle_fill_color, min_width=0.3, margin_right=1.7, transition_time=0.2),
Icon(
self.icon,
min_width=1.9,
min_height=1.9,
fill=self.COLOR_ACTIVE_TEXT if self.is_active() else self.current_text_fill_color
),
Text(
self.text,
style=TextStyle(fill=self.COLOR_ACTIVE_TEXT if self.is_active() else self.current_text_fill_color, font_weight="normal", font_size=1.1),
margin_left=1,
margin_top=1.3,
margin_bottom=1.3,
grow_x=True,
selectable=False
),
),
hover_shadow_radius=1,
hover_shadow_color=self.session.theme.primary_color_darker ,
hover_fill=self.session.theme.primary_color_darker ,
corner_radius=0.1,
cursor="pointer",
transition_time=0.2
),
on_pointer_enter=self.on_pointer_enter,
on_pointer_leave=self.on_pointer_leave,
on_press=self.on_press
)
class NavigationBar(Component):
extension_state_changed: EventHandler[bool]
is_extended: bool = False
async def on_extension_pressed(self, _: PointerEvent) -> None:
if self.session.is_mobile():
self.is_extended = not self.is_extended
await self.call_event_handler(partial(self.extension_state_changed, self.is_extended))
self.force_refresh()
def build(self) -> Component:
navigation = Column(
Column(
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/chair_alt", "Sitzplan", "/seating", 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),
margin_bottom=6
),
Column(
NavigationButton("material/help", "FAQ", "/faq", 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/article", "Impressum", "/imprint", extension_state_changed=self.on_extension_pressed),
NavigationButton("material/balance", "Regeln & AGB", "/rules", extension_state_changed=self.on_extension_pressed),
),
Spacer(),
Text(f"ELM v{self.session[ConfigurationService].APP_VERSION}", font_size=0.5, fill=self.session.theme.primary_color_darker),
margin=1
)
if self.session.is_mobile():
return Rectangle(
content=Column(
Row(
PointerEventListener(
Rectangle(
content=Icon("material/keyboard_double_arrow_left" if self.is_extended else "material/view_headline"),
cursor="pointer",
margin=0.5,
min_height=1.5,
min_width=1.5
),
on_press=self.on_extension_pressed
),
Spacer()
),
Spacer() if not self.is_extended else navigation,
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_width=self.session.screen_width if self.is_extended else 2
)
else: # Tablet % Desktop
return Rectangle(
content=navigation,
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_width=20
)
+76
View File
@@ -0,0 +1,76 @@
from datetime import date
from rio import Component, Rectangle, Column, Text, DateInput, TextInput, Row
from rio.event import on_populate
from elm.components import ElmButton
from elm.services import UserService
from elm.types import UserSession
class PersonalInfoBox(Component):
first_name: str = ""
last_name: str = ""
birthday: date = date(1900, 1, 1)
update_in_progress: bool = False
info_text: str = " "
@on_populate
async def on_populate(self) -> None:
user = await self.session[UserService].get_user(self.session[UserSession].user_name)
if not user:
self.session.navigate_to("./login")
return
self.first_name = user.user_first_name
self.last_name = user.user_last_name
if user.user_birth_day is not None:
self.birthday = user.user_birth_day
async def update(self) -> None:
self.update_in_progress = True
first_name = self.first_name[:30]
last_name = self.last_name[:30]
birthday = self.birthday if self.birthday != date(1900, 1, 1) else None
user = await self.session[UserService].get_user(self.session[UserSession].user_name)
if user:
user.user_first_name = first_name
user.user_last_name = last_name
user.user_birth_day = birthday
await user.save()
self.info_text = "Aktualisiert!"
self.update_in_progress = False
def build(self) -> Component:
return Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Persönliche Informationen", 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(
TextInput(text=self.bind().first_name, label="Vorname"),
TextInput(text=self.bind().last_name, label="Nachname"),
spacing=1
) if self.session.is_mobile() else Row(
TextInput(text=self.bind().first_name, label="Vorname"),
TextInput(text=self.bind().last_name, label="Nachname"),
spacing=1
),
DateInput(label="Geburtstag", value=self.bind().birthday),
ElmButton(text="Speichern", is_loading=self.update_in_progress, on_press=self.update),
Text(text=self.info_text, fill=self.session.theme.success_color),
spacing=1,
margin=1
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
)
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text, PageView, Button, Link, Rectangle
from elm.components.HeaderBar import HeaderBar
from elm.components.NavigationBar import NavigationBar
class RootComponent(Component):
is_navigation_bar_extended: bool = False
def on_extension_state_changed(self, is_extended: bool) -> None:
self.is_navigation_bar_extended = is_extended
def build(self) -> Component:
return Column(
HeaderBar(),
Row(
NavigationBar(extension_state_changed=self.on_extension_state_changed),
Spacer() if self.is_navigation_bar_extended else PageView(grow_x=True, grow_y=True), # actual pages
grow_y=True,
grow_x=True
),
)
+179
View File
@@ -0,0 +1,179 @@
from typing import Optional
from rio import Component, Rectangle, Grid, Column, Row, Text, TextStyle, Color, Spacer
from elm.components import SeatPixel, WallPixel, TextPixel
from elm.types import Seat
MAX_GRID_WIDTH_PIXELS = 70
MAX_GRID_HEIGHT_PIXELS = 60
class SeatingPlan(Component):
preloaded_seats: list[Seat]
def get_seat(self, seat_id: str) -> Optional[Seat]:
return next(filter(lambda x: x.seat_id == seat_id, self.preloaded_seats), None)
"""
This seating plan is for the community center "Donsbach"
"""
def build(self) -> Component:
grid = Grid()
# Outlines
for x in range(0, MAX_GRID_WIDTH_PIXELS):
grid.add(WallPixel(), row=0, column=x)
for y in range(0, MAX_GRID_HEIGHT_PIXELS):
grid.add(WallPixel(), row=y, column=0)
for x in range(0, MAX_GRID_WIDTH_PIXELS):
grid.add(WallPixel(), row=MAX_GRID_HEIGHT_PIXELS, column=x)
for x in range(0, 41):
grid.add(WallPixel(), row=15, column=x)
for x in range(51, MAX_GRID_WIDTH_PIXELS):
grid.add(WallPixel(), row=32, column=x)
for x in range(41, 44):
grid.add(WallPixel(), row=32, column=x)
grid.add(WallPixel(), row=19, column=x)
for x in range(52, MAX_GRID_WIDTH_PIXELS):
grid.add(WallPixel(), row=11, column=x)
grid.add(WallPixel(), row=22, column=x)
for x in range(32, 40):
grid.add(WallPixel(), row=5, column=x)
grid.add(WallPixel(), row=10, column=x)
for y in range(5, 11):
grid.add(WallPixel(), row=y, column=31)
grid.add(WallPixel(), row=y, column=40)
for y in range(40, MAX_GRID_HEIGHT_PIXELS):
grid.add(WallPixel(), row=y, column=40)
for y in range(32, 36):
grid.add(WallPixel(), row=y, column=40)
for y in range(19, 33):
grid.add(WallPixel(), row=y, column=44)
for y in range(16, 20):
grid.add(WallPixel(), row=y, column=40)
for y in range(0, 5):
grid.add(WallPixel(), row=y, column=51)
for y in range(9, 15):
grid.add(WallPixel(), row=y, column=51)
for y in range(19, 33):
grid.add(WallPixel(), row=y, column=51)
# Block A
grid.add(SeatPixel("A01", seat=self.get_seat("A01"), seat_orientation="bottom"), row=57, column=1, width=6, height=2)
grid.add(SeatPixel("A02", seat=self.get_seat("A02"), seat_orientation="bottom"), row=57, column=7, width=6, height=2)
grid.add(SeatPixel("A03", seat=self.get_seat("A03"), seat_orientation="bottom"), row=57, column=13, width=6, height=2)
grid.add(SeatPixel("A04", seat=self.get_seat("A04"), seat_orientation="bottom"), row=57, column=19, width=6, height=2)
grid.add(SeatPixel("A05", seat=self.get_seat("A05"), seat_orientation="bottom"), row=57, column=25, width=6, height=2)
grid.add(SeatPixel("A10", seat=self.get_seat("A10"), seat_orientation="top"), row=55, column=1, width=6, height=2)
grid.add(SeatPixel("A11", seat=self.get_seat("A11"), seat_orientation="top"), row=55, column=7, width=6, height=2)
grid.add(SeatPixel("A12", seat=self.get_seat("A12"), seat_orientation="top"), row=55, column=13, width=6, height=2)
grid.add(SeatPixel("A13", seat=self.get_seat("A13"), seat_orientation="top"), row=55, column=19, width=6, height=2)
grid.add(SeatPixel("A14", seat=self.get_seat("A14"), seat_orientation="top"), row=55, column=25, width=6, height=2)
# Block B
grid.add(SeatPixel("B01", seat=self.get_seat("B01"), seat_orientation="bottom"), row=50, column=1, width=3, height=2)
grid.add(SeatPixel("B02", seat=self.get_seat("B02"), seat_orientation="bottom"), row=50, column=4, width=3, height=2)
grid.add(SeatPixel("B03", seat=self.get_seat("B03"), seat_orientation="bottom"), row=50, column=7, width=3, height=2)
grid.add(SeatPixel("B04", seat=self.get_seat("B04"), seat_orientation="bottom"), row=50, column=10, width=3, height=2)
grid.add(SeatPixel("B05", seat=self.get_seat("B05"), seat_orientation="bottom"), row=50, column=13, width=3, height=2)
grid.add(SeatPixel("B06", seat=self.get_seat("B06"), seat_orientation="bottom"), row=50, column=16, width=3, height=2)
grid.add(SeatPixel("B07", seat=self.get_seat("B07"), seat_orientation="bottom"), row=50, column=19, width=3, height=2)
grid.add(SeatPixel("B08", seat=self.get_seat("B08"), seat_orientation="bottom"), row=50, column=22, width=3, height=2)
grid.add(SeatPixel("B10", seat=self.get_seat("B10"), seat_orientation="top"), row=48, column=1, width=3, height=2)
grid.add(SeatPixel("B11", seat=self.get_seat("B11"), seat_orientation="top"), row=48, column=4, width=3, height=2)
grid.add(SeatPixel("B12", seat=self.get_seat("B12"), seat_orientation="top"), row=48, column=7, width=3, height=2)
grid.add(SeatPixel("B13", seat=self.get_seat("B13"), seat_orientation="top"), row=48, column=10, width=3, height=2)
grid.add(SeatPixel("B14", seat=self.get_seat("B14"), seat_orientation="top"), row=48, column=13, width=3, height=2)
grid.add(SeatPixel("B15", seat=self.get_seat("B15"), seat_orientation="top"), row=48, column=16, width=3, height=2)
grid.add(SeatPixel("B16", seat=self.get_seat("B16"), seat_orientation="top"), row=48, column=19, width=3, height=2)
grid.add(SeatPixel("B17", seat=self.get_seat("B17"), seat_orientation="top"), row=48, column=22, width=3, height=2)
# Block C
grid.add(SeatPixel("C01", seat=self.get_seat("C01"), seat_orientation="bottom"), row=43, column=1, width=3, height=2)
grid.add(SeatPixel("C02", seat=self.get_seat("C02"), seat_orientation="bottom"), row=43, column=4, width=3, height=2)
grid.add(SeatPixel("C03", seat=self.get_seat("C03"), seat_orientation="bottom"), row=43, column=7, width=3, height=2)
grid.add(SeatPixel("C04", seat=self.get_seat("C04"), seat_orientation="bottom"), row=43, column=10, width=3, height=2)
grid.add(SeatPixel("C05", seat=self.get_seat("C05"), seat_orientation="bottom"), row=43, column=13, width=3, height=2)
grid.add(SeatPixel("C06", seat=self.get_seat("C06"), seat_orientation="bottom"), row=43, column=16, width=3, height=2)
grid.add(SeatPixel("C07", seat=self.get_seat("C07"), seat_orientation="bottom"), row=43, column=19, width=3, height=2)
grid.add(SeatPixel("C08", seat=self.get_seat("C08"), seat_orientation="bottom"), row=43, column=22, width=3, height=2)
grid.add(SeatPixel("C10", seat=self.get_seat("C10"), seat_orientation="top"), row=41, column=1, width=3, height=2)
grid.add(SeatPixel("C11", seat=self.get_seat("C11"), seat_orientation="top"), row=41, column=4, width=3, height=2)
grid.add(SeatPixel("C12", seat=self.get_seat("C12"), seat_orientation="top"), row=41, column=7, width=3, height=2)
grid.add(SeatPixel("C13", seat=self.get_seat("C13"), seat_orientation="top"), row=41, column=10, width=3, height=2)
grid.add(SeatPixel("C14", seat=self.get_seat("C14"), seat_orientation="top"), row=41, column=13, width=3, height=2)
grid.add(SeatPixel("C15", seat=self.get_seat("C15"), seat_orientation="top"), row=41, column=16, width=3, height=2)
grid.add(SeatPixel("C16", seat=self.get_seat("C16"), seat_orientation="top"), row=41, column=19, width=3, height=2)
grid.add(SeatPixel("C17", seat=self.get_seat("C17"), seat_orientation="top"), row=41, column=22, width=3, height=2)
# Block D
grid.add(SeatPixel("D01", seat=self.get_seat("D01"), seat_orientation="bottom"), row=34, column=1, width=6, height=2)
grid.add(SeatPixel("D02", seat=self.get_seat("D02"), seat_orientation="bottom"), row=34, column=7, width=6, height=2)
grid.add(SeatPixel("D03", seat=self.get_seat("D03"), seat_orientation="bottom"), row=34, column=13, width=6, height=2)
grid.add(SeatPixel("D04", seat=self.get_seat("D04"), seat_orientation="bottom"), row=34, column=19, width=6, height=2)
grid.add(SeatPixel("D05", seat=self.get_seat("D05"), seat_orientation="bottom"), row=34, column=25, width=6, height=2)
grid.add(SeatPixel("D10", seat=self.get_seat("D10"), seat_orientation="top"), row=32, column=1, width=6, height=2)
grid.add(SeatPixel("D11", seat=self.get_seat("D11"), seat_orientation="top"), row=32, column=7, width=6, height=2)
grid.add(SeatPixel("D12", seat=self.get_seat("D12"), seat_orientation="top"), row=32, column=13, width=6, height=2)
grid.add(SeatPixel("D13", seat=self.get_seat("D13"), seat_orientation="top"), row=32, column=19, width=6, height=2)
grid.add(SeatPixel("D14", seat=self.get_seat("D14"), seat_orientation="top"), row=32, column=25, width=6, height=2)
# Block E
grid.add(SeatPixel("E01", seat=self.get_seat("E01"), seat_orientation="bottom"), row=27, column=1, width=6, height=2)
grid.add(SeatPixel("E02", seat=self.get_seat("E02"), seat_orientation="bottom"), row=27, column=7, width=6, height=2)
grid.add(SeatPixel("E03", seat=self.get_seat("E03"), seat_orientation="bottom"), row=27, column=13, width=6, height=2)
grid.add(SeatPixel("E04", seat=self.get_seat("E04"), seat_orientation="bottom"), row=27, column=19, width=6, height=2)
grid.add(SeatPixel("E05", seat=self.get_seat("E05"), seat_orientation="bottom"), row=27, column=25, width=6, height=2)
grid.add(SeatPixel("E10", seat=self.get_seat("E10"), seat_orientation="top"), row=25, column=1, width=6, height=2)
grid.add(SeatPixel("E11", seat=self.get_seat("E11"), seat_orientation="top"), row=25, column=7, width=6, height=2)
grid.add(SeatPixel("E12", seat=self.get_seat("E12"), seat_orientation="top"), row=25, column=13, width=6, height=2)
grid.add(SeatPixel("E13", seat=self.get_seat("E13"), seat_orientation="top"), row=25, column=19, width=6, height=2)
grid.add(SeatPixel("E14", seat=self.get_seat("E14"), seat_orientation="top"), row=25, column=25, width=6, height=2)
# Orga Block
grid.add(SeatPixel("Z01", seat=self.get_seat("Z01"), seat_orientation="top"), row=40, column=35, width=4, height=2)
grid.add(SeatPixel("Z02", seat=self.get_seat("Z02"), seat_orientation="top"), row=40, column=31, width=4, height=2)
grid.add(SeatPixel("Z\n0\n3", seat=self.get_seat("Z03"), seat_orientation="top"), row=40, column=29, width=2, height=6)
grid.add(SeatPixel("Z\n0\n4", seat=self.get_seat("Z04"), seat_orientation="bottom"), row=46, column=29, width=2, height=6)
grid.add(SeatPixel("Z05", seat=self.get_seat("Z05"), seat_orientation="bottom"), row=50, column=31, width=4, height=2)
grid.add(SeatPixel("Z06", seat=self.get_seat("Z06"), seat_orientation="bottom"), row=50, column=35, width=5, height=2)
# Stage
grid.add(TextPixel(text="Bühne"), row=16, column=1, width=39, height=4)
# Drinks
grid.add(TextPixel(text="G\ne\nt\nr\nä\nn\nk\ne"), row=20, column=40, width=4, height=12)
# Main Entrance
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
grid.add(TextPixel(icon_name="material/bed"), row=1, column=1, width=30, height=14)
# Toilet
grid.add(TextPixel(icon_name="material/wc"), row=1, column=52, width=19, height=10)
grid.add(TextPixel(icon_name="material/wc"), row=12, column=52, width=19, height=10)
return grid
+138
View File
@@ -0,0 +1,138 @@
from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color, PointerEventListener, Row, PointerEvent, Tooltip
from typing import Optional, Literal
from rio.event import on_populate
from elm.types import Seat, UserSession, User
class SeatPixel(Component):
seat_id: str
seat_orientation: Literal["top", "bottom"]
seat: Optional[Seat] = None
associated_user: Optional[User] = None
@on_populate
async def on_populate(self) -> None:
if self.seat is None:
self.seat = await Seat.find_one(Seat.seat_id == self.seat_id.replace("\n", ""), fetch_links=True)
if self.seat and self.seat.user is not None:
self.associated_user = await self.seat.user.fetch()
async def on_press(self, _: PointerEvent) -> None:
self.session.navigate_to(f"./seat-info?seat_id={self.seat_id.replace("\n", "")}")
def determine_color(self) -> Color:
if self.seat is not None:
try:
user_name = self.session[UserSession].user_name
except KeyError:
user_name = None
if self.seat.user is not None and self.associated_user and self.associated_user.user_name == user_name:
return Color.from_hex("800080")
elif self.seat.is_blocked or self.seat.user is not None:
return self.session.theme.danger_color
return self.session.theme.success_color
return self.session.theme.success_color
def build(self) -> Component:
if self.seat is None:
return Spacer()
text = Text(f"{self.seat_id}", style=TextStyle(fill=Color.BLACK, font_size=0.9), align_x=0.5, selectable=False)
rec = Rectangle(
content=Row(text),
min_width=1,
min_height=1,
fill=self.determine_color(),
stroke_width=0.1,
hover_stroke_width=0.1,
stroke_color=Color.BLACK,
grow_x=True,
grow_y=True,
hover_fill=self.session.theme.hud_color,
transition_time=0.4,
ripple=True
)
if self.associated_user or self.seat.is_blocked:
return PointerEventListener(
content=Tooltip(
anchor=rec,
tip=self.associated_user.user_name if self.associated_user else "Gesperrt",
position=self.seat_orientation,
),
on_press=self.on_press,
)
else:
return PointerEventListener(
content=rec,
on_press=self.on_press,
)
class TextPixel(Component):
text: Optional[str] = None
icon_name: Optional[str] = None
no_outline: bool = False
def build(self) -> Component:
if self.text is not None:
content = Text(self.text, style=TextStyle(fill=self.session.theme.text_color, font_size=1), align_x=0.5, selectable=False)
elif self.icon_name is not None:
content = Icon(self.icon_name, fill=self.session.theme.text_color)
else:
content = None
return Rectangle(
content=content,
min_width=1,
min_height=1,
fill=self.session.theme.background_color,
stroke_width=0.0 if self.no_outline else 0.1,
stroke_color=self.session.theme.neutral_color,
hover_stroke_width=None if self.no_outline else 0.1,
grow_x=True,
grow_y=True,
hover_fill=None,
ripple=True
)
class WallPixel(Component):
def build(self) -> Component:
return Rectangle(
min_width=1,
min_height=1,
fill=Color.from_hex("434343"),
grow_x=True,
grow_y=True,
)
class DebugPixel(Component):
def build(self) -> Component:
return Rectangle(
content=Spacer(),
min_width=1,
min_height=1,
fill=self.session.theme.success_color,
hover_stroke_color=self.session.theme.hud_color,
hover_stroke_width=0.1,
grow_x=True,
grow_y=True,
hover_fill=self.session.theme.secondary_color,
transition_time=0.1
)
class InvisiblePixel(Component):
def build(self) -> Component:
return Rectangle(
content=Spacer(),
min_width=1,
min_height=1,
fill=Color.TRANSPARENT,
hover_stroke_width=0.0,
grow_x=True,
grow_y=True
)
+120
View File
@@ -0,0 +1,120 @@
from asyncio import sleep
from decimal import Decimal
from typing import Callable
from rio import Component, Row, Column, Color, PointerEventListener, PointerEvent, Rectangle, Text, TextStyle, event
from elm.types import UserSession
from elm.services import AccountingService
class UserNavigationButton(Component):
text: str
target_url: str
close_navigation: Callable
new_tab: bool = False
current_rectangle_fill_color = Color.TRANSPARENT
current_text_fill_color = Color.from_hex("b9ccb2")
COLOR_ACTIVE_RECTANGLE = Color.from_hex("4EFBE5")
COLOR_ACTIVE_TEXT = Color.from_hex("4EFBE5")
COLOR_INACTIVE_RECTANGLE = Color.TRANSPARENT
COLOR_INACTIVE_TEXT = Color.from_hex("b9ccb2")
async def on_pointer_enter(self, _: PointerEvent) -> None:
self.current_rectangle_fill_color = self.COLOR_ACTIVE_RECTANGLE
self.current_text_fill_color = self.COLOR_ACTIVE_TEXT
self.force_refresh()
async def on_pointer_leave(self, _: PointerEvent) -> None:
self.current_rectangle_fill_color = self.COLOR_INACTIVE_RECTANGLE
self.current_text_fill_color = self.COLOR_INACTIVE_TEXT
self.force_refresh()
async def on_press(self, _: PointerEvent) -> None:
if self.new_tab:
self.session.open_url_in_browser(self.target_url)
else:
self.session.navigate_to(self.target_url)
await self.close_navigation()
await self.on_pointer_leave(None)
def build(self) -> Component:
return PointerEventListener(
Rectangle(
content=Row(
Rectangle(fill=self.current_rectangle_fill_color, min_width=0.2, margin_right=0.5, transition_time=0.2),
Text(
self.text,
style=TextStyle(fill=self.current_text_fill_color, font_weight="normal", font_size=0.9),
margin=0.5,
grow_x=True,
selectable=False
),
),
hover_fill=self.session.theme.primary_color_darker ,
corner_radius=0.1,
cursor="pointer",
transition_time=0.2
),
on_pointer_enter=self.on_pointer_enter,
on_pointer_leave=self.on_pointer_leave,
on_press=self.on_press
)
class UserNavigation(Component):
close_navigation: Callable
balance: Decimal = Decimal(0)
@event.on_page_change
async def on_page_change(self) -> None:
await self.close_navigation()
async def update_balance(self) -> None:
try:
balance = await self.session[AccountingService].get_balance(self.session[UserSession].user_name)
if balance != self.balance:
self.balance = balance
except KeyError:
pass
await sleep(5)
self.session.create_task(self.update_balance())
@event.on_populate
async def on_populate(self) -> None:
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:
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(
content=Column(*base_nav),
min_width=3.5,
min_height=3.5,
fill=self.session.theme.background_color
)
+14
View File
@@ -0,0 +1,14 @@
from .LanCountdownBox import LanCountdownBox
from .LanInfoBox import LanInfoBox
from .LandingPageBox import LandingPageBoxFull, LandingPageBoxHalf
from .UserNavigation import UserNavigationButton, UserNavigation
from .ElmButton import ElmButton
from .AvatarEditBox import AvatarEditBox
from .AccountInfoBox import AccountInfoBox
from .PersonalInfoBox import PersonalInfoBox
from .BuyTicketBox import BuyTicketBox
from .SeatingPlanPixels import *
from .SeatingPlan import *
from .CateringItemBox import CateringItemBox
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
)
+65
View File
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import Literal
from rio import Component, Column, Row, Text, page, Rectangle, SwitcherBar, SwitcherBarChangeEvent, List
from elm.components import CateringCategoryDisplay, CateringCart
from elm.types.CateringTypes import CateringMenuItem, CateringOrderedItem
@page(name="Catering", url_segment="catering")
class CateringPage(Component):
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:
self.active_category = event.value
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:
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, add_to_cart_pressed_callback=self.add_to_cart_pressed, 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
),
CateringCart(cart=self.cart, grow_y=True),
spacing=1,
min_width=20
),
spacing=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
)
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
from _sha2 import sha256
from random import choices
from typing import Any, Optional
from rio import Component, Column, Row, Text, Spacer, page, Color, Rectangle, TextInput, GuardEvent, ProgressCircle
from elm.types import UserSession, User
from elm.services import UserService, ConfigurationService, MailingService
from elm.components import ElmButton
def forgot_password_page_guard(event: GuardEvent) -> Optional[str]:
try:
_ = event.session[UserSession].user_name
return "/"
except KeyError:
return None
@page(name="Forgot password", url_segment="lost-pw", guard=forgot_password_page_guard)
class ForgotPasswordPage(Component):
mail: str = ""
success: bool = False
is_loading: bool = False
async def on_confirm(self, _: Any = None) -> None:
self.is_loading = True
lan_info = self.session[ConfigurationService].get_lan_info()
user_service = self.session[UserService]
mailing_service = self.session[MailingService]
user = await user_service.get_user_by_mail(self.mail.strip())
if user is not None:
new_password = "".join(choices(user_service.ALLOWED_USER_NAME_SYMBOLS, k=16))
user.user_fallback_password = sha256(new_password.encode(encoding="utf-8")).hexdigest()
await User.save(user)
await mailing_service.send_email(
subject=f"Dein neues Passwort für {lan_info.name}",
body=f"Du hast für den EZGG LAN Manager der {lan_info.name} ein neues Passwort angefragt. "
f"Und hier ist es schon:\n\n{new_password}\n\nSolltest du kein neues Passwort angefordert haben, "
f"ignoriere diese E-Mail.\n\nLiebe Grüße\nDein {lan_info.name} - Team",
receiver=self.mail.strip()
)
self.success = True
self.is_loading = False
def build(self) -> Component:
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text("Passwort vergessen", margin=0.5, selectable=False, overflow="wrap", grow_x=True),
ProgressCircle(min_size=1, margin=0.5, color="primary", progress=None if self.is_loading else 0)
),
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(
text=self.bind().mail,
label="Mail Adresse",
on_confirm=self.on_confirm
),
Text("Prüfe deine Mails!", fill=self.session.theme.success_color, overflow="wrap", justify="center") if self.success else Spacer(grow_x=False, grow_y=False),
ElmButton(text="Neues Passwort anfordern", style="small" if self.session.is_mobile() else "normal", on_press=self.on_confirm, wrap=self.session.is_mobile()),
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_height=15
),
align_x=0.5,
align_y=0.5
)
+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
)
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
from rio import Component, Column, Row, Text, Spacer, page, Color, TextStyle
from elm.components import LanCountdownBox, LanInfoBox, LandingPageBoxFull, LandingPageBoxHalf
@page(name="Landing", url_segment="")
class LandingPage(Component):
def build(self) -> Component:
full_box = LandingPageBoxFull(
image_name="news_image.jpg",
heading_text="EZGG LAN geht in die 2. Runde",
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"
)
half_box_1 = LandingPageBoxHalf(
heading_text="Sponsored by Crackz",
image_name="crackz.png",
article_text="CRACKZ ist dein Co-Op-Partner für verboten guten Geschmack und der Snack, der mit dir durchzockt.\n\n\n\nMehr auf crackz.gg",
link="https://www.crackz.gg"
)
half_box_2 = LandingPageBoxHalf(
heading_text="Made with rio",
image_name="rio.png",
article_text="Unsere Webseite ist mit rio umgesetzt.\n\nEinem einfach zu bedienenden Framework um ganze Webapps in reinem Python zu entwickeln.\n\n\nMehr auf rio.dev",
link="https://rio.dev"
)
if self.session.is_mobile():
return Column(
Column(
Row(
Text("//", style=TextStyle(font_size=1.7, fill=self.session.theme.primary_color), margin_right=2),
Text("Neuigkeiten", style=TextStyle(font_size=1.2, fill=Color.WHITE, font_weight="bold")),
Spacer(),
margin_bottom=0.5
),
Row(full_box, margin_bottom=2),
Column(half_box_1, half_box_2, spacing=1, margin_bottom=1),
Spacer()
),
Column(
LanInfoBox(),
LanCountdownBox(),
Spacer(),
spacing=2,
),
margin=0.5
)
else: # Tablet & Desktop
return Row(
Column(
Row(
Text("//", style=TextStyle(font_size=1.7, fill=self.session.theme.primary_color), margin_right=2),
Text("Neuigkeiten", style=TextStyle(font_size=1.2, fill=Color.WHITE, font_weight="bold")),
Spacer(),
Text("", margin_right=0.5, style=TextStyle(font_size=1.2, fill=self.session.theme.primary_color)),
Text("", margin_right=0.5, style=TextStyle(font_size=1.2, fill=self.session.theme.primary_color_darker)),
Text("", margin_right=0.5, style=TextStyle(font_size=1.2, fill=self.session.theme.primary_color_dark)),
margin_bottom=0.5
),
Row(full_box, margin_bottom=2),
Row(
Row(half_box_1, half_box_2, spacing=1, margin_bottom=1),
spacing=1,
margin_bottom=2
),
Spacer(),
grow_x=True
),
Column(
LanInfoBox(),
LanCountdownBox(),
Spacer(),
spacing=2,
),
spacing=2,
margin=2
)
+109
View File
@@ -0,0 +1,109 @@
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 elm.types import UserSession, User
from elm.services import UserService, LocalData, LocalDataService, ConfigurationService
from elm.components import ElmButton
def login_page_guard(event: GuardEvent) -> Optional[str]:
try:
_ = event.session[UserSession].user_name
return "/"
except KeyError:
return None
@page(name="Login", url_segment="login", guard=login_page_guard)
class LoginPage(Component):
user_name: str = ""
password: str = ""
error_on_last_attempt: bool = False
login_in_progress: bool = False
async def on_login_confirmed(self, _: Any) -> None:
""" Handler for pressing ENTER inside the text inputs """
await self.on_login_pressed()
async def on_login_pressed(self) -> None:
self.login_in_progress = True
user_name = copy(self.user_name) # Prevents race condition name swap
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:
user: User = await self.session[UserService].get_user(user_name)
self.error_on_last_attempt = False
user_session = UserSession(id=uuid4(), user_name=user.user_name, is_team_member=user.is_team_member)
self.session.attach(user_session)
token = self.session[LocalDataService].set_session(user_session)
self.session[LocalData].stored_session_token = token
self.session[UserSession].profile_picture = await self.load_user_picture()
self.session.attach(self.session[LocalData])
self.login_in_progress = False
self.session.navigate_to("./")
else:
self.login_in_progress = False
self.error_on_last_attempt = True
async def load_user_picture(self) -> bytes:
try:
user_picture = await self.session[UserService].get_user_picture(self.session[UserSession].user_name)
if user_picture is not None and len(user_picture) > 0:
return user_picture
except KeyError:
return self.session[ConfigurationService].DEFAULT_PROFILE_PICTURE
return self.session[ConfigurationService].DEFAULT_PROFILE_PICTURE
def on_register_pressed(self) -> None:
self.session.navigate_to("./register")
def on_lost_password_pressed(self) -> None:
self.session.navigate_to("./lost-pw")
def build(self) -> Component:
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Login", 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(
text=self.bind().user_name,
label="Nutzername",
on_confirm=self.on_login_confirmed
),
TextInput(
text=self.bind().password,
label="Passwort",
is_secret=True,
on_confirm=self.on_login_confirmed
),
Text("Falscher Nutzername oder Passwort", fill=self.session.theme.danger_color, overflow="wrap", justify="center") if self.error_on_last_attempt else Spacer(grow_x=False, grow_y=False),
ElmButton(text="Login", style="small" if self.session.is_mobile() else "normal", on_press=self.on_login_pressed, is_loading=self.login_in_progress),
ElmButton(text="Passwort\nvergessen" if self.session.is_mobile() else "Passwort vergessen", style="small" if self.session.is_mobile() else "normal", on_press=self.on_lost_password_pressed),
ElmButton(text="Account anlegen", style="small" if self.session.is_mobile() else "normal", on_press=self.on_register_pressed),
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_height=15
),
align_x=0.5,
align_y=0.5
)
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from rio import Component, Row, page
from rio.event import on_populate
from elm.types import UserSession
from elm.services import LocalData, LocalDataService
@page(name="Logout", url_segment="logout")
class LandingPage(Component):
@on_populate
def on_populate(self) -> None:
try:
self.session.detach(UserSession)
except KeyError:
pass
self.session[LocalDataService].del_session(self.session[LocalData].stored_session_token)
self.session[LocalData].stored_session_token = None
self.session.attach(self.session[LocalData])
self.session.navigate_to("/")
def build(self) -> Component:
return Row()
+242
View File
@@ -0,0 +1,242 @@
from __future__ import annotations
import logging
from asyncio import sleep
from datetime import datetime
from decimal import Decimal, ROUND_DOWN
from typing import Optional
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, GuardEvent, Revealer, Image, NumberInput
from rio.event import on_populate
from elm.types import UserSession, Transaction
from elm.services import AccountingService
from elm.components import ElmButton
logger = logging.getLogger(__name__.split(".")[-1])
class TransactionRow(Component):
transaction_time: datetime
transaction_title: str
transaction_amount: Decimal
is_debit: bool
def build(self) -> Component:
color = self.session.theme.danger_color if self.is_debit else self.session.theme.success_color
return Rectangle(
content=Row(
Text(
f"{self.transaction_time.strftime("%d.%m.%y")} /",
justify="left",
font_size=0.8,
margin_left=0.5,
fill=color
),
Text(
self.transaction_title,
justify="left",
font_size=0.8,
margin_left=0.5,
fill=color,
overflow="ellipsize",
grow_x=True
),
Text(
f"{'-' if self.is_debit else '+'}{str(self.transaction_amount.quantize(Decimal('.01'), rounding=ROUND_DOWN))}",
justify="right",
font_size=0.8,
margin_right=0.5,
fill=color
),
margin_bottom=0.5,
margin_top=0.5
),
hover_fill=self.session.theme.background_color,
transition_time=0.2
)
def my_balance_page_guard(event: GuardEvent) -> Optional[str]:
try:
_ = event.session[UserSession].user_name
return None
except KeyError:
return "/"
@page(name="My Balance", url_segment="balance", guard=my_balance_page_guard)
class MyBalancePage(Component):
current_balance: str = "-"
last_20_transactions: list[Transaction] = []
bank_revealer_open: bool = False
paypal_revealer_open: bool = False
payment_qr_image: bytes = bytes()
paypal_charge_amount: float = 0.00
paypal_charge_in_progress: bool = False
@on_populate
async def async_init(self) -> None:
self.current_balance = self.session[AccountingService].make_euro_string_from_decimal(
await self.session[AccountingService].get_balance(self.session[UserSession].user_name)
)
self.last_20_transactions = (await self.session[AccountingService].get_transaction_history(self.session[UserSession].user_name))[:20]
self.payment_qr_image = self.session[AccountingService].make_payment_qr_image(
"Einfach Zocken Gaming Gesellschaft",
"GENODE51BIK",
"DE47517624340019856607",
f"AUFLADUNG - {self.session[UserSession].user_name}")
async def check_if_paypal_process_done(self) -> None:
await sleep(2)
if await self.session[AccountingService].has_user_open_orders(self.session[UserSession].user_name):
self.session.create_task(self.check_if_paypal_process_done())
else:
self.paypal_charge_in_progress = False
self.paypal_charge_amount = 0.00
self.current_balance = self.session[AccountingService].make_euro_string_from_decimal(
await self.session[AccountingService].get_balance(self.session[UserSession].user_name)
)
self.last_20_transactions = (await self.session[AccountingService].get_transaction_history(self.session[UserSession].user_name))[:20]
self.paypal_revealer_open = False
async def pay_with_paypal(self) -> None:
self.paypal_charge_in_progress = True
logger.info("Starting PayPal transaction over %s for user %s", f"{self.paypal_charge_amount}", self.session[UserSession].user_name)
amount = Decimal(self.paypal_charge_amount)
try:
approval_url = await self.session[AccountingService].start_paypal_process(self.session[UserSession].user_name, amount)
except Exception as e:
logger.error(e)
return
self.session.open_url_in_browser(approval_url)
self.session.create_task(self.check_if_paypal_process_done())
async def toggle_bank_revealer(self) -> None:
self.bank_revealer_open = not self.bank_revealer_open
async def toggle_paypal_revealer(self) -> None:
self.paypal_revealer_open = not self.paypal_revealer_open
def build(self) -> Component:
col_row = Column if self.session.is_mobile() else Row
transaction_rows = []
for transaction in sorted(self.last_20_transactions, key=lambda t: t.transaction_date, reverse=True):
transaction_rows.append(
TransactionRow(
transaction_time=transaction.transaction_date,
transaction_title=transaction.title,
transaction_amount=transaction.value,
is_debit=transaction.is_debit
)
)
return col_row(
Column(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Guthaben", 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=self.current_balance,
justify="center",
font_size=2,
grow_x=True,
grow_y=True,
margin_top=2,
margin_bottom=1
),
Spacer(),
margin=1,
spacing=1
)
),
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("Guthaben aufladen", 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(
ElmButton(text="Banküberweisung", style="small" if self.session.is_mobile() else "normal", on_press=self.toggle_bank_revealer),
Revealer(header=None, is_open=self.bank_revealer_open, content=Column(
Text("QR Code", justify="center"),
Image(self.payment_qr_image, min_width=14, min_height=14, margin_bottom=1),
Text("Bankverbindung", justify="center"),
Text("Empfänger: Einfach Zocken Gaming Gesellschaft", justify="left", overflow="wrap", font_size=0.7),
Text("IBAN: DE47517624340019856607", justify="left", overflow="wrap", font_size=0.7),
Text("BIC: GENODE51BIK", justify="left", overflow="wrap", font_size=0.7),
Text(f"Verwendungszweck: AUFLADUNG - {self.session[UserSession].user_name}", justify="left", overflow="wrap", font_size=0.7),
spacing=1
)),
ElmButton(text="Paypal", style="small" if self.session.is_mobile() else "normal", on_press=self.toggle_paypal_revealer),
Revealer(header=None, is_open=self.paypal_revealer_open, content=Column(
NumberInput(label="Summe", decimals=2, value=self.bind().paypal_charge_amount, suffix_text=""),
ElmButton(text="Jetzt aufladen", style="small" if self.session.is_mobile() else "normal", on_press=self.pay_with_paypal, is_loading=self.paypal_charge_in_progress),
spacing=1
)),
margin=1,
spacing=1
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
Spacer(),
spacing=1
),
Column(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Letzte Transaktionen", 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(
Rectangle(
content=Row(
Text("Datum / Titel", justify="left", font_size=0.8, margin_left=0.5),
Text("Betrag", justify="right", font_size=0.8, margin_right=0.5),
margin_bottom=0.5,
margin_top=0.5
)
),
*transaction_rows,
spacing=0.5,
margin=1
)
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
Spacer(),
spacing=1,
grow_x=True
),
spacing=1,
margin=1,
margin_right=2
)
+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
)
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import Optional
from rio import Component, Column, Row, Spacer, page, GuardEvent
from elm.types import UserSession
from elm.components import AvatarEditBox, AccountInfoBox, PersonalInfoBox
def my_profile_page_guard(event: GuardEvent) -> Optional[str]:
try:
_ = event.session[UserSession].user_name
return None
except KeyError:
return "/"
@page(name="My Profile", url_segment="my-profile", guard=my_profile_page_guard)
class MyProfilePage(Component):
def build(self) -> Component:
if self.session.is_mobile():
return Column(
Column(
AvatarEditBox(),
Spacer(),
spacing=1
),
Column(
AccountInfoBox(),
PersonalInfoBox(),
Spacer(),
spacing=1,
grow_x=True
),
spacing=1,
margin=0.5
)
else:
return Row(
Column(
AvatarEditBox(),
Spacer(),
spacing=1
),
Column(
AccountInfoBox(),
PersonalInfoBox(),
Spacer(),
spacing=1,
grow_x=True
),
spacing=1,
margin=1,
margin_right=2
)
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import logging
from asyncio import sleep
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, QueryParameter, ProgressCircle
from rio.event import on_populate
from elm.services import AccountingService
logger = logging.getLogger(__name__.split(".")[-1])
@page(name="PayPal Return", url_segment="return-paypal")
class PayPalReturnPage(Component):
token: QueryParameter[str] = "No Value"
in_progress: bool = True
error_message: str = ""
success_message: str = ""
@on_populate
async def on_populate(self) -> None:
result = await self.session[AccountingService].finalize_paypal_process(self.token)
await sleep(1)
if result:
self.in_progress = False
self.success_message = "Aufladung erfolgreich. Du kannst dieses Fenster schließen."
else:
self.in_progress = False
self.error_message = "Es ist ein Fehler aufgetreten, bitte kontaktiere uns"
def build(self) -> Component:
col_contents = []
if self.in_progress:
col_contents.append(ProgressCircle(min_size=5, color=self.session.theme.primary_color))
col_contents.append(Text("Wir prüfen deine Aufladung", overflow="wrap", justify="center"))
else:
if self.error_message:
col_contents.append(Text(self.error_message, overflow="wrap", justify="center", fill=self.session.theme.danger_color))
elif self.success_message:
col_contents.append(Text(self.success_message, overflow="wrap", justify="center", fill=self.session.theme.success_color))
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Paypal Aufladung", 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(
*col_contents,
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_width=1 if self.session.is_mobile() else 25
),
align_x=0.5,
align_y=0.5
)
+147
View File
@@ -0,0 +1,147 @@
from __future__ import annotations
from copy import copy
from typing import Any, Optional
from rio import Component, Column, Row, Text, Spacer, page, Color, Rectangle, TextInput, GuardEvent, ProgressCircle
from email_validator import validate_email, EmailNotValidError
from elm.types import UserSession
from elm.services import UserService, NameNotAllowedError, MailAlreadyInUseError
from elm.components import ElmButton
def register_page_guard(event: GuardEvent) -> Optional[str]:
try:
_ = event.session[UserSession].user_name
return "/"
except KeyError:
return None
@page(name="Register", url_segment="register", guard=register_page_guard)
class RegisterPage(Component):
user_name: str = ""
password_1: str = ""
password_2: str = ""
mail: str = ""
error_message: str = ""
success_message: str = ""
input_blocked: bool = False
async def on_register_confirmed(self, _: Any) -> None:
""" Handler for pressing ENTER inside the text inputs """
await self.on_register_pressed()
async def on_register_pressed(self) -> None:
self.input_blocked = True
user_name = copy(self.user_name) # Prevents race condition name swap
if len(user_name) < 3:
self.error_message = f"Nutzername muss mindestens 3 Zeichen haben"
self.input_blocked = False
return
if len(self.password_1) == 0 or len(self.password_2) == 0:
self.error_message = "Kein Passwort gesetzt"
self.input_blocked = False
return
if self.password_1 != self.password_2:
self.error_message = "Passwörter stimmen nicht überein"
self.input_blocked = False
return
try:
validate_email(self.mail, check_deliverability=False)
except EmailNotValidError:
self.error_message = "Ungültige Mail Adresse"
self.input_blocked = False
return
existing_user = await self.session[UserService].get_user(user_name)
if existing_user:
self.error_message = "Nutzer exisitiert bereits"
self.input_blocked = False
return
try:
await self.session[UserService].create_user(user_name, self.mail, self.password_1)
except NameNotAllowedError as e:
self.error_message = f"Nutzername enthält unerlaubte Zeichen: {e.disallowed_char}"
self.input_blocked = False
return
except MailAlreadyInUseError:
self.error_message = "Mail Adresse bereits in Nutzung"
self.input_blocked = False
return
self.error_message = ""
self.user_name, self.password_1, self.password_2, self.mail = "", "", "", ""
self.success_message = "Registrierung erfolgreich"
self.input_blocked = False
def build(self) -> Component:
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text("Registrierung", margin=0.5, selectable=False, overflow="wrap", grow_x=True),
ProgressCircle(min_size=1, margin=0.5, color="primary", progress=None if self.input_blocked else 0)
),
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(
text=self.bind().user_name,
label="Nutzername",
on_confirm=self.on_register_confirmed,
is_sensitive=not self.input_blocked
),
TextInput(
text=self.bind().mail,
label="E-Mail",
on_confirm=self.on_register_confirmed,
is_sensitive=not self.input_blocked
),
TextInput(
text=self.bind().password_1,
label="Passwort",
is_secret=True,
on_confirm=self.on_register_confirmed,
is_sensitive=not self.input_blocked
),
TextInput(
text=self.bind().password_2,
label="wiederholen" if self.session.is_mobile() else "Passwort wiederholen",
is_secret=True,
on_confirm=self.on_register_confirmed,
is_sensitive=not self.input_blocked
),
Text(self.error_message, fill=self.session.theme.danger_color, overflow="wrap", justify="center") if self.error_message else Spacer(grow_x=False, grow_y=False),
Text(self.success_message, fill=self.session.theme.success_color, overflow="wrap", justify="center") if self.success_message else Spacer(grow_x=False, grow_y=False),
ElmButton(
text="Registrieren",
style="small" if self.session.is_mobile() else "normal",
on_press=self.on_register_pressed
) if not self.success_message else Spacer(grow_x=False, grow_y=False),
ElmButton(text="Jetzt einloggen", style="small" if self.session.is_mobile() else "normal", on_press=lambda: self.session.navigate_to("./login")) if self.success_message else Spacer(grow_x=False, grow_y=False),
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_height=15
),
align_x=0.5,
align_y=0.5
)
+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
)
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
from typing import Optional
from bson import ObjectId
from rio import Component, page, Rectangle, ProgressCircle, Row, QueryParameter, Column, Text, Spacer
from rio.event import on_populate
from elm import UserSession
from elm.components import ElmButton
from elm.types import Seat, User, Ticket
@page(name="Seat Info", url_segment="seat-info")
class SeatInfoPage(Component):
seat_id: QueryParameter[str] = ""
seat: Optional[Seat] = None
seat_user: Optional[User] = None
initial_load_done: bool = False
choosing_button_loading: bool = False
message: str = ""
message_is_error: bool = False
@on_populate
async def on_populate(self) -> None:
self.seat = await Seat.find_one(Seat.seat_id == self.seat_id)
if self.seat and self.seat.user is not None:
self.seat_user = await self.seat.user.fetch()
self.initial_load_done = True
async def choose_seat(self) -> None:
self.choosing_button_loading = True
try:
user_name = self.session[UserSession].user_name
except KeyError:
self.session.navigate_to("./login")
return
user = await User.find_one(User.user_name == user_name)
if not user:
return
user_ticket = await Ticket.find_one(
{"owner.$id": ObjectId(user.id)}
)
if not user_ticket or user_ticket.category != self.seat.category:
self.message = "Du hast nicht das passende Ticket"
self.message_is_error = True
self.choosing_button_loading = False
return
user_seat = await Seat.find_one(
{"user.$id": ObjectId(user.id)}
)
if user_seat is not None:
self.message = "Du hast bereits einen Sitzplatz"
self.message_is_error = True
self.choosing_button_loading = False
return
s = await Seat.find_one(Seat.seat_id == self.seat_id)
if not s:
return
s.user = user
await s.save()
self.message = "Sitzplatz gewählt!"
self.message_is_error = False
self.choosing_button_loading = False
def build(self) -> Component:
if not self.initial_load_done:
box_contents = [ProgressCircle(margin=1)]
else:
if self.seat is None:
box_contents = [Text(text="Der angeforderte Sitzplatz konnte nicht gefunden werden", margin=1, overflow="wrap", justify="center", fill=self.session.theme.danger_color)]
else:
box_contents = [
Row(
Text(text="Kategorie:", justify="left"),
Text(text=self.seat.category, justify="right"),
spacing=1
),
Row(
Text(text="Belegt:", justify="left"),
Text(text="Ja" if self.seat.user is not None or self.seat.is_blocked else "Nein", justify="right", fill=self.session.theme.danger_color if self.seat.user is not None or self.seat.is_blocked else self.session.theme.success_color),
spacing=1
),
Row(
Text(text="Nutzer:", justify="left"),
Text(text=self.seat_user.user_name if self.seat_user else "-", justify="right"),
spacing=1
),
]
if not self.seat.is_blocked and self.seat.user is None:
box_contents.append(
ElmButton(text="Platz wählen", on_press=self.choose_seat, is_loading=self.choosing_button_loading)
)
box_contents.append(
Text(text=self.message, fill=self.session.theme.danger_color if self.message_is_error else self.session.theme.success_color, overflow="wrap", justify="center")
)
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text(f"Sitzplatz: {self.seat_id}", margin=0.5, selectable=False),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
*box_contents,
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
align_x=0.5,
align_y=0.5
)
+29
View File
@@ -0,0 +1,29 @@
from __future__ import annotations
from typing import Optional
from rio import Component, page, Rectangle, PointerEvent, ProgressCircle, Row
from rio.event import on_populate
from elm.components import SeatingPlan
from elm.types import Seat
@page(name="Seating Plan", url_segment="seating")
class SeatingPlanPage(Component):
preloaded_seats: Optional[list[Seat]] = None
@on_populate
async def on_populate(self) -> None:
self.preloaded_seats = await Seat.find_all().to_list()
def build(self) -> Component:
return Rectangle(
content=Row(ProgressCircle(), margin=self.session.screen_width // 6) if self.preloaded_seats is None else SeatingPlan(margin=0, preloaded_seats=self.preloaded_seats),
fill=self.session.theme.box_color,
stroke_width = 0.1,
stroke_color = self.session.theme.box_border_color,
margin_left=1,
margin_top=1
)
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from rio import Component, Column, Row, page
from elm.services import ConfigurationService
from elm.components import BuyTicketBox
@page(name="Tickets", url_segment="tickets")
class TicketsPage(Component):
def build(self) -> Component:
row_col = Column if self.session.is_mobile() else Row
ticket_boxes = []
for ticket_info in self.session[ConfigurationService].get_ticket_info():
ticket_boxes.append(BuyTicketBox(ticket_info=ticket_info))
return row_col(
*ticket_boxes,
spacing=1,
margin=1
)
+42
View File
@@ -0,0 +1,42 @@
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 elm.types import UserSession, User
from elm.services import UserService, LocalData, LocalDataService, ConfigurationService
from elm.components import ElmButton
@page(name="Tournaments", url_segment="tournaments")
class TournamentsPage(Component):
def build(self) -> Component:
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Turniere", 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("Es wurde noch kein Turnierbaum hinterlegt", fill=self.session.theme.text_color, overflow="wrap", justify="center"),
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
align_x=0.5,
align_y=0.5
)
+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
)
+217
View File
@@ -0,0 +1,217 @@
import io
import logging
from decimal import Decimal, ROUND_DOWN
from typing import Optional
import httpx
import qrcode
from elm.types import Transaction, User, PayPalConfiguration
from elm.services import MailingService, ConfigurationService
logger = logging.getLogger(__name__.split(".")[-1])
class InsufficientFundsError(Exception):
pass
class AccountingService:
PAYPAL_SANDBOX_URL = "https://api-m.sandbox.paypal.com"
PAYPAL_PROD_URL = "https://api-m.paypal.com"
def __init__(self, configuration_service: ConfigurationService, mailing_service: MailingService) -> None:
self._configuration_service = configuration_service
self._paypal_config: PayPalConfiguration = configuration_service.get_paypal_configuration()
self._pending_paypal_orders: dict[str, tuple[str, Decimal]] = {}
self._mailing_service = mailing_service
async def has_user_open_orders(self, user_name: str) -> bool:
for pending_paypal_order in self._pending_paypal_orders.values():
if pending_paypal_order[0] == user_name:
return True
return False
async def get_paypal_access_token(self) -> str:
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
async with httpx.AsyncClient() as client:
response = await client.post(
f"{url}/v1/oauth2/token",
auth=(
self._paypal_config.client_id_sandbox if self._configuration_service.DEV_MODE_ACTIVE else self._paypal_config.client_id,
self._paypal_config.secret_sandbox if self._configuration_service.DEV_MODE_ACTIVE else self._paypal_config.secret,
),
headers={
"Content-Type": "application/x-www-form-urlencoded",
},
data={
"grant_type": "client_credentials"
}
)
response.raise_for_status()
data = response.json()
return data["access_token"]
async def start_paypal_process(self, user_name: str, amount: Decimal) -> str:
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
return_domain = "http://localhost:8000" if self._configuration_service.DEV_MODE_ACTIVE else self._configuration_service.BASE_URL
amount = amount.quantize(Decimal(".01"))
async with httpx.AsyncClient() as client:
access_token = await self.get_paypal_access_token()
response = await client.post(
url=f"{url}/v2/checkout/orders/",
headers={
"Authorization": f"Bearer {access_token}"
},
json={
"intent": "CAPTURE",
"purchase_units": [
{
"custom_id": user_name,
"amount": {
"currency_code": "EUR",
"value": str(amount)
}
}
],
"payment_source": {
"paypal": {
"experience_context": {
"return_url": f"{return_domain}/return-paypal",
"cancel_url": f"{return_domain}/cancel-paypal",
"user_action": "PAY_NOW",
"shipping_preference": "NO_SHIPPING"
}
}
}
}
)
try:
payer_action_url = next(
link["href"]
for link in response.json()["links"]
if link["rel"] == "payer-action"
)
except StopIteration:
logger.error("No payer action url found: %s", response.text)
return "#"
self._pending_paypal_orders[response.json()["id"]] = (user_name, amount)
return payer_action_url
async def finalize_paypal_process(self, order_id: str) -> bool:
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
async with httpx.AsyncClient() as client:
access_token = await self.get_paypal_access_token()
response = await client.get(
url=f"{url}/v2/checkout/orders/{order_id}",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
)
is_approved = response.json()["status"] == "APPROVED"
if is_approved:
response = await client.post(
f"{url}/v2/checkout/orders/{order_id}/capture",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
)
is_completed = response.json()["status"] == "COMPLETED"
if is_completed:
await self.add_balance(self._pending_paypal_orders[order_id][0], self._pending_paypal_orders[order_id][1], "PayPal Aufladung")
self._pending_paypal_orders.pop(order_id)
return True
return False
async def add_balance(self, user_name: str, balance_to_add: Decimal, title: str) -> Decimal:
user = await User.find_one(User.user_name == user_name)
if not user:
raise KeyError("User does not exist")
await Transaction(
user_name=user_name,
value=balance_to_add,
is_debit=False,
title=title
).save()
logger.debug(f"Added balance of {self.make_euro_string_from_decimal(balance_to_add)} to user '{user_name}'")
new_balance = await self.get_balance(user_name)
await self._mailing_service.send_email(
"Dein Guthaben wurde aufgeladen",
self._mailing_service.generate_account_balance_added_mail_body(user, balance_to_add, new_balance),
user.user_mail
)
return new_balance
async def remove_balance(self, user_name: str, balance_to_remove: Decimal, title: str) -> Decimal:
current_balance = await self.get_balance(user_name)
if (current_balance - balance_to_remove) < 0:
raise InsufficientFundsError
await Transaction(
user_name=user_name,
value=balance_to_remove,
is_debit=True,
title=title
).save()
logger.debug(
f"Removed balance of {self.make_euro_string_from_decimal(balance_to_remove)} from user '{user_name}'")
return await self.get_balance(user_name)
async def get_balance(self, user_name: str) -> Decimal:
balance_buffer = Decimal("0")
for transaction in await self.get_transaction_history(user_name):
if transaction.is_debit:
balance_buffer -= transaction.value
else:
balance_buffer += transaction.value
return balance_buffer
@staticmethod
async def get_transaction_history(user_name: str) -> list[Transaction]:
user = await User.find_one(User.user_name == user_name)
if not user:
raise KeyError("User does not exist")
return await Transaction.find_many(Transaction.user_name == user_name).to_list()
@staticmethod
def make_euro_string_from_decimal(euros: Optional[Decimal]) -> str:
"""
Internally, all money values are euros as decimal. Only when showing them to the user we generate a string.
"""
if euros is None:
return "0.00 €"
rounded_decimal = str(euros.quantize(Decimal(".01"), rounding=ROUND_DOWN))
return f"{rounded_decimal}"
@staticmethod
def make_payment_qr_image(beneficiary_name, beneficiary_bic, beneficiary_iban, text, amount_euros=None) -> bytes:
text = text.replace("\n", ";")
amount_formatted = "EUR{:.2f}".format(amount_euros) if amount_euros else ""
epc_text = f"""BCD
002
1
SCT
{beneficiary_bic}
{beneficiary_name}
{beneficiary_iban}
{amount_formatted}
{text}
"""
qr = qrcode.QRCode(
version=6,
error_correction=qrcode.constants.ERROR_CORRECT_M,
)
qr.add_data(epc_text)
img = qr.make_image()
img_bytes = io.BytesIO()
img.save(img_bytes)
return img_bytes.getvalue()
+143
View File
@@ -0,0 +1,143 @@
import sys
from datetime import datetime
from decimal import Decimal
from pathlib import Path
import logging
import tomllib
from from_root import from_root
from elm.types.ConfigurationTypes import MailingServiceConfiguration, LanInfo, ReceiptPrintingConfiguration, DatabaseConfiguration, PayPalConfiguration, TicketInfo
logger = logging.getLogger(__name__.split(".")[-1])
logger.setLevel(logging.DEBUG)
class ConfigurationService:
def __init__(self, config_file_path: Path) -> None:
try:
with open(from_root("VERSION"), "r") as version_file:
self._version = version_file.read().strip()
except FileNotFoundError:
logger.warning("Could not find VERSION file, defaulting to '0.0.0'")
self._version = "0.0.0"
try:
with open(config_file_path, "rb") as config_file:
self._config = tomllib.load(config_file)
except FileNotFoundError:
logger.fatal(f"Could not find config file at \"{config_file_path}\", exiting...")
exit(1)
try:
self._DEFAULT_PROFILE_PICTURE = self._preload_default_profile_picture()
except FileNotFoundError:
logger.fatal("Could not find default profile picture, exiting...")
exit(1)
def get_paypal_configuration(self) -> PayPalConfiguration:
try:
return PayPalConfiguration(
client_id_sandbox=self._config["paypal"]["client_id_sandbox"],
secret_sandbox=self._config["paypal"]["secret_sandbox"],
client_id=self._config["paypal"]["client_id"],
secret=self._config["paypal"]["secret"]
)
except KeyError:
logger.fatal("Error loading DatabaseConfiguration, exiting...")
sys.exit(1)
def get_ticket_info(self) -> tuple[TicketInfo, ...]:
try:
return tuple([TicketInfo(
category=value,
total_tickets=self._config["tickets"][value]["total_tickets"],
price=Decimal(self._config["tickets"][value]["price"]),
description=self._config["tickets"][value]["description"],
additional_info=self._config["tickets"][value]["additional_info"],
can_be_sold=self._config["tickets"][value]["can_be_sold"]
) for value in self._config["tickets"]])
except KeyError as e:
logger.debug(e)
logger.fatal("Error loading ticket configuration, exiting...")
sys.exit(1)
def get_database_configuration(self) -> DatabaseConfiguration:
try:
return DatabaseConfiguration(
database_host=self._config["database"]["database_host"],
database_port=self._config["database"]["database_port"],
database_name=self._config["database"]["database_name"],
database_user=self._config["database"]["database_user"],
database_password=self._config["database"]["database_password"],
)
except KeyError:
logger.fatal("Error loading DatabaseConfiguration, exiting...")
sys.exit(1)
def get_mailing_service_configuration(self) -> MailingServiceConfiguration:
try:
mailing_configuration = self._config["mailing"]
return MailingServiceConfiguration(
smtp_server=mailing_configuration["smtp_server"],
smtp_port=mailing_configuration["smtp_port"],
sender=mailing_configuration["sender"],
username=mailing_configuration["username"],
password=mailing_configuration["password"]
)
except KeyError:
logger.fatal("Error loading MailingServiceConfiguration, exiting...")
sys.exit(1)
def get_lan_info(self) -> LanInfo:
try:
lan_info = self._config["lan"]
return LanInfo(
name=lan_info["name"],
iteration=lan_info["iteration"],
date_from=datetime.strptime(lan_info["date_from"], "%Y-%m-%d %H:%M:%S"),
date_till=datetime.strptime(lan_info["date_till"], "%Y-%m-%d %H:%M:%S"),
organizer_mail=lan_info["organizer_mail"],
internet_speed_mbs=lan_info["internet_speed_mbs"],
has_wifi=lan_info["has_wifi"],
has_showers=lan_info["has_showers"],
ts3_address=lan_info["ts3_address"],
discord_invite_link=lan_info["discord_invite_link"]
)
except KeyError:
logger.fatal("Error loading LAN Info, exiting...")
sys.exit(1)
def get_receipt_printing_configuration(self) -> ReceiptPrintingConfiguration:
try:
receipt_printing_configuration = self._config["receipt_printing"]
return ReceiptPrintingConfiguration(
host=receipt_printing_configuration["host"],
port=receipt_printing_configuration["port"],
order_print_endpoint=receipt_printing_configuration["order_print_endpoint"],
password=receipt_printing_configuration["password"]
)
except KeyError:
logger.fatal("Error loading Receipt Printing Configuration, exiting...")
sys.exit(1)
def _preload_default_profile_picture(self) -> bytes:
with open(from_root(self._config["misc"]["default_profile_picture"]), "rb") as file:
return file.read()
@property
def APP_VERSION(self) -> str:
return self._version
@property
def DEV_MODE_ACTIVE(self) -> bool:
return self._config["misc"]["dev_mode_active"]
@property
def DEFAULT_PROFILE_PICTURE(self) -> bytes:
return self._DEFAULT_PROFILE_PICTURE
@property
def BASE_URL(self) -> str:
return self._config["misc"]["base_url"]
+58
View File
@@ -0,0 +1,58 @@
import logging
import sys
from beanie import init_beanie
from pymongo import AsyncMongoClient
from pymongo.asynchronous.collection import AsyncCollection
from pymongo.errors import ServerSelectionTimeoutError, OperationFailure
from elm.types import User, Transaction, Ticket, Seat, CateringTypes
from elm.types.ConfigurationTypes import DatabaseConfiguration
logger = logging.getLogger(__name__.split(".")[-1])
logger.setLevel(logging.DEBUG)
class DuplicationError(Exception):
pass
class NoDatabaseConnectionError(Exception):
pass
class DatabaseService:
def __init__(self, db_config: DatabaseConfiguration) -> None:
self._db_config = db_config
self._client = None
self._database = 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:
self._client = AsyncMongoClient(mongo_uri)
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(
database=self._database,
document_models=[
User,
Transaction,
Ticket,
Seat,
CateringTypes.CateringMenuItem,
CateringTypes.CateringOrder
]
)
+26
View File
@@ -0,0 +1,26 @@
import secrets
from typing import Optional
from rio import UserSettings
from elm.types.UserSession import UserSession
class LocalData(UserSettings):
stored_session_token: Optional[str] = None
class LocalDataService:
def __init__(self) -> None:
self._session: dict[str, UserSession] = {}
def verify_token(self, token: str) -> Optional[UserSession]:
return self._session.get(token)
def set_session(self, session: UserSession) -> str:
key = secrets.token_hex(32)
self._session[key] = session
return key
def del_session(self, token: Optional[str]) -> None:
if token is not None:
self._session.pop(token, None)
+55
View File
@@ -0,0 +1,55 @@
import logging
from decimal import Decimal
from email.message import EmailMessage
from asyncio import sleep
import aiosmtplib
from elm.services.ConfigurationService import ConfigurationService
from elm.types.User import User
logger = logging.getLogger(__name__.split(".")[-1])
logger.setLevel(logging.DEBUG)
class MailingService:
def __init__(self, configuration_service: ConfigurationService):
self._configuration_service = configuration_service
self._config = self._configuration_service.get_mailing_service_configuration()
async def send_email(self, subject: str, body: str, receiver: str) -> None:
if self._configuration_service.DEV_MODE_ACTIVE:
logger.info(f"Skipped sending mail to {receiver} because demo mode is active.")
logger.info(f"Subject: {subject}")
logger.info(f"Receiver: {receiver}")
logger.info(f"Body: {body}")
await sleep(1)
return
try:
message = EmailMessage()
message["From"] = self._config.sender
message["To"] = receiver
message["Subject"] = subject
message.set_content(body)
await aiosmtplib.send(
message,
hostname=self._config.smtp_server,
port=self._config.smtp_port,
username=self._config.username,
password=self._config.password
)
except Exception as e:
logger.error(f"Failed to send email: {e}")
def generate_account_balance_added_mail_body(self, user: User, added_balance: Decimal, total_balance: Decimal) -> str:
return f"""
Hallo {user.user_name},
deinem Account wurden {added_balance:.2f} € hinzugefügt. Dein neues Guthaben beträgt nun {total_balance:.2f} €.
Wenn du zu dieser Aufladung Fragen hast, stehen wir dir in unserem Discord Server oder per Mail an {self._configuration_service.get_lan_info().organizer_mail} zur Verfügung.
Liebe Grüße
Dein {self._configuration_service.get_lan_info().name} Team
"""
+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)
+83
View File
@@ -0,0 +1,83 @@
from hashlib import sha256
from typing import Optional
from string import ascii_letters, digits
from pymongo.errors import DuplicateKeyError
from elm.types.User import User
class NameNotAllowedError(Exception):
def __init__(self, disallowed_char: str) -> None:
self.disallowed_char = disallowed_char
class MailAlreadyInUseError(Exception):
pass
class UserService:
ALLOWED_USER_NAME_SYMBOLS = ascii_letters + digits + "!#$%&*+,-./:;<=>?[]^_{|}~"
MAX_USERNAME_LENGTH = 14
def __init__(self) -> None:
pass
@staticmethod
async def get_all_users() -> list[User]:
return await User.find_all().to_list()
@staticmethod
async def get_user(user_name: str) -> Optional[User]:
return await User.find_one(User.user_name == user_name)
@staticmethod
async def get_user_by_mail(mail: str) -> Optional[User]:
return await User.find_one(User.user_mail == mail.lower())
@staticmethod
async def get_user_picture(user_name: str) -> Optional[bytes]:
user = await User.find_one(User.user_name == user_name)
if user:
return user.user_picture
return None
@staticmethod
async def change_user_password(user_name: str, new_password: str) -> bool:
user = await User.find_one(User.user_name == user_name)
if not user:
return False
user.user_password = sha256(new_password.encode(encoding="utf-8")).hexdigest()
await user.save()
return True
async def create_user(self, user_name: str, user_mail: str, password_clear_text: str) -> User:
disallowed_char = self._check_for_disallowed_char(user_name)
if disallowed_char:
raise NameNotAllowedError(disallowed_char)
try:
return await User(
user_name=user_name,
user_mail=user_mail.lower(),
user_password=sha256(password_clear_text.encode(encoding="utf-8")).hexdigest()
).insert()
except DuplicateKeyError:
raise MailAlreadyInUseError
async def is_login_valid(self, user_name: str, password_clear_text: str) -> bool:
user = await self.get_user(user_name)
user_password_hash = sha256(password_clear_text.encode(encoding="utf-8")).hexdigest()
if not user:
return False
if user.user_fallback_password and user.user_fallback_password == user_password_hash:
return True
return user.user_password == user_password_hash
def _check_for_disallowed_char(self, name: str) -> Optional[str]:
for c in name:
if c not in self.ALLOWED_USER_NAME_SYMBOLS:
return c
return None
+8
View File
@@ -0,0 +1,8 @@
from .ConfigurationService import ConfigurationService
from .DatabaseService import DatabaseService
from .UserService import UserService, NameNotAllowedError, MailAlreadyInUseError
from .LocalDataService import LocalData, LocalDataService
from .MailingService import MailingService
from .AccountingService import AccountingService
from .PreloadService import PreloadService
from .ReceiptPrintingService import ReceiptPrintingService
+202
View File
@@ -0,0 +1,202 @@
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
@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):
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"
@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)
+60
View File
@@ -0,0 +1,60 @@
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
class NoSuchCategoryError(Exception):
pass
@dataclass(frozen=True)
class MailingServiceConfiguration:
smtp_server: str
smtp_port: int
sender: str
username: str
password: str
@dataclass(frozen=True)
class DatabaseConfiguration:
database_host: str
database_port: str
database_name: str
database_user: str
database_password :str
@dataclass(frozen=True)
class LanInfo:
name: str
iteration: str
date_from: datetime
date_till: datetime
organizer_mail: str
internet_speed_mbs: int
has_wifi: bool
has_showers: bool
ts3_address: str
discord_invite_link: str
@dataclass(frozen=True)
class ReceiptPrintingConfiguration:
host: str
port: int
order_print_endpoint: str
password: str
@dataclass(frozen=True)
class PayPalConfiguration:
client_id_sandbox: str
secret_sandbox: str
client_id: str
secret: str
@dataclass(frozen=True)
class TicketInfo:
category: str
total_tickets: int
price: Decimal
description: str
additional_info: str
can_be_sold: bool
+15
View File
@@ -0,0 +1,15 @@
from typing import Optional
from beanie import Document, Link
from elm.types import User
class Seat(Document):
seat_id: str
is_blocked: bool
category: str
user: Optional[Link[User]] = None
class Settings:
name = "seats"
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from beanie import Document, Link
from elm.types import User
class TicketState(Enum):
AVAILABLE = 1
SOLD_OUT = 2
UNAVAILABLE = 3
class Ticket(Document):
category: str
purchase_date: datetime
owner: Optional[Link[User]] = None
class Settings:
name = "tickets"
+28
View File
@@ -0,0 +1,28 @@
from datetime import datetime, UTC
from decimal import Decimal
from typing import Annotated
from beanie import Document, Indexed
from bson import Decimal128
from pydantic import field_validator, Field
class Transaction(Document):
user_name: Annotated[str, Indexed()]
value: Decimal
is_debit: bool
title: str
transaction_date: datetime = Field(
default_factory=lambda: datetime.now(UTC)
)
class Settings:
name = "transactions"
@field_validator("value", mode="before")
@classmethod
def convert_decimal128(cls, v):
if isinstance(v, Decimal128):
return v.to_decimal()
return v
+32
View File
@@ -0,0 +1,32 @@
from datetime import date, datetime, UTC
from typing import Optional, Annotated
from beanie import Document, Indexed
from pydantic import Field
class User(Document):
user_name: Annotated[str, Indexed(unique=True)]
user_mail: Annotated[str, Indexed(unique=True)]
user_password: str
user_picture: Optional[bytes] = None
user_fallback_password: Optional[str] = ""
user_first_name: Optional[str] = ""
user_last_name: Optional[str] = ""
user_birth_day: Optional[date] = None
is_active: bool = True
is_team_member: bool = False
created_at: datetime = Field(
default_factory=lambda: datetime.now(UTC)
)
class Settings:
name = "users"
def __hash__(self) -> int:
return hash(self.user_name)
def __eq__(self, other):
if not isinstance(other, User):
return NotImplemented
return self.user_name == other.user_name
+11
View File
@@ -0,0 +1,11 @@
from typing import Optional
from uuid import UUID
from rio import Dataclass
class UserSession(Dataclass):
id: UUID
user_name: str
is_team_member: bool
profile_picture: Optional[bytes] = None
+7
View File
@@ -0,0 +1,7 @@
from .User import User
from .UserSession import UserSession
from .ConfigurationTypes import *
from .Transaction import Transaction
from .Ticket import Ticket, TicketState
from .Seat import Seat
from .ConfigurationTypes import *
+2
View File
@@ -0,0 +1,2 @@
async def async_noop(*args, **kwargs) -> None:
pass
+4
View File
@@ -0,0 +1,4 @@
[app]
app-type = "website"
main-module = "elm"
project-files = ["*.py", "/assets/", "/rio.toml"]