Compare commits
13 Commits
main
...
2290072820
| Author | SHA1 | Date | |
|---|---|---|---|
| 2290072820 | |||
| 974ac7af3f | |||
| 4803607e3b | |||
| 5d422a9863 | |||
| 547601d7df | |||
| 63b64bbed1 | |||
| 6c8c0c7a4f | |||
| 695b5ae741 | |||
| 0104bce751 | |||
| 85619feed5 | |||
| 45ad5f164a | |||
| 4552b0dc86 | |||
| 0eb3757c84 |
+154
@@ -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/
|
||||||
@@ -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ühren zurzeit Wartungsarbeiten durch und sind in kürze wieder für euch da.</p>
|
||||||
|
<p>— Euer EZGG LAN Team</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
[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]
|
||||||
|
db_address="mongodb://localhost:27017"
|
||||||
|
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=""
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
aiosmtplib==5.1.0
|
||||||
|
annotated-doc==0.0.4
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.13.0
|
||||||
|
beanie==2.1.0
|
||||||
|
blessed==1.42.0
|
||||||
|
certifi==2026.5.20
|
||||||
|
chardet==7.4.3
|
||||||
|
click==8.4.0
|
||||||
|
colorama==0.4.6
|
||||||
|
crawlerdetect==0.3.2
|
||||||
|
cssutils==2.15.0
|
||||||
|
dnspython==2.8.0
|
||||||
|
email-validator==2.3.0
|
||||||
|
encutils==1.0.0
|
||||||
|
fastapi==0.128.8
|
||||||
|
from-root==1.3.0
|
||||||
|
gitignore_parser==0.1.13
|
||||||
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httptools==0.7.1
|
||||||
|
httpx==0.28.1
|
||||||
|
identity-containers==1.1.0
|
||||||
|
idna==3.15
|
||||||
|
imy==0.7.1
|
||||||
|
introspection==1.14.3
|
||||||
|
isort==7.0.0
|
||||||
|
jinxed==2.0.0
|
||||||
|
langcodes==3.5.1
|
||||||
|
lazy-model==0.4.0
|
||||||
|
more-itertools==11.0.2
|
||||||
|
multidict==6.7.1
|
||||||
|
multipart==1.3.1
|
||||||
|
narwhals==2.21.2
|
||||||
|
ordered-set==4.1.0
|
||||||
|
path-imports==1.1.2
|
||||||
|
pillow==12.2.0
|
||||||
|
platformdirs==4.9.6
|
||||||
|
propcache==0.5.2
|
||||||
|
pydantic==2.13.4
|
||||||
|
pydantic_core==2.46.4
|
||||||
|
pymongo==4.17.0
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
pytz==2026.2
|
||||||
|
PyYAML==6.0.3
|
||||||
|
qrcode==8.2
|
||||||
|
RapidFuzz==3.14.5
|
||||||
|
readchar==4.2.2
|
||||||
|
revel==0.9.2.post1
|
||||||
|
rio-ui==0.12
|
||||||
|
sentinel==1.0.0
|
||||||
|
six==1.17.0
|
||||||
|
starlette==0.52.1
|
||||||
|
timer-dict==1.0.0
|
||||||
|
tomlkit==0.13.3
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
unicall==0.2.0.post0
|
||||||
|
uniserde==0.4.1
|
||||||
|
uvicorn==0.40.0
|
||||||
|
uvloop==0.22.1
|
||||||
|
watchfiles==1.2.0
|
||||||
|
wcwidth==0.7.0
|
||||||
|
websockets==16.0
|
||||||
|
yarl==1.24.2
|
||||||
@@ -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
|
||||||
|
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)],
|
||||||
|
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.
@@ -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,141 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
@on_populate
|
||||||
|
async def on_populate(self) -> 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")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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.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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from rio import Component, Rectangle, Column, Spacer, Text, Row, TextInput, FlowContainer
|
||||||
|
from rio.event import on_populate
|
||||||
|
|
||||||
|
from elm.components import ElmButton, CateringItemBox
|
||||||
|
from elm.types.CateringTypes import CateringMenuItem, CateringMenuItemCategory
|
||||||
|
|
||||||
|
ITEM_CATEGORY_BY_DISPLAY_CATEGORY: dict[Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"], list[CateringMenuItemCategory]] = {
|
||||||
|
"Frühstück": [CateringMenuItemCategory.BREAKFAST],
|
||||||
|
"Hauptspeisen": [CateringMenuItemCategory.MAIN_COURSE],
|
||||||
|
"Snacks & Dessert": [CateringMenuItemCategory.SNACK, CateringMenuItemCategory.DESSERT],
|
||||||
|
"Softdrinks": [CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC],
|
||||||
|
"Alkohol": [CateringMenuItemCategory.BEVERAGE_ALCOHOLIC, CateringMenuItemCategory.BEVERAGE_COCKTAIL, CateringMenuItemCategory.BEVERAGE_SHOT]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CateringCategoryDisplay(Component):
|
||||||
|
active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"]
|
||||||
|
catering_menu_items: list[CateringMenuItem] = []
|
||||||
|
|
||||||
|
@on_populate
|
||||||
|
async def on_populate(self) -> None:
|
||||||
|
self.catering_menu_items = await CateringMenuItem.find(
|
||||||
|
{
|
||||||
|
"category": {
|
||||||
|
"$in": ITEM_CATEGORY_BY_DISPLAY_CATEGORY[self.active_category]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).to_list()
|
||||||
|
def build(self) -> Component:
|
||||||
|
if len(self.catering_menu_items) <= 0:
|
||||||
|
return Spacer()
|
||||||
|
|
||||||
|
return Rectangle(
|
||||||
|
content=Column(
|
||||||
|
Rectangle(
|
||||||
|
content=Rectangle(
|
||||||
|
content=Text(self.active_category, margin=0.5, selectable=False, overflow="wrap"),
|
||||||
|
fill=self.session.theme.header_box_background_color,
|
||||||
|
margin=0.4
|
||||||
|
),
|
||||||
|
stroke_width=0.1,
|
||||||
|
stroke_color=self.session.theme.box_border_color,
|
||||||
|
),
|
||||||
|
# Items here
|
||||||
|
Column(*[CateringItemBox(i, margin=0.5, grow_y=True) for i in self.catering_menu_items]),
|
||||||
|
Spacer()
|
||||||
|
),
|
||||||
|
fill=self.session.theme.box_color,
|
||||||
|
stroke_width=0.1,
|
||||||
|
stroke_color=self.session.theme.box_border_color
|
||||||
|
)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from rio import Component, Rectangle, Column, Text, Row, Separator, Color, Checkbox, FlowContainer, IconButton, Icon, Spacer
|
||||||
|
|
||||||
|
from elm.services import AccountingService
|
||||||
|
from elm.components import ElmButton
|
||||||
|
from elm.types.CateringTypes import CateringMenuItem, CateringModificationKey
|
||||||
|
|
||||||
|
|
||||||
|
class CateringItemBox(Component):
|
||||||
|
item: CateringMenuItem
|
||||||
|
|
||||||
|
def make_money_string(self, money: Decimal) -> str:
|
||||||
|
return self.session[AccountingService].make_euro_string_from_decimal(money)
|
||||||
|
|
||||||
|
def build(self) -> Component:
|
||||||
|
base_mods = []
|
||||||
|
extra_mods = []
|
||||||
|
if self.item.active:
|
||||||
|
for modifier_group in self.item.modifier_groups:
|
||||||
|
if modifier_group.key == CateringModificationKey.BASE:
|
||||||
|
base_mods.append(Text("Basis:", fill=self.session.theme.secondary_color, font_size=0.8, margin_top=0.5))
|
||||||
|
container = FlowContainer(spacing=2.5)
|
||||||
|
for option in modifier_group.options:
|
||||||
|
container.children.append(Row(Checkbox(is_on=option.default_selected), Text(option.label), spacing=0.6))
|
||||||
|
base_mods.append(container)
|
||||||
|
if modifier_group.key == CateringModificationKey.EXTRA:
|
||||||
|
extra_mods.append(Text("Extras:", fill=self.session.theme.secondary_color, font_size=0.8, margin_top=0.8))
|
||||||
|
container = FlowContainer(spacing=2.5)
|
||||||
|
for option in modifier_group.options:
|
||||||
|
text = f"{option.label}"
|
||||||
|
if option.price_delta > Decimal("0"):
|
||||||
|
text += f" (+ {self.make_money_string(option.price_delta)})"
|
||||||
|
container.children.append(Row(Checkbox(is_on=option.default_selected), Text(text), spacing=0.6))
|
||||||
|
extra_mods.append(container)
|
||||||
|
|
||||||
|
return Rectangle(
|
||||||
|
content=Column(
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
Row(
|
||||||
|
Text(text=self.item.name, overflow="nowrap", justify="left", font_size=1.1, margin_right=0.8, font_weight="bold", strikethrough=not self.item.active),
|
||||||
|
Text(text=self.make_money_string(self.item.base_price), overflow="ellipsize", justify="left", font_size=0.8, grow_x=True, fill=self.session.theme.primary_color, align_y=1.2)
|
||||||
|
),
|
||||||
|
Text(self.item.description, font_size=0.7, margin_left=2),
|
||||||
|
*base_mods,
|
||||||
|
*extra_mods,
|
||||||
|
spacing=0.5,
|
||||||
|
margin=0.5,
|
||||||
|
grow_x=True
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
Spacer(),
|
||||||
|
Rectangle(
|
||||||
|
content=Icon(icon="material/add_shopping_cart", min_width=2, min_height=2, margin=1),
|
||||||
|
hover_fill=Color.TRANSPARENT if not self.item.active else self.session.theme.hud_color,
|
||||||
|
cursor="not-allowed" if not self.item.active else "pointer",
|
||||||
|
transition_time=0.2
|
||||||
|
),
|
||||||
|
Spacer()
|
||||||
|
)
|
||||||
|
|
||||||
|
),
|
||||||
|
Separator(color=self.session.theme.box_border_color),
|
||||||
|
spacing=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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(
|
||||||
|
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
|
||||||
|
),
|
||||||
|
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)
|
||||||
|
)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
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", "/contact", 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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -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=76, 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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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 build(self) -> Component:
|
||||||
|
return Rectangle(
|
||||||
|
content=Column(
|
||||||
|
UserNavigationButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_decimal(self.balance)}", "/balance", self.close_navigation),
|
||||||
|
UserNavigationButton("Mein Profil", "/my-profile", self.close_navigation),
|
||||||
|
UserNavigationButton("Mein Clan", "/my-clans", self.close_navigation),
|
||||||
|
UserNavigationButton("Ausloggen", "/logout", self.close_navigation)
|
||||||
|
),
|
||||||
|
min_width=3.5,
|
||||||
|
min_height=3.5,
|
||||||
|
fill=self.session.theme.background_color
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from copy import copy
|
||||||
|
from typing import Any, Optional, Literal
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from rio import Component, Column, Row, Text, Spacer, page, Color, Rectangle, TextInput, GuardEvent, SwitcherBar, SwitcherBarChangeEvent
|
||||||
|
|
||||||
|
from elm.types import UserSession, User
|
||||||
|
from elm.types.CateringTypes import *
|
||||||
|
from elm.services import UserService, LocalData, LocalDataService, ConfigurationService
|
||||||
|
from elm.components import ElmButton, CateringCategoryDisplay
|
||||||
|
|
||||||
|
|
||||||
|
@page(name="Catering", url_segment="catering")
|
||||||
|
class CateringPage(Component):
|
||||||
|
active_category: Literal["Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol"] = "Hauptspeisen"
|
||||||
|
|
||||||
|
async def on_switcher_bar_change(self, event: SwitcherBarChangeEvent) -> None:
|
||||||
|
self.active_category = event.value
|
||||||
|
print(event)
|
||||||
|
|
||||||
|
def build(self) -> Component:
|
||||||
|
return Row(
|
||||||
|
Column(
|
||||||
|
Rectangle(
|
||||||
|
content=Column(
|
||||||
|
Rectangle(
|
||||||
|
content=SwitcherBar("Frühstück", "Hauptspeisen", "Snacks & Dessert", "Softdrinks", "Alkohol", margin=0.5, selected_value=self.bind().active_category, on_change=self.on_switcher_bar_change),
|
||||||
|
stroke_width=0.1,
|
||||||
|
stroke_color=self.session.theme.box_border_color,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
fill=self.session.theme.box_color,
|
||||||
|
stroke_width=0.1,
|
||||||
|
stroke_color=self.session.theme.box_border_color
|
||||||
|
),
|
||||||
|
CateringCategoryDisplay(active_category=self.active_category, grow_y=True),
|
||||||
|
grow_x=True,
|
||||||
|
spacing=1
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
Rectangle(
|
||||||
|
content=Column(
|
||||||
|
Rectangle(
|
||||||
|
content=Rectangle(
|
||||||
|
content=Text("Warenkorb", margin=0.5, selectable=False, overflow="wrap"),
|
||||||
|
fill=self.session.theme.header_box_background_color,
|
||||||
|
margin=0.4
|
||||||
|
),
|
||||||
|
stroke_width=0.1,
|
||||||
|
stroke_color=self.session.theme.box_border_color,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
fill=self.session.theme.box_color,
|
||||||
|
stroke_width=0.1,
|
||||||
|
stroke_color=self.session.theme.box_border_color
|
||||||
|
),
|
||||||
|
Rectangle(
|
||||||
|
content=Column(
|
||||||
|
Text("ToDo", margin=1),
|
||||||
|
Spacer()
|
||||||
|
),
|
||||||
|
fill=self.session.theme.box_color,
|
||||||
|
stroke_width=0.1,
|
||||||
|
stroke_color=self.session.theme.box_border_color,
|
||||||
|
grow_y=True
|
||||||
|
),
|
||||||
|
spacing=1,
|
||||||
|
min_width=18
|
||||||
|
),
|
||||||
|
spacing=1,
|
||||||
|
margin=1
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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 23.04.2027 ist es soweit. Dann findet die EZGG LAN in der zweiten Edition statt. Es erwarten euch viele Verbesserungen zur letzten Edition und wir hoffen euch auch dieses mal begrüßen zu dürfen.",
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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 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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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überweißung", 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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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_address=self._config["database"]["database_address"],
|
||||||
|
database_name=self._config["database"]["database_name"],
|
||||||
|
)
|
||||||
|
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"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from beanie import init_beanie
|
||||||
|
from pymongo import AsyncMongoClient
|
||||||
|
from pymongo.asynchronous.collection import AsyncCollection
|
||||||
|
|
||||||
|
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
|
||||||
|
self._users = None
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
if self._client is None:
|
||||||
|
self._client = AsyncMongoClient(self._db_config.database_address)
|
||||||
|
self._database = self._client[self._db_config.database_name]
|
||||||
|
self._users: AsyncCollection = self._database["users"]
|
||||||
|
await init_beanie(
|
||||||
|
database=self._database,
|
||||||
|
document_models=[User, Transaction, Ticket, Seat, CateringTypes.CateringMenuItem, CateringTypes.CateringOrder]
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
"""
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from asyncio import sleep
|
||||||
|
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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, UTC
|
||||||
|
from decimal import Decimal
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Optional, Annotated
|
||||||
|
|
||||||
|
from beanie import Document, PydanticObjectId, Indexed
|
||||||
|
from bson import Decimal128
|
||||||
|
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
||||||
|
|
||||||
|
class CateringMenuItemCategory(StrEnum):
|
||||||
|
MAIN_COURSE = "MAIN_COURSE"
|
||||||
|
DESSERT = "DESSERT"
|
||||||
|
BEVERAGE_NON_ALCOHOLIC = "BEVERAGE_NON_ALCOHOLIC"
|
||||||
|
BEVERAGE_ALCOHOLIC = "BEVERAGE_ALCOHOLIC"
|
||||||
|
BEVERAGE_COCKTAIL = "BEVERAGE_COCKTAIL"
|
||||||
|
BEVERAGE_SHOT = "BEVERAGE_SHOT"
|
||||||
|
BREAKFAST = "BREAKFAST"
|
||||||
|
SNACK = "SNACK"
|
||||||
|
NON_FOOD = "NON_FOOD"
|
||||||
|
|
||||||
|
class CateringOrderStatus(StrEnum):
|
||||||
|
RECEIVED = "RECEIVED"
|
||||||
|
DELAYED = "DELAYED"
|
||||||
|
READY_FOR_PICKUP = "READY_FOR_PICKUP"
|
||||||
|
EN_ROUTE = "EN_ROUTE"
|
||||||
|
COMPLETED = "COMPLETED"
|
||||||
|
CANCELED = "CANCELED"
|
||||||
|
|
||||||
|
class CateringModificationKey(StrEnum):
|
||||||
|
BASE = "base" # For base ingredients that can be deselected, like butter on bread
|
||||||
|
EXTRA = "extra" # For ingredients that can be added, like ketchup on fries
|
||||||
|
|
||||||
|
class MongoDecimalModel(BaseModel):
|
||||||
|
model_config = ConfigDict(
|
||||||
|
arbitrary_types_allowed=True,
|
||||||
|
json_encoders={
|
||||||
|
Decimal: lambda v: str(v)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("*", mode="before", check_fields=False)
|
||||||
|
@classmethod
|
||||||
|
def convert_decimal128(cls, v):
|
||||||
|
if isinstance(v, Decimal128):
|
||||||
|
return v.to_decimal()
|
||||||
|
|
||||||
|
return v
|
||||||
|
|
||||||
|
class CateringModifierOption(MongoDecimalModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
|
||||||
|
default_selected: bool = False
|
||||||
|
|
||||||
|
price_delta: Decimal = Decimal("0.00")
|
||||||
|
|
||||||
|
class CateringModifierGroup(MongoDecimalModel):
|
||||||
|
key: CateringModificationKey
|
||||||
|
label: str
|
||||||
|
|
||||||
|
# True = checkbox group
|
||||||
|
# False = radio button group
|
||||||
|
multi_select: bool = True
|
||||||
|
|
||||||
|
min_selected: int = 0
|
||||||
|
max_selected: Optional[int] = None
|
||||||
|
|
||||||
|
options: list[CateringModifierOption] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CateringMenuItem(MongoDecimalModel, Document):
|
||||||
|
name: Annotated[str, Indexed(unique=True)]
|
||||||
|
|
||||||
|
category: CateringMenuItemCategory
|
||||||
|
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
base_price: Decimal = Decimal("0.00")
|
||||||
|
|
||||||
|
modifier_groups: list[CateringModifierGroup] = Field(default_factory=list)
|
||||||
|
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
created_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
name = "catering_menu_items"
|
||||||
|
|
||||||
|
class CateringSelectedModifier(MongoDecimalModel):
|
||||||
|
group_key: str
|
||||||
|
option_key: str
|
||||||
|
|
||||||
|
label: str
|
||||||
|
|
||||||
|
selected: bool
|
||||||
|
|
||||||
|
price_delta: Decimal = Decimal("0.00")
|
||||||
|
|
||||||
|
|
||||||
|
class CateringOrderedItem(MongoDecimalModel):
|
||||||
|
menu_item_id: PydanticObjectId
|
||||||
|
|
||||||
|
# Snapshot fields
|
||||||
|
name: str
|
||||||
|
|
||||||
|
quantity: int = 1
|
||||||
|
|
||||||
|
base_price: Decimal
|
||||||
|
|
||||||
|
selected_modifiers: list[CateringSelectedModifier] = Field(
|
||||||
|
default_factory=list
|
||||||
|
)
|
||||||
|
|
||||||
|
# Final calculated price INCLUDING modifiers
|
||||||
|
final_unit_price: Decimal
|
||||||
|
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
class CateringOrder(Document):
|
||||||
|
customer_id: PydanticObjectId
|
||||||
|
|
||||||
|
items: list[CateringOrderedItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
status: CateringOrderStatus = CateringOrderStatus.RECEIVED
|
||||||
|
|
||||||
|
created_at: datetime = Field(
|
||||||
|
default_factory=lambda: datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
name = "catering_orders"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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_address: str
|
||||||
|
database_name: 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
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 *
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
async def async_noop(*args, **kwargs) -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[app]
|
||||||
|
app-type = "website"
|
||||||
|
main-module = "elm"
|
||||||
|
project-files = ["*.py", "/assets/", "/rio.toml"]
|
||||||
Reference in New Issue
Block a user