add ticketing

This commit is contained in:
David Rodenkirchen
2026-05-22 11:14:30 +02:00
parent 6c8c0c7a4f
commit 63b64bbed1
11 changed files with 182 additions and 76 deletions
-3
View File
@@ -1,3 +0,0 @@
# ELM
The EZGG LAN Manager
+2 -2
View File
@@ -27,14 +27,14 @@
price="20.00" price="20.00"
description="Normales Ticket" description="Normales Ticket"
additional_info="Berechtigt zur Nutzung eines regulären Platzes für die gesamte Dauer der LAN" additional_info="Berechtigt zur Nutzung eines regulären Platzes für die gesamte Dauer der LAN"
is_default=true can_be_sold=true
[tickets."DELUXE"] [tickets."DELUXE"]
total_tickets=30 total_tickets=30
price="25.00" price="25.00"
description="Deluxe Ticket" description="Deluxe Ticket"
additional_info="Wie das normale Ticket, aber mit doppelt so breitem Tisch (160cm)" additional_info="Wie das normale Ticket, aber mit doppelt so breitem Tisch (160cm)"
is_default=false can_be_sold=true
[receipt_printing] [receipt_printing]
host="10.0.0.103" host="10.0.0.103"
+20 -10
View File
@@ -1,7 +1,9 @@
from typing import Optional
from rio import Component, Rectangle, Column, Text, Row, PointerEventListener, TextInput from rio import Component, Rectangle, Column, Text, Row, PointerEventListener, TextInput
from rio.event import on_populate from rio.event import on_populate
from elm.types import UserSession from elm.types import UserSession, Ticket
from elm.components import ElmButton from elm.components import ElmButton
from elm.services import UserService from elm.services import UserService
@@ -13,13 +15,18 @@ class AccountInfoBox(Component):
account_info_is_error: bool = False account_info_is_error: bool = False
password_input_blocked: bool = False password_input_blocked: bool = False
password_change_in_progress: bool = False password_change_in_progress: bool = False
ticket: Optional[Ticket] = None
@on_populate @on_populate
async def on_populate(self) -> None: async def on_populate(self) -> None:
user = await self.session[UserService].get_user(self.session[UserSession].user_name) try:
if user: user = await self.session[UserService].get_user(self.session[UserSession].user_name)
self.mail = user.user_mail if user:
else: self.mail = user.user_mail
self.ticket = await Ticket.find_one({"owner.$id": user.id})
else:
self.session.navigate_to("./login")
except KeyError:
self.session.navigate_to("./login") self.session.navigate_to("./login")
async def set_new_password(self) -> None: async def set_new_password(self) -> None:
@@ -55,6 +62,9 @@ class AccountInfoBox(Component):
def build(self) -> Component: def build(self) -> Component:
row_col = Row row_col = Row
ticket_text = "-"
if self.ticket:
ticket_text = self.ticket.category
if self.session.is_mobile(): if self.session.is_mobile():
row_col = Column row_col = Column
@@ -75,13 +85,13 @@ class AccountInfoBox(Component):
row_col( row_col(
PointerEventListener( PointerEventListener(
Rectangle( Rectangle(
content=Row(Text("Ticket:", margin=1, overflow="wrap", justify="left"), Text("-", margin=1, overflow="wrap", justify="right")), content=Row(Text("Ticket:", margin=1, overflow="wrap", justify="left"), Text(ticket_text, margin=1, overflow="wrap", justify="right")),
fill=self.session.theme.danger_color_dark, fill=self.session.theme.success_color if self.ticket else self.session.theme.danger_color_dark,
stroke_width=0.1, stroke_width=0.1,
stroke_color=self.session.theme.danger_color, stroke_color=self.session.theme.success_color if self.ticket else self.session.theme.danger_color,
hover_fill=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_width=0.1,
hover_stroke_color=self.session.theme.danger_color_dark, hover_stroke_color=self.session.theme.success_color if self.ticket else self.session.theme.danger_color_dark,
transition_time=0.2, transition_time=0.2,
cursor="pointer" cursor="pointer"
), ),
+137
View File
@@ -0,0 +1,137 @@
from asyncio import sleep
from datetime import datetime
from typing import Optional
from rio import Component, Column, Row, Text, Spacer, Rectangle, ProgressBar, Tooltip
from rio.event import on_populate
from elm.services import AccountingService
from elm.components import ElmButton
from elm.services.AccountingService import InsufficientFundsError
from elm.types import Ticket, UserSession, User, TicketInfo, TicketState
class BuyTicketBox(Component):
ticket_info: TicketInfo
user_ticket: Optional[Ticket] = None
ticket_state: TicketState = TicketState.UNAVAILABLE
sold_tickets: int = 0
purchase_in_process: bool = False
purchase_status: str = ""
purchase_error_message: str = ""
@on_populate
async def on_populate(self) -> None:
self.sold_tickets = len(await Ticket.find_many(Ticket.category == self.ticket_info.category).to_list())
if self.sold_tickets >= self.ticket_info.total_tickets:
self.ticket_state = TicketState.SOLD_OUT
elif self.ticket_info.can_be_sold:
self.ticket_state = TicketState.AVAILABLE
else:
self.ticket_state = TicketState.UNAVAILABLE
self.user_ticket = await self.get_user_ticket()
async def get_user_ticket(self) -> Optional[Ticket]:
try:
user = await User.find_one(User.user_name == self.session[UserSession].user_name)
if not user:
return None
return await Ticket.find_one({"owner.$id": user.id})
except KeyError:
return None
def is_logged_in(self) -> bool:
try:
return bool(self.session[UserSession].user_name)
except KeyError:
return False
def get_available_tickets(self) -> int:
return self.ticket_info.total_tickets - self.sold_tickets
async def buy_ticket(self) -> None:
self.purchase_in_process = True
self.purchase_status = "Ticket wird gekauft..."
await sleep(1)
try:
user = await User.find_one(User.user_name == self.session[UserSession].user_name)
if not user:
raise KeyError
except KeyError:
self.session.navigate_to("./login")
return
try:
await self.session[AccountingService].remove_balance(user.user_name, self.ticket_info.price, f"Ticketkauf - {self.ticket_info.category}")
except InsufficientFundsError:
self.purchase_in_process = False
self.purchase_status = ""
self.purchase_error_message = "Ungenügendes Guthaben!"
return
new_ticket = Ticket(category=self.ticket_info.category, purchase_date=datetime.now(), owner=user)
await new_ticket.save()
self.user_ticket = new_ticket
self.purchase_in_process = False
self.purchase_status = ""
self.sold_tickets = len(await Ticket.find_many(Ticket.category == self.ticket_info.category).to_list())
def build(self) -> Component:
ticket_owned_text = ""
if self.purchase_error_message:
button_row_content = Row(
Text(self.purchase_error_message, justify="center", margin=0.5, fill=self.session.theme.danger_color, overflow="wrap"),
)
elif self.purchase_in_process:
button_row_content = Row(
Text(self.purchase_status, justify="center", margin=0.5, overflow="wrap")
)
else:
if self.user_ticket:
button_row_content = Tooltip(anchor=ElmButton(text="Kaufen", is_disabled=True), tip="Du hast bereits ein Ticket")
ticket_owned_text = "Du besitzt dieses Ticket!" if self.user_ticket.category == self.ticket_info.category else ""
elif self.is_logged_in():
if self.ticket_state == TicketState.UNAVAILABLE:
button_row_content = Tooltip(anchor=ElmButton(text="Kaufen", is_disabled=True), tip="Aktuell nicht verfügbar")
elif self.ticket_state == TicketState.SOLD_OUT:
button_row_content = Tooltip(anchor=ElmButton(text="Kaufen", is_disabled=True), tip="Ausverkauft")
elif self.ticket_state == TicketState.AVAILABLE:
button_row_content = ElmButton(text="Kaufen", on_press=self.buy_ticket)
else:
button_row_content = Tooltip(anchor=ElmButton(text="Kaufen", is_disabled=True), tip="Entwickler hauen!")
else:
button_row_content = ElmButton(text="Kaufen", on_press=lambda: self.session.navigate_to("./login"))
return Column(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text(self.ticket_info.description, margin=0.5, selectable=False, overflow="wrap", grow_x=True),
Text(self.session[AccountingService].make_euro_string_from_decimal(self.ticket_info.price), justify="right", margin_right=0.5, fill=self.session.theme.warning_color)
),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
Text(self.ticket_info.additional_info, overflow="wrap", margin_bottom=1),
Text(ticket_owned_text, margin_bottom=3, overflow="wrap", fill=self.session.theme.success_color),
Row(Text("Verfügbar:", font_size=0.8 if self.session.is_mobile() else 1), Text(f"{self.get_available_tickets()} / {self.ticket_info.total_tickets}", justify="right", font_size=0.8 if self.session.is_mobile() else 1)),
ProgressBar(progress=self.get_available_tickets() / self.ticket_info.total_tickets, min_height=1),
button_row_content,
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
Spacer()
)
+7 -4
View File
@@ -17,8 +17,11 @@ class ElmButton(Component):
style: Literal["small", "normal"] = "normal" style: Literal["small", "normal"] = "normal"
wrap: bool = False wrap: bool = False
is_loading: bool = False is_loading: bool = False
is_disabled: bool = False
async def _on_press(self, event: PointerEvent) -> None: async def _on_press(self, event: PointerEvent) -> None:
if self.is_disabled:
return
if iscoroutinefunction(self.on_press): if iscoroutinefunction(self.on_press):
await self.on_press() await self.on_press()
else: else:
@@ -54,10 +57,10 @@ class ElmButton(Component):
stroke_width=0.1, stroke_width=0.1,
stroke_color=self.session.theme.secondary_color, stroke_color=self.session.theme.secondary_color,
hover_stroke_width=0.1, hover_stroke_width=0.1,
hover_stroke_color=self.session.theme.hud_color, hover_stroke_color=self.session.theme.secondary_color if self.is_disabled else self.session.theme.hud_color,
hover_fill=self.session.theme.hud_color, hover_fill=Color.TRANSPARENT if self.is_disabled else self.session.theme.hud_color,
transition_time=0, transition_time=0,
cursor="pointer" cursor="not-allowed" if self.is_disabled else "pointer"
), ),
on_press=self._on_press on_press=self._on_press
) )
+1
View File
@@ -6,3 +6,4 @@ from .ElmButton import ElmButton
from .AvatarEditBox import AvatarEditBox from .AvatarEditBox import AvatarEditBox
from .AccountInfoBox import AccountInfoBox from .AccountInfoBox import AccountInfoBox
from .PersonalInfoBox import PersonalInfoBox from .PersonalInfoBox import PersonalInfoBox
from .BuyTicketBox import BuyTicketBox
+5 -55
View File
@@ -1,70 +1,20 @@
from __future__ import annotations from __future__ import annotations
from rio import Component, Column, Row, Text, Spacer, page, Color, TextStyle, Rectangle, TextInput, ProgressBar, Dict from rio import Component, Column, Row, page
from rio.event import on_populate
from elm.services import ConfigurationService, AccountingService
from elm.components import LanCountdownBox, LanInfoBox, LandingPageBoxFull, LandingPageBoxHalf, ElmButton
from elm.types import Ticket
from elm.services import ConfigurationService
from elm.components import BuyTicketBox
@page(name="Tickets", url_segment="tickets") @page(name="Tickets", url_segment="tickets")
class TicketsPage(Component): class TicketsPage(Component):
sold_tickets_by_category: Dict[str, int] = Dict()
"""
ToDo: Implement conditional ticket buying (check login!)
"""
@on_populate
async def on_populate(self) -> None:
for ticket_info in self.session[ConfigurationService].get_ticket_info():
self.sold_tickets_by_category[ticket_info.category] = len(await Ticket.find_many(Ticket.category == ticket_info.category).to_list())
def get_available_tickets_by_category(self, category: str, total_tickets: int) -> int:
return total_tickets - self.sold_tickets_by_category.get(category, 0)
def build(self) -> Component: def build(self) -> Component:
row_col = Column if self.session.is_mobile() else Row row_col = Column if self.session.is_mobile() else Row
ticket_boxes = [] ticket_boxes = []
for ticket_info in self.session[ConfigurationService].get_ticket_info(): for ticket_info in self.session[ConfigurationService].get_ticket_info():
ticket_boxes.append( ticket_boxes.append(BuyTicketBox(ticket_info=ticket_info))
Column(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text(ticket_info.description, margin=0.5, selectable=False, overflow="wrap", grow_x=True),
Text(self.session[AccountingService].make_euro_string_from_decimal(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(ticket_info.additional_info, overflow="wrap", margin_bottom=1),
Text("Du besitzt dieses Ticket!", 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_by_category(ticket_info.category, ticket_info.total_tickets)} / {ticket_info.total_tickets}", justify="right", font_size=0.8 if self.session.is_mobile() else 1)),
ProgressBar(progress=self.get_available_tickets_by_category(ticket_info.category, ticket_info.total_tickets) / ticket_info.total_tickets, min_height=1),
ElmButton(text="Kaufen"),
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
Spacer()
)
)
return row_col( return row_col(
*ticket_boxes, *ticket_boxes,
spacing=1, spacing=1,
margin=1 margin=1
) )
+2 -1
View File
@@ -54,7 +54,8 @@ class ConfigurationService:
total_tickets=self._config["tickets"][value]["total_tickets"], total_tickets=self._config["tickets"][value]["total_tickets"],
price=Decimal(self._config["tickets"][value]["price"]), price=Decimal(self._config["tickets"][value]["price"]),
description=self._config["tickets"][value]["description"], description=self._config["tickets"][value]["description"],
additional_info=self._config["tickets"][value]["additional_info"] additional_info=self._config["tickets"][value]["additional_info"],
can_be_sold=self._config["tickets"][value]["can_be_sold"]
) for value in self._config["tickets"]]) ) for value in self._config["tickets"]])
except KeyError as e: except KeyError as e:
logger.debug(e) logger.debug(e)
+1
View File
@@ -54,3 +54,4 @@ class TicketInfo:
price: Decimal price: Decimal
description: str description: str
additional_info: str additional_info: str
can_be_sold: bool
+6
View File
@@ -1,10 +1,16 @@
from datetime import datetime from datetime import datetime
from enum import Enum
from typing import Optional from typing import Optional
from beanie import Document, Link from beanie import Document, Link
from elm.types import User from elm.types import User
class TicketState(Enum):
AVAILABLE = 1
SOLD_OUT = 2
UNAVAILABLE = 3
class Ticket(Document): class Ticket(Document):
category: str category: str
+1 -1
View File
@@ -2,4 +2,4 @@ from .User import User
from .UserSession import UserSession from .UserSession import UserSession
from .ConfigurationTypes import * from .ConfigurationTypes import *
from .Transaction import Transaction from .Transaction import Transaction
from .Ticket import Ticket from .Ticket import Ticket, TicketState