sync mvp-dev to main #2

Merged
Typhus merged 85 commits from mvp-dev into main 2025-01-23 19:25:01 +00:00
76 changed files with 6448 additions and 0 deletions

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.0.1

19
config/README.md Normal file
View File

@ -0,0 +1,19 @@
# Configuration
## config.toml
TBD
## seating plan
### creation
- Create a new seating plan via diagrams.net editor (make sure to use the "editable XML" version)
- Boxes with a letter followed by up to 3 numbers are detected as seats
- Add the property "category" to designate seats as specific category (otherwise, the default will be applied)
### exporting
- Export as SVG when ready
- remove the onclick listener from the root element (optional)
- add path of svg to seating configuration

View File

@ -0,0 +1,44 @@
[lan]
name="EZ LAN"
iteration="0.5"
date_from="2024-10-30 15:00:00"
date_till="2024-11-01 12:00:00"
organizer_mail="tech@example.com"
[database]
db_user="demo_user"
db_password="demo_password"
db_host="127.0.0.1"
db_port=3306
db_name="ez_lan_manager"
[mailing]
smtp_server=""
smtp_port=587
sender=""
username=""
password=""
[seating]
# SeatID -> Category
A01 = "NORMAL"
A02 = "NORMAL"
C01 = "LUXUS"
[tickets]
[tickets."NORMAL"]
total_tickets=30
price=2500
description="Normales Ticket"
additional_info="Berechtigt zur Nutzung eines regulären Platzes für die gesamte Dauer der LAN"
is_default=true
[tickets."LUXUS"]
total_tickets=10
price=3500
description="Luxus Ticket"
additional_info="Berechtigt zur Nutzung eines verbesserten Platzes. Dieser ist mit einer höheren Internet-Bandbreite und einem Sitzkissen ausgestattet."
is_default=false
[misc]
dev_mode_active=true # Supresses E-Mail sending

Binary file not shown.

200
sql/create_database.sql Normal file
View File

@ -0,0 +1,200 @@
CREATE DATABASE IF NOT EXISTS `ez_lan_manager` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci */;
USE `ez_lan_manager`;
-- MySQL dump 10.13 Distrib 5.7.24, for Linux (x86_64)
--
-- Host: 127.0.0.1 Database: ez_lan_manager
-- ------------------------------------------------------
-- Server version 5.5.5-10.11.8-MariaDB-0ubuntu0.24.04.1
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `catering_menu_items`
--
DROP TABLE IF EXISTS `catering_menu_items`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `catering_menu_items` (
`catering_menu_item_id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(45) NOT NULL,
`additional_info` varchar(300) DEFAULT '',
`price` int(11) NOT NULL DEFAULT 0,
`category` varchar(80) NOT NULL,
`is_disabled` tinyint(4) DEFAULT 0,
PRIMARY KEY (`catering_menu_item_id`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `news`
--
DROP TABLE IF EXISTS `news`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `news` (
`news_id` int(11) NOT NULL AUTO_INCREMENT,
`news_content` text DEFAULT NULL,
`news_title` varchar(100) DEFAULT NULL,
`news_subtitle` varchar(100) DEFAULT NULL,
`news_author` int(11) NOT NULL,
`news_date` date DEFAULT current_timestamp(),
PRIMARY KEY (`news_id`),
KEY `user_is_idx` (`news_author`),
CONSTRAINT `user_is` FOREIGN KEY (`news_author`) REFERENCES `users` (`user_id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `order_catering_menu_item`
--
DROP TABLE IF EXISTS `order_catering_menu_item`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `order_catering_menu_item` (
`order_id` int(11) NOT NULL,
`catering_menu_item_id` int(11) NOT NULL,
`quantity` int(11) NOT NULL DEFAULT 1,
PRIMARY KEY (`order_id`,`catering_menu_item_id`),
KEY `catering_menu_item_id_idx` (`catering_menu_item_id`),
CONSTRAINT `catering_menu_item_id` FOREIGN KEY (`catering_menu_item_id`) REFERENCES `catering_menu_items` (`catering_menu_item_id`) ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT `order_id` FOREIGN KEY (`order_id`) REFERENCES `orders` (`order_id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `orders`
--
DROP TABLE IF EXISTS `orders`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `orders` (
`order_id` int(11) NOT NULL AUTO_INCREMENT,
`status` varchar(45) NOT NULL,
`user` int(11) NOT NULL,
`order_date` datetime NOT NULL DEFAULT current_timestamp(),
`is_delivery` tinyint(4) NOT NULL DEFAULT 1,
PRIMARY KEY (`order_id`)
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `seats`
--
DROP TABLE IF EXISTS `seats`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `seats` (
`seat_id` varchar(5) NOT NULL,
`is_blocked` tinyint(4) NOT NULL DEFAULT 0,
`seat_category` varchar(45) NOT NULL DEFAULT '',
`user` int(11) DEFAULT NULL,
PRIMARY KEY (`seat_id`),
UNIQUE KEY `user_UNIQUE` (`user`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `tickets`
--
DROP TABLE IF EXISTS `tickets`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `tickets` (
`ticket_id` int(11) NOT NULL AUTO_INCREMENT,
`ticket_category` varchar(45) NOT NULL,
`user` int(11) NOT NULL,
`purchase_date` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`ticket_id`),
KEY `user_id_idx` (`user`),
CONSTRAINT `user` FOREIGN KEY (`user`) REFERENCES `users` (`user_id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `transactions`
--
DROP TABLE IF EXISTS `transactions`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `transactions` (
`transaction_id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`value` varchar(45) NOT NULL DEFAULT '0',
`is_debit` tinyint(4) NOT NULL,
`transaction_date` datetime NOT NULL DEFAULT current_timestamp(),
`transaction_reference` varchar(45) NOT NULL,
PRIMARY KEY (`transaction_id`),
UNIQUE KEY `transaction_id_UNIQUE` (`transaction_id`),
KEY `user_id_idx` (`user_id`),
CONSTRAINT `user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB AUTO_INCREMENT=19 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `user_profile_picture`
--
DROP TABLE IF EXISTS `user_profile_picture`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `user_profile_picture` (
`user_id` int(11) NOT NULL,
`picture` mediumblob DEFAULT NULL,
PRIMARY KEY (`user_id`),
CONSTRAINT `fk_user_profile_picture_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `users`
--
DROP TABLE IF EXISTS `users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `users` (
`user_id` int(11) NOT NULL AUTO_INCREMENT,
`user_name` varchar(50) NOT NULL,
`user_mail` varchar(100) NOT NULL,
`user_password` varchar(255) NOT NULL,
`user_first_name` varchar(50) DEFAULT NULL,
`user_last_name` varchar(50) DEFAULT NULL,
`user_birth_date` date DEFAULT NULL,
`is_active` tinyint(4) DEFAULT 1,
`is_team_member` tinyint(4) DEFAULT NULL,
`is_admin` tinyint(4) DEFAULT NULL,
`created_at` datetime DEFAULT current_timestamp(),
`last_updated_at` datetime DEFAULT current_timestamp(),
PRIMARY KEY (`user_id`),
UNIQUE KEY `user_id_UNIQUE` (`user_id`),
UNIQUE KEY `user_mail_UNIQUE` (`user_mail`),
UNIQUE KEY `user_name_UNIQUE` (`user_name`)
) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2024-08-25 22:37:14

187
src/EzLanManager.py Normal file
View File

@ -0,0 +1,187 @@
import logging
from asyncio import get_event_loop
import sys
from pathlib import Path
from rio import App, Theme, Color, Font, ComponentPage, Session
from from_root import from_root
from src.ez_lan_manager import pages, init_services
from src.ez_lan_manager.helpers.LoggedInGuard import logged_in_guard, not_logged_in_guard, team_guard
from src.ez_lan_manager.services.DatabaseService import NoDatabaseConnectionError
from src.ez_lan_manager.types.SessionStorage import SessionStorage
logger = logging.getLogger("EzLanManager")
if __name__ == "__main__":
theme = Theme.from_colors(
primary_color=Color.from_hex("ffffff"),
secondary_color=Color.from_hex("018786"),
neutral_color=Color.from_hex("1e1e1e"),
background_color=Color.from_hex("121212"),
hud_color=Color.from_hex("02dac5"),
text_color=Color.from_hex("018786"),
mode="dark",
corner_radius_small=0,
corner_radius_medium=0,
corner_radius_large=0,
font=Font(from_root("src/ez_lan_manager/assets/fonts/joystix.otf"))
)
services = init_services()
lan_info = services[2].get_lan_info()
async def on_session_start(session: Session) -> None:
await session.set_title(lan_info.name)
session.attach(SessionStorage())
async def on_app_start(a: App) -> None:
init_result = await a.default_attachments[3].init_db_pool()
if not init_result:
logger.fatal("Could not connect to database, exiting...")
sys.exit(1)
app = App(
name="EZ LAN Manager",
build=pages.BasePage,
pages=[
ComponentPage(
name="News",
url_segment="",
build=pages.NewsPage,
),
ComponentPage(
name="News",
url_segment="news",
build=pages.NewsPage,
),
ComponentPage(
name="Overview",
url_segment="overview",
build=lambda: pages.PlaceholderPage(placeholder_name="LAN Übersicht"),
),
ComponentPage(
name="BuyTicket",
url_segment="buy_ticket",
build=pages.BuyTicketPage,
),
ComponentPage(
name="SeatingPlan",
url_segment="seating",
build=pages.SeatingPlanPage,
),
ComponentPage(
name="Catering",
url_segment="catering",
build=pages.CateringPage,
),
ComponentPage(
name="Guests",
url_segment="guests",
build=pages.GuestsPage,
),
ComponentPage(
name="Tournaments",
url_segment="tournaments",
build=pages.TournamentsPage,
),
ComponentPage(
name="FAQ",
url_segment="faq",
build=pages.FaqPage,
),
ComponentPage(
name="RulesGTC",
url_segment="rules-gtc",
build=pages.RulesPage
),
ComponentPage(
name="Contact",
url_segment="contact",
build=pages.ContactPage,
),
ComponentPage(
name="Imprint",
url_segment="imprint",
build=pages.ImprintPage,
),
ComponentPage(
name="Register",
url_segment="register",
build=pages.RegisterPage,
guard=not_logged_in_guard
),
ComponentPage(
name="ForgotPassword",
url_segment="forgot-password",
build=pages.ForgotPasswordPage,
guard=not_logged_in_guard
),
ComponentPage(
name="EditProfile",
url_segment="edit-profile",
build=pages.EditProfilePage,
guard=logged_in_guard
),
ComponentPage(
name="Account",
url_segment="account",
build=pages.AccountPage,
guard=logged_in_guard
),
ComponentPage(
name="ManageNewsPage",
url_segment="manage-news",
build=pages.ManageNewsPage,
guard=team_guard
),
ComponentPage(
name="ManageUsersPage",
url_segment="manage-users",
build=pages.ManageUsersPage,
guard=team_guard
),
ComponentPage(
name="ManageCateringPage",
url_segment="manage-catering",
build=pages.ManageCateringPage,
guard=team_guard
),
ComponentPage(
name="ManageTournamentsPage",
url_segment="manage-tournaments",
build=pages.ManageTournamentsPage,
guard=team_guard
),
ComponentPage(
name="DbErrorPage",
url_segment="db-error",
build=pages.DbErrorPage,
)
],
theme=theme,
assets_dir=Path(__file__).parent / "assets",
default_attachments=services,
on_session_start=on_session_start,
on_app_start=on_app_start,
icon=from_root("src/ez_lan_manager/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, Genuss, Gesellschaft, Videospiele, "
"Videogames, LAN, Party, EZ, 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"
}
)
sys.exit(app.run_as_web_server())

View File

@ -0,0 +1,30 @@
import logging
from from_root import from_root
from src.ez_lan_manager.services import *
from src.ez_lan_manager.services.AccountingService import AccountingService
from src.ez_lan_manager.services.CateringService import CateringService
from src.ez_lan_manager.services.ConfigurationService import ConfigurationService
from src.ez_lan_manager.services.DatabaseService import DatabaseService
from src.ez_lan_manager.services.MailingService import MailingService
from src.ez_lan_manager.services.NewsService import NewsService
from src.ez_lan_manager.services.SeatingService import SeatingService
from src.ez_lan_manager.services.TicketingService import TicketingService
from src.ez_lan_manager.services.UserService import UserService
from src.ez_lan_manager.types import *
# Inits services in the correct order
def init_services() -> tuple[AccountingService, CateringService, ConfigurationService, DatabaseService, MailingService, NewsService, SeatingService, TicketingService, UserService]:
logging.basicConfig(level=logging.DEBUG)
configuration_service = ConfigurationService(from_root("config.toml"))
db_service = DatabaseService(configuration_service.get_database_configuration())
user_service = UserService(db_service)
accounting_service = AccountingService(db_service)
news_service = NewsService(db_service)
mailing_service = MailingService(configuration_service)
ticketing_service = TicketingService(configuration_service.get_ticket_info(), db_service, accounting_service)
seating_service = SeatingService(configuration_service.get_seating_configuration(), configuration_service.get_lan_info(), db_service, ticketing_service)
catering_service = CateringService(db_service, accounting_service, user_service)
return accounting_service, catering_service, configuration_service, db_service, mailing_service, news_service, seating_service, ticketing_service, user_service

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,38 @@
from asyncio import sleep
from rio import Text, Component, TextStyle
class AnimatedText(Component):
def __post_init__(self) -> None:
self._display_printing: list[bool] = [False]
self.text_comp = Text("")
async def display_text(self, success: bool, text: str, speed: float = 0.06, font_size: float = 0.9) -> None:
if self._display_printing[0]:
return
else:
self._display_printing[0] = True
self.text_comp.text = ""
if success:
self.text_comp.style = TextStyle(
fill=self.session.theme.success_color,
font_size=font_size
)
for c in text:
self.text_comp.text = self.text_comp.text + c
await self.text_comp.force_refresh()
await sleep(speed)
else:
self.text_comp.style = TextStyle(
fill=self.session.theme.danger_color,
font_size=font_size
)
for c in text:
self.text_comp.text = self.text_comp.text + c
await self.text_comp.force_refresh()
await sleep(speed)
self._display_printing[0] = False
def build(self) -> Component:
return self.text_comp

View File

@ -0,0 +1,30 @@
from typing import Callable
import rio
from rio import Component, Row, Text, IconButton, TextStyle
from src.ez_lan_manager import AccountingService
MAX_LEN = 24
class CateringCartItem(Component):
article_name: str
article_price: int
article_id: int
list_id: int
remove_item_cb: Callable
@staticmethod
def ellipsize_string(string: str) -> str:
if len(string) <= MAX_LEN:
return string
return string[:MAX_LEN - 3] + "..."
def build(self) -> rio.Component:
return Row(
Text(self.ellipsize_string(self.article_name), align_x=0, overflow="wrap", min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)),
Text(AccountingService.make_euro_string_from_int(self.article_price), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)),
IconButton(icon="material/close", size=2, color=self.session.theme.danger_color, style="plain-text", on_press=lambda: self.remove_item_cb(self.list_id)),
proportions=(19, 5, 2)
)

View File

@ -0,0 +1,47 @@
from typing import Callable
from rio import Component, Row, Text, TextStyle, Color, Rectangle, CursorStyle
from rio.components.pointer_event_listener import PointerEvent, PointerEventListener
from src.ez_lan_manager.types.CateringOrder import CateringOrderStatus, CateringOrder
MAX_LEN = 24
class CateringOrderItem(Component):
order: CateringOrder
info_modal_cb: Callable
def get_display_text_and_color_for_order_status(self, order_status: CateringOrderStatus) -> tuple[str, Color]:
match order_status:
case CateringOrderStatus.RECEIVED:
return "In Bearbeitung", self.session.theme.success_color
case CateringOrderStatus.DELAYED:
return "Verspätet", Color.from_hex("eed202")
case CateringOrderStatus.READY_FOR_PICKUP:
return "Abholbereit", self.session.theme.success_color
case CateringOrderStatus.EN_ROUTE:
return "Unterwegs", self.session.theme.success_color
case CateringOrderStatus.COMPLETED:
return "Abgeschlossen", self.session.theme.success_color
case CateringOrderStatus.CANCELED:
return "Storniert", self.session.theme.danger_color
case _:
return "Unbekannt(wtf?)", self.session.theme.danger_color
def build(self) -> Component:
order_status, color = self.get_display_text_and_color_for_order_status(self.order.status)
return PointerEventListener(
Rectangle(
content=Row(
Text(f"ID: {str(self.order.order_id):0>6}", align_x=0, overflow="wrap", min_width=10, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), margin_right=1),
Text(order_status, overflow="wrap", min_width=10, style=TextStyle(fill=color, font_size=0.9), margin_right=1),
Text(self.order.order_date.strftime("%d.%m. %H:%M"), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), align_x=1)
),
fill=self.session.theme.primary_color,
hover_fill=self.session.theme.hud_color,
transition_time=0.1,
cursor=CursorStyle.POINTER
),
on_press=lambda _: self.info_modal_cb(self.order),
)

View File

@ -0,0 +1,70 @@
from typing import Callable
import rio
from rio import Component, Row, Text, IconButton, TextStyle, Column, Spacer, Card, Color
from src.ez_lan_manager import AccountingService
MAX_LEN = 24
class CateringSelectionItem(Component):
article_name: str
article_price: int
article_id: int
on_add_callback: Callable
is_sensitive: bool
additional_info: str
is_grey: bool
@staticmethod
def split_article_name(article_name: str) -> tuple[str, str]:
if len(article_name) <= MAX_LEN:
return article_name, ""
top, bottom = "", ""
words = article_name.split(" ")
last_word_added = ""
while len(top) <= MAX_LEN:
w = words.pop(0)
top += f" {w}"
last_word_added = w
top = top.replace(last_word_added, "")
bottom = f"{last_word_added} " + " ".join(words)
return top.strip(), bottom.strip()
def build(self) -> rio.Component:
article_name_top, article_name_bottom = self.split_article_name(self.article_name)
return Card(
content=Column(
Row(
Text(article_name_top, align_x=0, overflow="wrap", min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)),
Text(AccountingService.make_euro_string_from_int(self.article_price), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)),
IconButton(
icon="material/add",
min_size=2,
color=self.session.theme.success_color,
style="plain-text",
on_press=lambda: self.on_add_callback(self.article_id),
is_sensitive=self.is_sensitive
),
proportions=(19, 5, 2),
margin_bottom=0
),
Spacer() if not article_name_bottom else Text(article_name_bottom, align_x=0, overflow="wrap", min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)),
Row(
Text(
self.additional_info,
align_x=0,
overflow="wrap",
min_width=19,
style=TextStyle(fill=self.session.theme.background_color, font_size=0.6)
),
margin_top=0
),
margin_bottom=0.5,
),
color=Color.from_hex("d3d3d3") if self.is_grey else self.session.theme.primary_color
)

View File

@ -0,0 +1,74 @@
from copy import copy, deepcopy
from typing import Optional
from rio import *
from src.ez_lan_manager import ConfigurationService, UserService
from src.ez_lan_manager.components.DesktopNavigationButton import DesktopNavigationButton
from src.ez_lan_manager.components.UserInfoAndLoginBox import UserInfoAndLoginBox
from src.ez_lan_manager.types.SessionStorage import SessionStorage
from src.ez_lan_manager.types.User import User
class DesktopNavigation(Component):
user: Optional[User] = None
@event.on_populate
async def async_init(self) -> None:
self.session[SessionStorage].subscribe_to_logged_in_or_out_event(str(self.__class__), self.async_init)
if self.session[SessionStorage].user_id:
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
else:
self.user = None
def build(self) -> Component:
lan_info = self.session[ConfigurationService].get_lan_info()
user_navigation = [
DesktopNavigationButton("News", "./news"),
Spacer(min_height=1),
DesktopNavigationButton(f"Über {lan_info.name} {lan_info.iteration}", "./overview"),
DesktopNavigationButton("Ticket kaufen", "./buy_ticket"),
DesktopNavigationButton("Sitzplan", "./seating"),
DesktopNavigationButton("Catering", "./catering"),
DesktopNavigationButton("Teilnehmer", "./guests"),
DesktopNavigationButton("Turniere", "./tournaments"),
DesktopNavigationButton("FAQ", "./faq"),
DesktopNavigationButton("Regeln & AGB", "./rules-gtc"),
Spacer(min_height=1),
DesktopNavigationButton("Discord", "#", open_new_tab=True), # Temporarily disabled: https://discord.gg/8gTjg34yyH
DesktopNavigationButton("Die EZ GG e.V.", "https://ezgg-ev.de/about", open_new_tab=True),
DesktopNavigationButton("Kontakt", "./contact"),
DesktopNavigationButton("Impressum & DSGVO", "./imprint"),
Spacer(min_height=1)
]
team_navigation = [
Text("Verwaltung", align_x=0.5, margin_top=0.3, style=TextStyle(fill=Color.from_hex("F0EADE"), font_size=1.2)),
Text("Vorsichtig sein!", align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.danger_color, font_size=0.6)),
DesktopNavigationButton("News", "./manage-news", is_team_navigation=True),
DesktopNavigationButton("Benutzer", "./manage-users", is_team_navigation=True),
DesktopNavigationButton("Catering", "./manage-catering", is_team_navigation=True),
DesktopNavigationButton("Turniere", "./manage-tournaments", is_team_navigation=True),
Spacer(min_height=1),
Revealer(
header="Normale Navigation",
content=Column(*user_navigation),
header_style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9)
)
] if self.user is not None and self.user.is_team_member else []
nav_to_use = copy(team_navigation) if self.user is not None and self.user.is_team_member else copy(user_navigation)
return Card(
Column(
Text(lan_info.name, align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.hud_color, font_size=2.5)),
Text(f"Edition {lan_info.iteration}", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=1.2), margin_top=0.3, margin_bottom=2),
UserInfoAndLoginBox(),
*nav_to_use,
align_y=0
),
color=self.session.theme.neutral_color,
min_width=15,
grow_y=True,
corner_radius=(0.5, 0, 0, 0),
margin_right=0.1
)

View File

@ -0,0 +1,25 @@
from rio import Component, TextStyle, Color, Link, Button, Text
class DesktopNavigationButton(Component):
STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9)
TEAM_STYLE = TextStyle(fill=Color.from_hex("F0EADE"), font_size=0.9)
label: str
target_url: str
is_team_navigation: bool = False
open_new_tab: bool = False
def build(self) -> Component:
return Link(
content=Button(
content=Text(self.label, style=self.TEAM_STYLE if self.is_team_navigation else self.STYLE),
shape="rectangle",
style="minor",
color="danger" if self.is_team_navigation else "secondary",
grow_x=True,
margin_left=0.6,
margin_right=0.6,
margin_top=0.6
),
target_url=self.target_url,
open_in_new_tab=self.open_new_tab
)

View File

@ -0,0 +1,103 @@
from rio import Component, TextStyle, Color, TextInput, Button, Text, Rectangle, Column, Row, Spacer, \
EventHandler
from src.ez_lan_manager.services.UserService import UserService
from src.ez_lan_manager.types.SessionStorage import SessionStorage
from src.ez_lan_manager.types.User import User
class LoginBox(Component):
status_change_cb: EventHandler = None
TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9)
user_name_input_text: str = ""
password_input_text: str = ""
user_name_input_is_valid = True
password_input_is_valid = True
login_button_is_loading = False
is_account_locked: bool = False
async def _on_login_pressed(self) -> None:
if await self.session[UserService].is_login_valid(self.user_name_input_text, self.password_input_text):
user: User = await self.session[UserService].get_user(self.user_name_input_text)
if not user.is_active:
self.is_account_locked = True
return
self.user_name_input_is_valid = True
self.password_input_is_valid = True
self.login_button_is_loading = False
self.is_account_locked = False
await self.session[SessionStorage].set_user_id_and_team_member_flag(user.user_id, user.is_team_member)
await self.status_change_cb()
else:
self.user_name_input_is_valid = False
self.password_input_is_valid = False
self.login_button_is_loading = False
self.is_account_locked = False
await self.force_refresh()
def build(self) -> Component:
user_name_input = TextInput(
text=self.bind().user_name_input_text,
label="Benutzername",
accessibility_label="Benutzername",
min_height=0.5,
on_confirm=lambda _: self._on_login_pressed(),
is_valid=self.user_name_input_is_valid
)
password_input = TextInput(
text=self.bind().password_input_text,
label="Passwort",
accessibility_label="Passwort",
is_secret=True,
on_confirm=lambda _: self._on_login_pressed(),
is_valid=self.password_input_is_valid
)
login_button = Button(
Text("LOGIN", style=self.TEXT_STYLE, justify="center"),
shape="rectangle",
style="minor",
color="secondary",
margin_bottom=0.4,
on_press=self._on_login_pressed
)
register_button = Button(
Text("REG", style=self.TEXT_STYLE, justify="center"),
shape="rectangle",
style="minor",
color="secondary",
on_press=lambda: self.session.navigate_to("./register")
)
forgot_password_button = Button(
Text("LST PWD", style=self.TEXT_STYLE, justify="center"),
shape="rectangle",
style="minor",
color="secondary",
on_press=lambda: self.session.navigate_to("./forgot-password")
)
return Rectangle(
content=Column(
user_name_input,
password_input,
Column(
Row(
login_button
),
Row(
register_button,
Spacer(),
forgot_password_button,
proportions=(49, 2, 49)
),
margin_bottom=0.5
),
Text(text="Dieses Konto\nist gesperrt", style=TextStyle(fill=self.session.theme.danger_color, font_size=0.9 if self.is_account_locked else 0), align_x=0.5),
spacing=0.4
),
fill=Color.TRANSPARENT,
min_height=8,
min_width=12,
align_x=0.5,
margin_top=0.3,
margin_bottom=2
)

View File

@ -0,0 +1,25 @@
from typing import Optional
from rio import Component, Rectangle, Text
class MainViewContentBox(Component):
content: Optional[Component] = None
def build(self) -> Component:
if self.content is None:
content = Text("Vielleich sollte hier etwas sein...\n\n\n... Wenn ja, habe ich es nicht gefunden. :(")
else:
content = self.content
return Rectangle(
content=content,
fill=self.session.theme.primary_color,
margin_left=1,
margin_right=1,
margin_top=1,
margin_bottom=1,
shadow_radius=0.5,
shadow_color=self.session.theme.hud_color,
shadow_offset_y=0,
corner_radius=0.2
)

View File

@ -0,0 +1,76 @@
from datetime import datetime
from typing import Optional
from rio import Component, Column, NumberInput, ThemeContextSwitcher, TextInput, Row, Button, EventHandler
from src.ez_lan_manager.types.Transaction import Transaction
from src.ez_lan_manager.types.User import User
class NewTransactionForm(Component):
user: Optional[User] = None
input_value: float = 0
input_reason: str = ""
new_transaction_cb: EventHandler[Transaction] = None
async def send_debit_transaction(self) -> None:
await self.call_event_handler(
self.new_transaction_cb,
Transaction(
user_id=self.user.user_id,
value=round(self.input_value * 100),
is_debit=True,
reference=self.input_reason,
transaction_date=datetime.now()
)
)
async def send_credit_transaction(self) -> None:
await self.call_event_handler(
self.new_transaction_cb,
Transaction(
user_id=self.user.user_id,
value=round(self.input_value * 100),
is_debit=False,
reference=self.input_reason,
transaction_date=datetime.now()
)
)
def build(self) -> Component:
return ThemeContextSwitcher(
content=Column(
NumberInput(
value=self.bind().input_value,
label="Betrag",
suffix_text="",
decimals=2,
thousands_separator=".",
margin=1,
margin_bottom=0
),
TextInput(
text=self.bind().input_reason,
label="Beschreibung",
margin=1,
margin_bottom=0
),
Row(
Button(
content="Entfernen",
shape="rectangle",
color="danger",
margin=1,
on_press=self.send_debit_transaction
),
Button(
content="Hinzufügen",
shape="rectangle",
color="success",
margin=1,
on_press=self.send_credit_transaction
)
)
),
color="primary"
)

View File

@ -0,0 +1,152 @@
from datetime import datetime
from functools import partial
from typing import Optional, Callable
from rio import Component, Rectangle, Text, TextStyle, Column, Row, TextInput, DateInput, MultiLineTextInput, IconButton, Color, Button, ThemeContextSwitcher
class NewsPost(Component):
title: str = ""
text: str = ""
date: str = ""
subtitle: str = ""
author: str = ""
def build(self) -> Component:
return Rectangle(
content=Column(
Row(
Text(
self.title,
grow_x=True,
margin=2,
margin_bottom=0,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.3
),
overflow="ellipsize"
),
Text(
self.date,
margin=2,
align_x=1,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.6
),
overflow="wrap"
)
),
Text(
self.subtitle,
grow_x=True,
margin=2,
margin_top=0,
margin_bottom=0,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.8
),
overflow="ellipsize"
),
Text(
self.text,
margin=2,
style=TextStyle(
fill=self.session.theme.background_color
),
overflow="wrap"
),
Text(
f"Geschrieben von {self.author}",
align_x=0,
grow_x=True,
margin=2,
margin_top=0,
margin_bottom=1,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.5,
italic=True
),
overflow="nowrap"
)
),
fill=self.session.theme.primary_color,
margin_left=1,
margin_right=1,
margin_top=2,
margin_bottom=1,
shadow_radius=0.5,
shadow_color=self.session.theme.hud_color,
shadow_offset_y=0,
corner_radius=0.2
)
class EditableNewsPost(NewsPost):
news_id: int = -1
save_cb: Callable = lambda _: None
delete_cb: Callable = lambda _: None
def set_prop(self, prop, value) -> None:
self.__setattr__(prop, value)
def build(self) -> Component:
return ThemeContextSwitcher(
content=Rectangle(
content=Column(
Row(
TextInput(
text=self.title,
label="Titel",
style="rounded",
min_width=15,
on_change=lambda e: self.set_prop("title", e.text)
),
DateInput(
value=datetime.strptime(self.date, "%d.%m.%Y"),
style="rounded",
on_change=lambda e: self.set_prop("date", e.value.strftime("%d.%m.%Y"))
)
),
TextInput(
text=self.subtitle,
label="Untertitel",
style="rounded",
grow_x=True,
on_change=lambda e: self.set_prop("subtitle", e.text)
),
MultiLineTextInput(
text=self.text,
label="Text",
style="rounded",
grow_x=True,
min_height=12,
on_change=lambda e: self.set_prop("text", e.text)
),
Row(
TextInput(
text=self.author,
label="Autor",
style="rounded",
grow_x=True,
on_change=lambda e: self.set_prop("author", e.text)
),
Rectangle(content=Button(icon="material/delete", style="major", color="danger", shape="rectangle", on_press=partial(self.delete_cb, self.news_id)), fill=Color.from_hex("0b7372")),
Rectangle(content=Button(icon="material/save", style="major", color="success", shape="rectangle", on_press=partial(self.save_cb, self)), fill=Color.from_hex("0b7372"))
)
),
fill=self.session.theme.primary_color,
margin_left=1,
margin_right=1,
margin_top=2,
margin_bottom=1,
shadow_radius=0.2,
shadow_color=self.session.theme.background_color,
shadow_offset_y=0,
corner_radius=0.2
),
color="primary"
)

View File

@ -0,0 +1,194 @@
from typing import Callable
from rio import Component, Rectangle, Grid, Column, Row, Text, TextStyle, Color
from src.ez_lan_manager.components.SeatingPlanPixels import SeatPixel, WallPixel, InvisiblePixel, TextPixel
from src.ez_lan_manager.types.Seat import Seat
MAX_GRID_WIDTH_PIXELS = 34
MAX_GRID_HEIGHT_PIXELS = 45
class SeatingPlanLegend(Component):
def build(self) -> Component:
return Column(
Text("Legende", style=TextStyle(fill=self.session.theme.neutral_color), justify="center", margin=1),
Row(
Text("L = Luxus Platz", justify="center", style=TextStyle(fill=self.session.theme.neutral_color)),
Text("N = Normaler Platz", justify="center", style=TextStyle(fill=self.session.theme.neutral_color)),
),
Row(
Rectangle(
content=Column(
Text(f"Freier Platz", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False),
Text(f"", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5,
selectable=False, overflow="wrap")
),
min_width=1,
min_height=1,
fill=self.session.theme.success_color,
grow_x=False,
grow_y=False,
hover_fill=self.session.theme.success_color,
transition_time=0.4,
ripple=True
),
Rectangle(
content=Column(
Text(f"Belegter Platz", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False),
Text(f"", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5,
selectable=False, overflow="wrap")
),
min_width=1,
min_height=1,
fill=self.session.theme.danger_color,
grow_x=False,
grow_y=False,
hover_fill=self.session.theme.danger_color,
transition_time=0.4,
ripple=True
),
Rectangle(
content=Column(
Text(f"Eigener Platz", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False),
Text(f"", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5,
selectable=False, overflow="wrap")
),
min_width=1,
min_height=1,
fill=Color.from_hex("800080"),
grow_x=False,
grow_y=False,
hover_fill=Color.from_hex("800080"),
transition_time=0.4,
ripple=True
),
margin=1,
spacing=1
)
)
class SeatingPlan(Component):
seat_clicked_cb: Callable
seating_info: list[Seat]
def get_seat(self, seat_id: str) -> Seat:
return next(filter(lambda seat: seat.seat_id == seat_id, self.seating_info))
"""
This seating plan is for the community center "Bottenhorn"
"""
def build(self) -> Component:
grid = Grid()
# Outlines
for column_id in range(0, MAX_GRID_WIDTH_PIXELS):
grid.add(InvisiblePixel(), row=0, column=column_id)
for y in range(0, 13):
grid.add(WallPixel(), row=y, column=0)
for y in range(13, 19):
grid.add(InvisiblePixel(), row=y, column=0)
for y in range(19, MAX_GRID_HEIGHT_PIXELS):
grid.add(WallPixel(), row=y, column=0)
# Block A
block_a_margin_left = 12
block_a_margin_top = 1
(grid
.add(SeatPixel("A01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A01")), row=block_a_margin_top, column=block_a_margin_left, width=2, height=3)
.add(SeatPixel("A02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A02")), row=block_a_margin_top + 4, column=block_a_margin_left, width=2, height=3)
.add(SeatPixel("A03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A03")), row=block_a_margin_top + 8, column=block_a_margin_left, width=2, height=3)
.add(SeatPixel("A10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A10")), row=block_a_margin_top, column=block_a_margin_left + 3, width=2, height=3)
.add(SeatPixel("A11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A11")), row=block_a_margin_top + 4, column=block_a_margin_left + 3, width=2, height=3)
.add(SeatPixel("A12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A12")), row=block_a_margin_top + 8, column=block_a_margin_left + 3, width=2, height=3)
)
# Block B
block_b_margin_left = 20
block_b_margin_top = 1
(grid
.add(SeatPixel("B01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B01")), row=block_b_margin_top, column=block_b_margin_left, width=2, height=3)
.add(SeatPixel("B02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B02")), row=block_b_margin_top + 4, column=block_b_margin_left, width=2, height=3)
.add(SeatPixel("B03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B03")), row=block_b_margin_top + 8, column=block_b_margin_left, width=2, height=3)
.add(SeatPixel("B10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B10")), row=block_b_margin_top, column=block_b_margin_left + 3, width=2, height=3)
.add(SeatPixel("B11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B11")), row=block_b_margin_top + 4, column=block_b_margin_left + 3, width=2, height=3)
.add(SeatPixel("B12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B12")), row=block_b_margin_top + 8, column=block_b_margin_left + 3, width=2, height=3)
)
# Block C
block_c_margin_left = 28
block_c_margin_top = 1
(grid
.add(SeatPixel("C01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C01")), row=block_c_margin_top, column=block_c_margin_left, width=2, height=3)
.add(SeatPixel("C02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C02")), row=block_c_margin_top + 4, column=block_c_margin_left, width=2, height=3)
.add(SeatPixel("C03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C03")), row=block_c_margin_top + 8, column=block_c_margin_left, width=2, height=3)
.add(SeatPixel("C10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C10")), row=block_c_margin_top, column=block_c_margin_left + 3, width=2, height=3)
.add(SeatPixel("C11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C11")), row=block_c_margin_top + 4, column=block_c_margin_left + 3, width=2, height=3)
.add(SeatPixel("C12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C12")), row=block_c_margin_top + 8, column=block_c_margin_left + 3, width=2, height=3)
)
# Block D
block_d_margin_left = 20
block_d_margin_top = 20
(grid
.add(SeatPixel("D01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D01")), row=block_d_margin_top, column=block_d_margin_left, width=2, height=3)
.add(SeatPixel("D02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D02")), row=block_d_margin_top + 4, column=block_d_margin_left, width=2, height=3)
.add(SeatPixel("D03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D03")), row=block_d_margin_top + 8, column=block_d_margin_left, width=2, height=3)
.add(SeatPixel("D10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D10")), row=block_d_margin_top, column=block_d_margin_left + 3, width=2, height=3)
.add(SeatPixel("D11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D11")), row=block_d_margin_top + 4, column=block_d_margin_left + 3, width=2, height=3)
.add(SeatPixel("D12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D12")), row=block_d_margin_top + 8, column=block_d_margin_left + 3, width=2, height=3)
)
# Block E
block_e_margin_left = 28
block_e_margin_top = 20
(grid
.add(SeatPixel("E01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E01")), row=block_e_margin_top, column=block_e_margin_left, width=2, height=3)
.add(SeatPixel("E02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E02")), row=block_e_margin_top + 4, column=block_e_margin_left, width=2, height=3)
.add(SeatPixel("E03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E03")), row=block_e_margin_top + 8, column=block_e_margin_left, width=2, height=3)
.add(SeatPixel("E10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E10")), row=block_e_margin_top, column=block_e_margin_left + 3, width=2, height=3)
.add(SeatPixel("E11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E11")), row=block_e_margin_top + 4, column=block_e_margin_left + 3, width=2, height=3)
.add(SeatPixel("E12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E12")), row=block_e_margin_top + 8, column=block_e_margin_left + 3, width=2, height=3)
)
# Middle Wall
for y in range(0, 13):
grid.add(WallPixel(), row=y, column=10)
for y in range(19, MAX_GRID_HEIGHT_PIXELS):
grid.add(WallPixel(), row=y, column=10)
# Stage
for x in range(11, MAX_GRID_WIDTH_PIXELS):
grid.add(WallPixel(), row=35, column=x)
grid.add(TextPixel(text="Bühne"), row=36, column=11, width=24, height=9)
# Drinks
grid.add(TextPixel(text="G\ne\nt\nr\nä\nn\nk\ne"), row=21, column=11, width=3, height=11)
# Sleeping
grid.add(TextPixel(icon_name="material/bed"), row=1, column=1, width=4, height=11)
# Toilet
grid.add(TextPixel(icon_name="material/floor", no_outline=True), row=1, column=7, width=3, height=2)
grid.add(TextPixel(icon_name="material/north", no_outline=True), row=3, column=7, width=3, height=2)
grid.add(TextPixel(icon_name="material/wc"), row=5, column=7, width=3, height=2)
# Entry/Helpdesk
grid.add(TextPixel(text="Einlass\n &Orga"), row=19, column=3, width=7, height=5)
# Wall below Entry/Helpdesk
for y in range(24, MAX_GRID_HEIGHT_PIXELS):
grid.add(WallPixel(), row=y, column=3)
# Entry Arrow
grid.add(TextPixel(icon_name="material/east", no_outline=True), row=15, column=1, width=2, height=2)
return Rectangle(
content=grid,
grow_x=True,
grow_y=True,
stroke_color=self.session.theme.neutral_color,
stroke_width=0.1,
fill=self.session.theme.primary_color,
margin=0.5
)

View File

@ -0,0 +1,46 @@
from typing import Optional, Callable
from rio import Component, Column, Text, TextStyle, Button, Spacer
class SeatingPlanInfoBox(Component):
show: bool
purchase_cb: Callable
is_booking_blocked: bool
seat_id: Optional[str] = None
seat_occupant: Optional[str] = None
seat_price: int = 0
is_blocked: bool = False
def build(self) -> Component:
if not self.show:
return Spacer()
if self.is_blocked:
return Column(Text(f"Sitzplatz gesperrt", margin=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.4), overflow="wrap", justify="center"), min_height=10)
if self.seat_id is None and self.seat_occupant is None:
return Column(Text(f"Sitzplatz auswählen...", margin=1, style=TextStyle(fill=self.session.theme.neutral_color), overflow="wrap", justify="center"), min_height=10)
return Column(
Text(f"Dieser Sitzplatz ({self.seat_id}) ist gebucht von:", margin=1, style=TextStyle(fill=self.session.theme.neutral_color), overflow="wrap", justify="center"),
Text(f"{self.seat_occupant}", margin_bottom=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.4), overflow="wrap", justify="center"),
min_height=10
) if self.seat_id and self.seat_occupant else Column(
Text(f"Dieser Sitzplatz ({self.seat_id}) ist frei", margin=1, style=TextStyle(fill=self.session.theme.neutral_color), overflow="wrap", justify="center"),
Button(
Text(
f"Buchen",
margin=1,
style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.1),
overflow="wrap",
justify="center"
),
shape="rounded",
style="major",
color="secondary",
margin=1,
grow_y=False,
is_sensitive=not self.is_booking_blocked,
on_press=self.purchase_cb
),
min_height=10
)

View File

@ -0,0 +1,103 @@
from functools import partial
from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color, PointerEventListener, Column
from typing import Optional, Callable
from src.ez_lan_manager.types.Seat import Seat
from src.ez_lan_manager.types.SessionStorage import SessionStorage
class SeatPixel(Component):
seat_id: str
on_press_cb: Callable
seat: Seat
def determine_color(self) -> Color:
if self.seat.user is not None and self.seat.user.user_id == self.session[SessionStorage].user_id:
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
def build(self) -> Component:
return PointerEventListener(
content=Rectangle(
content=Column(
Text(f"{self.seat_id}", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False),
Text(f"{self.seat.category[0]}", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, selectable=False, overflow="wrap")
),
min_width=1,
min_height=1,
fill=self.determine_color(),
hover_stroke_width = 0.1,
grow_x=True,
grow_y=True,
hover_fill=self.session.theme.hud_color,
transition_time=0.4,
ripple=True
),
on_press=partial(self.on_press_cb, self.seat_id)
)
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.neutral_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.neutral_color)
else:
content = None
return Rectangle(
content=content,
min_width=1,
min_height=1,
fill=self.session.theme.primary_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=self.session.theme.primary_color,
hover_stroke_width=0.0,
grow_x=True,
grow_y=True
)

View File

@ -0,0 +1,96 @@
from typing import Optional, Callable
from rio import Component, Column, Text, TextStyle, Button, Spacer, Row, ProgressCircle
class SeatingPurchaseBox(Component):
show: bool
seat_id: str
is_loading: bool
confirm_cb: Callable
cancel_cb: Callable
error_msg: Optional[str] = None
success_msg: Optional[str] = None
def build(self) -> Component:
if not self.show:
return Spacer()
if self.is_loading:
return Column(
ProgressCircle(
color="secondary",
align_x=0.5,
margin_top=2,
margin_bottom=2
),
min_height=10
)
if self.success_msg:
return Column(
Text(f"{self.success_msg}", margin=1, style=TextStyle(fill=self.session.theme.success_color, font_size=1.1),
overflow="wrap", justify="center"),
Row(
Button(
Text("Zurück",
margin=1,
style=TextStyle(fill=self.session.theme.success_color, font_size=1.1),
overflow="wrap",
justify="center"
),
shape="rounded",
style="plain-text",
on_press=self.cancel_cb
)
),
min_height=10
)
if self.error_msg:
return Column(
Text(f"{self.error_msg}", margin=1, style=TextStyle(fill=self.session.theme.danger_color, font_size=1.1),
overflow="wrap", justify="center"),
Row(
Button(
Text("Zurück",
margin=1,
style=TextStyle(fill=self.session.theme.danger_color, font_size=1.1),
overflow="wrap",
justify="center"
),
shape="rounded",
style="plain-text",
on_press=self.cancel_cb
)
),
min_height=10
)
return Column(
Text(f"Sitzplatz {self.seat_id} verbindlich buchen?", margin=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.4), overflow="wrap", justify="center"),
Row(
Button(
Text("Nein",
margin=1,
style=TextStyle(fill=self.session.theme.danger_color, font_size=1.1),
overflow="wrap",
justify="center"
),
shape="rounded",
style="plain-text",
on_press=self.cancel_cb
),
Button(
Text("Ja",
margin=1,
style=TextStyle(fill=self.session.theme.success_color, font_size=1.1),
overflow="wrap",
justify="center"
),
shape="rounded",
style="minor",
on_press=self.confirm_cb
)
),
min_height=10
)

View File

@ -0,0 +1,208 @@
from asyncio import sleep, create_task
import rio
from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer, Popup, Table
from src.ez_lan_manager.components.CateringCartItem import CateringCartItem
from src.ez_lan_manager.components.CateringOrderItem import CateringOrderItem
from src.ez_lan_manager.services.AccountingService import AccountingService
from src.ez_lan_manager.services.CateringService import CateringService, CateringError, CateringErrorType
from src.ez_lan_manager.types.CateringOrder import CateringOrder, CateringMenuItemsWithAmount
from src.ez_lan_manager.types.SessionStorage import SessionStorage
POPUP_CLOSE_TIMEOUT_SECONDS = 3
class ShoppingCartAndOrders(Component):
show_cart: bool = True
orders: list[CateringOrder] = []
order_button_loading: bool = False
popup_message: str = ""
popup_is_shown: bool = False
popup_is_error: bool = True
async def switch(self) -> None:
self.show_cart = not self.show_cart
self.orders = await self.session[CateringService].get_orders_for_user(self.session[SessionStorage].user_id)
async def on_remove_item(self, list_id: int) -> None:
catering_service = self.session[CateringService]
user_id = self.session[SessionStorage].user_id
cart = catering_service.get_cart(user_id)
try:
cart.pop(list_id)
except IndexError:
return
catering_service.save_cart(user_id, cart)
await self.force_refresh()
async def on_empty_cart_pressed(self) -> None:
self.session[CateringService].save_cart(self.session[SessionStorage].user_id, [])
await self.force_refresh()
async def on_add_item(self, article_id: int) -> None:
catering_service = self.session[CateringService]
user_id = self.session[SessionStorage].user_id
if not user_id:
return
cart = catering_service.get_cart(user_id)
item_to_add = await catering_service.get_menu_item_by_id(article_id)
cart.append(item_to_add)
catering_service.save_cart(user_id, cart)
await self.force_refresh()
async def show_popup(self, text: str, is_error: bool) -> None:
self.popup_is_error = is_error
self.popup_message = text
self.popup_is_shown = True
await self.force_refresh()
await sleep(POPUP_CLOSE_TIMEOUT_SECONDS)
self.popup_is_shown = False
await self.force_refresh()
async def on_order_pressed(self) -> None:
self.order_button_loading = True
await self.force_refresh()
user_id = self.session[SessionStorage].user_id
cart = self.session[CateringService].get_cart(user_id)
if len(cart) < 1:
_ = create_task(self.show_popup("Warenkorb leer", True))
else:
items_with_amounts: CateringMenuItemsWithAmount = {}
for item in cart:
try:
items_with_amounts[item] += 1
except KeyError:
items_with_amounts[item] = 1
try:
await self.session[CateringService].place_order(items_with_amounts, user_id)
except CateringError as catering_error:
if catering_error.error_type == CateringErrorType.INCLUDES_DISABLED_ITEM:
_ = create_task(self.show_popup("Warenkorb enthält gesperrte Artikel", True))
elif catering_error.error_type == CateringErrorType.INSUFFICIENT_FUNDS:
_ = create_task(self.show_popup("Guthaben nicht ausreichend", True))
else:
_ = create_task(self.show_popup("Unbekannter Fehler", True))
self.session[CateringService].save_cart(self.session[SessionStorage].user_id, [])
self.order_button_loading = False
_ = create_task(self.show_popup("Bestellung erfolgreich aufgegeben!", False))
async def _create_order_info_modal(self, order: CateringOrder) -> None:
def build_dialog_content() -> rio.Component:
# @todo: rio 0.10.8 did not have the ability to align the columns, check back in a future version
table = Table(
{
"Artikel": [item.name for item in order.items.keys()] + ["Gesamtpreis:"],
"Anzahl": [item for item in order.items.values()] + [""],
"Preis": [AccountingService.make_euro_string_from_int(item.price) for item in order.items.keys()] + [AccountingService.make_euro_string_from_int(order.price)],
},
show_row_numbers=False
)
return rio.Card(
rio.Column(
rio.Text(
f"Deine Bestellung ({order.order_id})",
align_x=0.5,
margin_bottom=0.5
),
table,
margin=2,
),
align_x=0.5,
align_y=0.2,
min_width=50,
min_height=10,
color=self.session.theme.primary_color,
margin_left=1,
margin_right=1,
margin_top=2,
margin_bottom=1,
)
dialog = await self.session.show_custom_dialog(
build=build_dialog_content,
modal=True,
user_closeable=True,
)
await dialog.wait_for_close()
def build(self) -> rio.Component:
user_id = self.session[SessionStorage].user_id
catering_service = self.session[CateringService]
cart = catering_service.get_cart(user_id)
if self.show_cart:
cart_container = ScrollContainer(
content=Column(
*[CateringCartItem(
article_name=cart_item.name,
article_price=cart_item.price,
article_id=cart_item.item_id,
remove_item_cb=self.on_remove_item,
list_id=idx
) for idx, cart_item in enumerate(cart)],
Spacer(grow_y=True)
),
min_height=8,
min_width=33,
margin=1
)
return Column(
cart_container,
Popup(
anchor=cart_container,
content=Text(self.popup_message, style=TextStyle(fill=self.session.theme.danger_color if self.popup_is_error else self.session.theme.success_color), overflow="wrap", margin=2, justify="center", min_width=20),
is_open=self.popup_is_shown,
position="center",
color=self.session.theme.primary_color
),
Row(
Text(
text=f"Preis: {AccountingService.make_euro_string_from_int(sum(cart_item.price for cart_item in cart))}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.8
),
margin=1
),
Button(
content=Text(
"Warenkorb leeren",
style=TextStyle(fill=self.session.theme.danger_color, font_size=0.9),
align_x=0.2
),
margin=1,
margin_left=0,
shape="rectangle",
style="major",
color="primary",
on_press=self.on_empty_cart_pressed
),
Button(
content=Text(
"Bestellen",
style=TextStyle(fill=self.session.theme.success_color, font_size=0.9),
align_x=0.2
),
margin=1,
margin_left=0,
shape="rectangle",
style="major",
color="primary",
on_press=self.on_order_pressed,
is_loading=self.order_button_loading
)
)
)
else:
orders_container = ScrollContainer(
content=Column(
*[CateringOrderItem(
order=order_item,
info_modal_cb=self._create_order_info_modal
) for order_item in self.orders],
Spacer(grow_y=True)
),
min_height=8,
min_width=33,
margin=1
)
return Column(orders_container)

View File

@ -0,0 +1,88 @@
from functools import partial
from typing import Callable, Optional
import rio
from rio import Component, Card, Column, Text, Row, Button, TextStyle, ProgressBar, event, Spacer
from src.ez_lan_manager import TicketingService
from src.ez_lan_manager.services.AccountingService import AccountingService
from src.ez_lan_manager.types.Ticket import Ticket
class TicketBuyCard(Component):
description: str
additional_info: str
price: int
category: str
pressed_cb: Callable
is_enabled: bool
total_tickets: int
user_ticket: Optional[Ticket]
available_tickets: int = 0
@event.on_populate
async def async_init(self) -> None:
self.available_tickets = await self.session[TicketingService].get_available_tickets_for_category(self.category)
def build(self) -> rio.Component:
ticket_description_style = TextStyle(
fill=self.session.theme.neutral_color,
font_size=1.2,
)
ticket_additional_info_style = TextStyle(
fill=self.session.theme.neutral_color,
font_size=0.8
)
ticket_owned_style = TextStyle(
fill=self.session.theme.success_color,
font_size=0.8
)
try:
progress = self.available_tickets / self.total_tickets
except ZeroDivisionError:
progress = 0
progress_bar = ProgressBar(
progress=progress,
color=self.session.theme.success_color if progress > 0.25 else self.session.theme.danger_color,
margin_right=1,
grow_x=True
)
tickets_side_text = Text(
f"{self.available_tickets}/{self.total_tickets}",
align_x=1
)
return Card(
Column(
Text(self.description, margin_left=1, margin_top=1, style=ticket_description_style),
Text("Du besitzt dieses Ticket!", margin_left=1, margin_top=1, style=ticket_owned_style) if self.user_ticket is not None and self.user_ticket.category == self.category else Spacer(),
Text(self.additional_info, margin_left=1, margin_top=1, style=ticket_additional_info_style, overflow="wrap"),
Row(
progress_bar,
tickets_side_text,
margin_top=1,
margin_left=1,
margin_right=1
),
Row(
Text(f"{AccountingService.make_euro_string_from_int(self.price)}", margin_left=1, margin_top=1, grow_x=True),
Button(
Text("Kaufen", align_x=0.5, margin=0.4),
margin_right=1,
margin_top=1,
style="major",
shape="rounded",
on_press=partial(self.pressed_cb, self.category),
is_sensitive=self.is_enabled
),
margin_bottom=1
)
),
margin_left=3,
margin_right=3,
margin_bottom=1,
color=self.session.theme.hud_color,
corner_radius=0.2
)

View File

@ -0,0 +1,250 @@
from datetime import date
from hashlib import sha256
from typing import Optional
from email_validator import validate_email, EmailNotValidError
from from_root import from_root
from rio import Component, Column, Button, Color, TextStyle, Text, TextInput, Row, Image, event, Spacer, DateInput, \
TextInputChangeEvent, NoFileSelectedError
from src.ez_lan_manager.services.UserService import UserService, NameNotAllowedError
from src.ez_lan_manager.services.ConfigurationService import ConfigurationService
from src.ez_lan_manager.types.SessionStorage import SessionStorage
from src.ez_lan_manager.types.User import User
class UserEditForm(Component):
is_own_profile: bool = True
profile_picture: Optional[bytes] = None
user: Optional[User] = None
input_user_name: str = ""
input_user_mail: str = ""
input_user_first_name: str = ""
input_user_last_name: str = ""
input_password_1: str = ""
input_password_2: str = ""
input_birthday: date = date.today()
is_email_valid: bool = True
result_text: str = ""
result_success: bool = True
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Profil bearbeiten")
if self.is_own_profile:
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
self.profile_picture = await self.session[UserService].get_profile_picture(self.user.user_id)
else:
self.profile_picture = await self.session[UserService].get_profile_picture(self.user.user_id)
self.input_user_name = self.user.user_name
self.input_user_mail = self.user.user_mail
self.input_user_first_name = self.optional_str_to_str(self.user.user_first_name)
self.input_user_last_name = self.optional_str_to_str(self.user.user_last_name)
self.input_birthday = self.user.user_birth_day if self.user.user_birth_day else date.today()
@staticmethod
def optional_str_to_str(s: Optional[str]) -> str:
if s:
return s
return ""
def on_email_changed(self, change_event: TextInputChangeEvent) -> None:
try:
validate_email(change_event.text, check_deliverability=False)
self.is_email_valid = True
except EmailNotValidError:
self.is_email_valid = 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.result_text = "Keine Datei ausgewählt!"
self.result_success = False
return
if new_pfp.size_in_bytes > 2 * 1_000_000:
self.result_text = "Bild zu groß! (> 2MB)"
self.result_success = False
return
image_data = await new_pfp.read_bytes()
await self.session[UserService].set_profile_picture(self.user.user_id, image_data)
self.profile_picture = image_data
self.result_text = "Gespeichert!"
self.result_success = True
async def remove_profile_picture(self) -> None:
await self.session[UserService].remove_profile_picture(self.user.user_id)
self.profile_picture = None
self.result_text = "Profilbild entfernt!"
self.result_success = True
async def on_save_pressed(self) -> None:
if not all((self.is_email_valid, self.input_user_name, self.input_user_mail)):
self.result_text = "Ungültige Werte!"
self.result_success = False
return
if len(self.input_password_1.strip()) > 0:
if self.input_password_1.strip() != self.input_password_2.strip():
self.result_text = "Passwörter nicht gleich!"
self.result_success = False
return
self.user.user_mail = self.input_user_mail
if self.input_birthday == date.today():
self.user.user_birth_day = None
else:
self.user.user_birth_day = self.input_birthday
self.user.user_first_name = self.input_user_first_name
self.user.user_last_name = self.input_user_last_name
self.user.user_name = self.input_user_name
if len(self.input_password_1.strip()) > 0:
self.user.user_password = sha256(self.input_password_1.strip().encode(encoding="utf-8")).hexdigest()
try:
await self.session[UserService].update_user(self.user)
except NameNotAllowedError:
self.result_text = "Ungültige Zeichen in Nutzername"
self.result_success = False
return
self.result_text = "Gespeichert!"
self.result_success = True
def build(self) -> Component:
pfp_image_container = Image(
from_root("src/ez_lan_manager/assets/img/anon_pfp.png") if self.profile_picture is None else self.profile_picture,
align_x=0.5,
min_width=10,
min_height=10,
margin_top=1,
margin_bottom=1
)
return Column(
pfp_image_container,
Button(
content=Text(
"Neues Bild hochladen",
style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9)
),
align_x=0.5,
margin_bottom=1,
shape="rectangle",
style="major",
color="primary",
on_press=self.upload_new_pfp
) if self.is_own_profile else Button(
content=Text(
"Bild löschen",
style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9)
),
align_x=0.5,
margin_bottom=1,
shape="rectangle",
style="major",
color="primary",
on_press=self.remove_profile_picture
),
Row(
TextInput(
label=f"{'Deine ' if self.is_own_profile else ''}User-ID",
text=str(self.user.user_id),
is_sensitive=False,
margin_left=1,
grow_x=False
),
TextInput(
label=f"{'Dein ' if self.is_own_profile else ''}Nickname",
text=self.bind().input_user_name,
is_sensitive=not self.is_own_profile,
margin_left=1,
margin_right=1,
grow_x=True
),
margin_bottom=1
),
TextInput(
label="E-Mail Adresse",
text=self.bind().input_user_mail,
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True,
is_valid=self.is_email_valid,
on_change=self.on_email_changed
),
Row(
TextInput(
label="Vorname",
text=self.bind().input_user_first_name,
margin_left=1,
margin_right=1,
grow_x=True
),
TextInput(
label="Nachname",
text=self.bind().input_user_last_name,
margin_right=1,
grow_x=True
),
margin_bottom=1
),
DateInput(
value=self.bind().input_birthday,
label="Geburtstag",
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True
),
TextInput(
label="Neues Passwort setzen",
text=self.bind().input_password_1,
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True,
is_secret=True
),
TextInput(
label="Neues Passwort wiederholen",
text=self.bind().input_password_2,
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True,
is_secret=True
),
Row(
Text(
text=self.bind().result_text,
style=TextStyle(fill=self.session.theme.success_color if self.result_success else self.session.theme.danger_color),
margin_left=1
),
Button(
content=Text(
"Speichern",
style=TextStyle(fill=self.session.theme.success_color, font_size=0.9),
align_x=0.2
),
align_x=0.9,
margin_top=2,
margin_bottom=1,
shape="rectangle",
style="major",
color="primary",
on_press=self.on_save_pressed
),
)
) if self.user else Spacer()

View File

@ -0,0 +1,15 @@
import logging
from rio import Component
from src.ez_lan_manager.components.LoginBox import LoginBox
from src.ez_lan_manager.components.UserInfoBox import UserInfoBox
from src.ez_lan_manager.types.SessionStorage import SessionStorage
logger = logging.getLogger(__name__.split(".")[-1])
class UserInfoAndLoginBox(Component):
def build(self) -> Component:
if self.session[SessionStorage].user_id is None:
return LoginBox(status_change_cb=self.force_refresh)
else:
return UserInfoBox(status_change_cb=self.force_refresh)

View File

@ -0,0 +1,107 @@
from random import choice
from typing import Optional
from rio import Component, TextStyle, Color, Button, Text, Rectangle, Column, Row, Spacer, Link, event, EventHandler
from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton
from src.ez_lan_manager.services.UserService import UserService
from src.ez_lan_manager.services.AccountingService import AccountingService
from src.ez_lan_manager.services.TicketingService import TicketingService
from src.ez_lan_manager.services.SeatingService import SeatingService
from src.ez_lan_manager.types.Seat import Seat
from src.ez_lan_manager.types.Ticket import Ticket
from src.ez_lan_manager.types.User import User
from src.ez_lan_manager.types.SessionStorage import SessionStorage
class StatusButton(Component):
STYLE = TextStyle(fill=Color.from_hex("121212"), font_size=0.5)
label: str
target_url: str
enabled: bool
def build(self) -> Component:
return Link(
content=Button(
content=Text(self.label, style=self.STYLE, justify="center"),
shape="rectangle",
style="major",
color="success" if self.enabled else "danger",
grow_x=True,
margin_left=0.6,
margin_right=0.6,
margin_top=0.6
),
target_url=self.target_url,
align_y=0.5,
grow_y=False
)
class UserInfoBox(Component):
status_change_cb: EventHandler = None
TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9)
user: Optional[User] = None
user_balance: Optional[int] = 0
user_ticket: Optional[Ticket] = None
user_seat: Optional[Seat] = None
@staticmethod
def get_greeting() -> str:
return choice(["Guten Tacho", "Tuten Gag", "Servus", "Moinjour", "Hallöchen", "Heyho", "Moinsen"])
async def logout(self) -> None:
await self.session[SessionStorage].clear()
self.user = None
await self.status_change_cb()
self.session.navigate_to("/")
@event.on_populate
async def async_init(self) -> None:
if self.session[SessionStorage].user_id:
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
self.user_balance = await self.session[AccountingService].get_balance(self.user.user_id)
self.user_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id)
self.user_seat = await self.session[SeatingService].get_user_seat(self.user.user_id)
self.session[AccountingService].add_update_hook(self.update)
async def update(self) -> None:
self.user_balance = await self.session[AccountingService].get_balance(self.user.user_id)
self.user_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id)
self.user_seat = await self.session[SeatingService].get_user_seat(self.user.user_id)
def build(self) -> Component:
if not self.user:
return Spacer()
return Rectangle(
content=Column(
Text(f"{self.get_greeting()},", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9), justify="center"),
Text(f"{self.user.user_name}", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=1.2), justify="center"),
Row(
StatusButton(label="TICKET", target_url="./buy_ticket",
enabled=self.user_ticket is not None),
StatusButton(label="SITZPLATZ", target_url="./seating",
enabled=self.user_seat is not None),
proportions=(50, 50),
grow_y=False
),
UserInfoBoxButton("Profil bearbeiten", "./edit-profile"),
UserInfoBoxButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_int(self.user_balance)}", "./account"),
Button(
content=Text("Ausloggen", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.6)),
shape="rectangle",
style="minor",
color="secondary",
grow_x=True,
margin_left=0.6,
margin_right=0.6,
margin_top=0.6,
on_press=self.logout
)
),
fill=Color.TRANSPARENT,
min_height=8,
min_width=12,
align_x=0.5,
margin_top=0.3,
margin_bottom=2
)

View File

@ -0,0 +1,24 @@
from rio import Component, TextStyle, Color, Link, Button, Text
class UserInfoBoxButton(Component):
STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.6)
label: str
target_url: str
open_new_tab: bool = False
def build(self) -> Component:
return Link(
content=Button(
content=Text(self.label, style=self.STYLE),
shape="rectangle",
style="minor",
color="secondary",
grow_x=True,
margin_left=0.6,
margin_right=0.6,
margin_top=0.6
),
target_url=self.target_url,
open_in_new_tab=self.open_new_tab
)

View File

@ -0,0 +1,24 @@
from typing import Optional
from rio import URL, GuardEvent
from src.ez_lan_manager.services.UserService import UserService
from src.ez_lan_manager.types.SessionStorage import SessionStorage
# Guards pages against access from users that are NOT logged in
def logged_in_guard(event: GuardEvent) -> Optional[URL]:
if event.session[SessionStorage].user_id is None:
return URL("./")
# Guards pages against access from users that ARE logged in
def not_logged_in_guard(event: GuardEvent) -> Optional[URL]:
if event.session[SessionStorage].user_id is not None:
return URL("./")
# Guards pages against access from users that are NOT logged in and NOT team members
def team_guard(event: GuardEvent) -> Optional[URL]:
user_id = event.session[SessionStorage].user_id
is_team_member = event.session[SessionStorage].is_team_member
if user_id is None or not is_team_member:
return URL("./")

View File

@ -0,0 +1,164 @@
# USE THIS ON AN EMPTY DATABASE TO GENERATE DEMO DATA
import asyncio
from datetime import date
import sys
from src.ez_lan_manager import init_services
from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory
from src.ez_lan_manager.types.News import News
DEMO_USERS = [
{ "user_name": "manfred", "user_mail": "manfred@demomail.com", "password_clear_text": "manfred" }, # Gast
{ "user_name": "gustav", "user_mail": "gustav@demomail.com", "password_clear_text": "gustav" }, # Gast + Ticket(NORMAL)
{ "user_name": "jason", "user_mail": "juergen@demomail.com", "password_clear_text": "jason" }, # Gast + Ticket(NORMAL) + Sitzplatz
{ "user_name": "lisa", "user_mail": "lisa@demomail.com", "password_clear_text": "lisa" }, # Teamler
{ "user_name": "thomas", "user_mail": "thomas@demomail.com", "password_clear_text": "thomas" } # Teamler + Admin
]
async def run() -> None:
services = init_services()
await services[3].init_db_pool()
catering_service = services[1]
user_service = services[8]
accounting_service = services[0]
ticket_service = services[7]
seating_service = services[6]
news_service = services[5]
if input("Generate seating table? (y/N): ").lower() == "y":
sys.exit("This part of the script is currently being reworked... :(")
if not input("Generate users? (Y/n): ").lower() == "n":
# MANFRED
manfred = await user_service.create_user(DEMO_USERS[0]["user_name"], DEMO_USERS[0]["user_mail"], DEMO_USERS[0]["password_clear_text"])
# GUSTAV
gustav = await user_service.create_user(DEMO_USERS[1]["user_name"], DEMO_USERS[1]["user_mail"], DEMO_USERS[1]["password_clear_text"])
await accounting_service.add_balance(gustav.user_id, 100000, "DEMO EINZAHLUNG")
await ticket_service.purchase_ticket(gustav.user_id, "NORMAL")
# JASON
jason = await user_service.create_user(DEMO_USERS[2]["user_name"], DEMO_USERS[2]["user_mail"], DEMO_USERS[2]["password_clear_text"])
await accounting_service.add_balance(jason.user_id, 100000, "DEMO EINZAHLUNG")
await ticket_service.purchase_ticket(jason.user_id, "NORMAL")
await seating_service.seat_user(jason.user_id, "D10")
# LISA
lisa = await user_service.create_user(DEMO_USERS[3]["user_name"], DEMO_USERS[3]["user_mail"], DEMO_USERS[3]["password_clear_text"])
await accounting_service.add_balance(lisa.user_id, 100000, "DEMO EINZAHLUNG")
lisa.is_team_member = True
await user_service.update_user(lisa)
# THOMAS
thomas = await user_service.create_user(DEMO_USERS[4]["user_name"], DEMO_USERS[4]["user_mail"], DEMO_USERS[4]["password_clear_text"])
await accounting_service.add_balance(thomas.user_id, 100000, "DEMO EINZAHLUNG")
thomas.is_team_member = True
thomas.is_admin = True
await user_service.update_user(thomas)
if not input("Generate catering menu? (Y/n): ").lower() == "n":
# MAIN_COURSE
await catering_service.add_menu_item("Schnitzel Wiener Art", "mit Pommes", 1050, CateringMenuItemCategory.MAIN_COURSE)
await catering_service.add_menu_item("Jäger Schnitzel mit Champignonrahm Sauce", "mit Pommes", 1150, CateringMenuItemCategory.MAIN_COURSE)
await catering_service.add_menu_item("Tortellini in Käsesauce mit Fleischfüllung", "", 1050, CateringMenuItemCategory.MAIN_COURSE)
await catering_service.add_menu_item("Tortellini in Käsesauce ohne Fleischfüllung", "Vegetarisch", 1050, CateringMenuItemCategory.MAIN_COURSE)
# SNACK
await catering_service.add_menu_item("Käse Schinken Wrap", "", 500, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Puten Paprika Wrap", "", 700, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Tomate Mozzarella Wrap", "", 600, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Portion Pommes", "", 400, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Rinds-Currywurst", "", 450, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Rinds-Currywurst mit Pommes", "", 650, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Nudelsalat", "", 450, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Nudelsalat mit Bockwurst", "", 600, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Kartoffelsalat", "", 450, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Kartoffelsalat mit Bockwurst", "", 600, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Sandwichtoast - Schinken", "", 180, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Sandwichtoast - Käse", "", 180, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Sandwichtoast - Schinken/Käse", "", 210, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Sandwichtoast - Salami", "", 180, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Sandwichtoast - Salami/Käse", "", 210, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Chips - Western Style", "", 130, CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Nachos - Salted", "", 130, CateringMenuItemCategory.SNACK)
# DESSERT
await catering_service.add_menu_item("Panna Cotta mit Erdbeersauce", "", 700, CateringMenuItemCategory.DESSERT)
await catering_service.add_menu_item("Panna Cotta mit Blaubeersauce", "", 700, CateringMenuItemCategory.DESSERT)
await catering_service.add_menu_item("Mousse au Chocolat", "", 700, CateringMenuItemCategory.DESSERT)
# BREAKFAST
await catering_service.add_menu_item("Fruit Loops", "", 150, CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Smacks", "", 150, CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Knuspermüsli", "Schoko", 200, CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Cini Minis", "", 150, CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Schinken", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Käse", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Schinken/Käse", "mit Margarine", 140, CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Salami", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Salami/Käse", "mit Margarine", 140, CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Nutella", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST)
# BEVERAGE_NON_ALCOHOLIC
await catering_service.add_menu_item("Wasser - Still", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Wasser - Medium", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Wasser - Spritzig", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Coca-Cola", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Coca-Cola Zero", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Fanta", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Sprite", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Spezi", "von Paulaner, 0,5L Flasche", 150, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Red Bull", "", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Energy", "Hausmarke", 150, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
# BEVERAGE_ALCOHOLIC
await catering_service.add_menu_item("Pils", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
await catering_service.add_menu_item("Radler", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
await catering_service.add_menu_item("Diesel", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
await catering_service.add_menu_item("Apfelwein Pur", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
await catering_service.add_menu_item("Apfelwein Sauer", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
await catering_service.add_menu_item("Apfelwein Cola", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
# BEVERAGE_COCKTAIL
await catering_service.add_menu_item("Vodka Energy", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Vodka O-Saft", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Whiskey Cola", "mit Bourbon", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Jägermeister Energy", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Sex on the Beach", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Long Island Ice Tea", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Caipirinha", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL)
# BEVERAGE_SHOT
await catering_service.add_menu_item("Jägermeister", "", 200, CateringMenuItemCategory.BEVERAGE_SHOT)
await catering_service.add_menu_item("Tequila", "", 200, CateringMenuItemCategory.BEVERAGE_SHOT)
await catering_service.add_menu_item("PfEZzi", "Getunter Pfefferminz-Schnaps", 199, CateringMenuItemCategory.BEVERAGE_SHOT)
# NON_FOOD
await catering_service.add_menu_item("Zigaretten", "Elixyr", 800, CateringMenuItemCategory.NON_FOOD)
await catering_service.add_menu_item("Mentholfilter", "passend für Elixyr", 120, CateringMenuItemCategory.NON_FOOD)
if not input("Generate default new post? (Y/n): ").lower() == "n":
loops = 0
user = None
while loops < 1000:
user = await user_service.get_user(loops)
if user is not None:
break
loops += 1
if user is None:
sys.exit("Database does not contain users! Exiting...")
await news_service.add_news(News(
news_id=None,
title="Der EZ LAN Manager",
subtitle="Eine Software des EZ GG e.V.",
content="Dies ist eine WIP-Version des EZ LAN Managers. Diese Software soll uns helfen in Zukunft die LAN Parties des EZ GG e.V.'s zu organisieren. Wer Fehler findet darf sie behalten. (Oder er meldet sie)",
author=user,
news_date=date.today()
))
if __name__ == "__main__":
with asyncio.Runner() as loop:
loop.run(run())

View File

@ -0,0 +1,177 @@
from typing import Optional
from rio import Column, Component, event, Text, TextStyle, Button, Color, Revealer, Row, ProgressCircle
from src.ez_lan_manager import ConfigurationService, UserService, AccountingService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ez_lan_manager.types.SessionStorage import SessionStorage
from src.ez_lan_manager.types.Transaction import Transaction
from src.ez_lan_manager.types.User import User
class AccountPage(Component):
user: Optional[User] = None
balance: Optional[int] = None
transaction_history: list[Transaction] = list()
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Guthabenkonto")
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
self.balance = await self.session[AccountingService].get_balance(self.user.user_id)
self.transaction_history = await self.session[AccountingService].get_transaction_history(self.user.user_id)
async def _on_banking_info_press(self):
self.banking_info_revealer.is_open = not self.banking_info_revealer.is_open
def build(self) -> Component:
if not self.user and not self.balance:
return Column(
MainViewContentBox(
ProgressCircle(
color="secondary",
align_x=0.5,
margin_top=2,
margin_bottom=2
)
),
align_y=0,
)
self.banking_info_revealer = Revealer(
header=None,
content=Column(
Text(
"Bankverbindung:",
style=TextStyle(
fill=self.session.theme.background_color
),
margin=0,
margin_top=0,
margin_bottom=1,
align_x=0.5
),
Text(
"Kontoinhaber: Einfach Zocken Gaming Gesellschaft\n"
"IBAN: DE47 5176 2434 0019 8566 07\n"
"BLZ: 51762434\n"
"BIC: GENODE51BIK\n\n"
"Verwendungszweck:",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.7
),
margin=0,
margin_bottom=1,
align_x=0.2
),
Text(
f"AUFLADUNG - {self.user.user_id} - {self.user.user_name}",
style=TextStyle(
fill=self.session.theme.neutral_color
),
margin=0,
margin_bottom=1,
align_x=0.5
)
),
margin=2,
margin_top=0,
margin_bottom=1,
grow_x=True
)
transaction_history = Column(
Text(
"Transaktionshistorie",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=1,
margin_bottom=1,
align_x=0.5
)
)
for transaction in sorted(self.transaction_history, key=lambda t: t.transaction_date, reverse=True):
transaction_history.add(
Row(
Text(
f"{transaction.reference} ({transaction.transaction_date.strftime('%d.%m - %H:%M')})",
style=TextStyle(
fill=self.session.theme.danger_color if transaction.is_debit else self.session.theme.success_color,
font_size=0.8
),
margin=0,
margin_top=0,
margin_left=0.5,
margin_bottom=0.4,
align_x=0
),
Text(
f"{'-' if transaction.is_debit else '+'}{AccountingService.make_euro_string_from_int(transaction.value)}",
style=TextStyle(
fill=self.session.theme.danger_color if transaction.is_debit else self.session.theme.success_color,
font_size=0.8
),
margin=0,
margin_top=0,
margin_right=0.5,
margin_bottom=0.4,
align_x=1
)
)
)
return Column(
MainViewContentBox(
content=Text(
f"Kontostand: {AccountingService.make_euro_string_from_int(self.balance)}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=2,
align_x=0.5
)
),
MainViewContentBox(
content=Column(
Text(
"LAN-Konto aufladen",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=2,
align_x=0.5
),
Button(
content=Text("BANKÜBERWEISUNG", style=TextStyle(fill=Color.from_hex("121212"), font_size=0.8), justify="center"),
shape="rectangle",
style="major",
color="secondary",
grow_x=True,
margin=2,
margin_top=0,
margin_bottom=1,
on_press=self._on_banking_info_press
),
self.banking_info_revealer,
Button(
content=Text("PAYPAL", style=TextStyle(fill=Color.from_hex("121212"), font_size=0.8), justify="center"),
shape="rectangle",
style="major",
color="secondary",
grow_x=True,
margin=2,
margin_top=0,
is_sensitive=False
)
)
),
MainViewContentBox(
content=transaction_history
),
align_y=0,
)

View File

@ -0,0 +1,69 @@
from __future__ import annotations
from typing import * # type: ignore
from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text, PageView
from src.ez_lan_manager import ConfigurationService, DatabaseService
from src.ez_lan_manager.components.DesktopNavigation import DesktopNavigation
class BasePage(Component):
color = "secondary"
corner_radius = (0, 0.5, 0, 0)
@event.periodic(60)
async def check_db_conn(self) -> None:
is_healthy = await self.session[DatabaseService].is_healthy()
if not is_healthy:
self.session.navigate_to("./db-error")
@event.on_window_size_change
async def on_window_size_change(self):
await self.force_refresh()
def build(self) -> Component:
content = Card(
PageView(),
color="secondary",
min_width=38,
corner_radius=(0, 0.5, 0, 0)
)
if self.session.window_width > 28:
return Container(
content=Column(
Column(
Row(
Spacer(grow_x=True, grow_y=True),
DesktopNavigation(),
content,
Spacer(grow_x=True, grow_y=True),
grow_y=True
),
Row(
Spacer(grow_x=True, grow_y=False),
Card(
content=Text(f"EZ LAN Manager Version {self.session[ConfigurationService].APP_VERSION} © EZ GG e.V.", align_x=0.5, align_y=0.5, style=TextStyle(fill=self.session.theme.primary_color, font_size=0.5)),
color=self.session.theme.neutral_color,
corner_radius=(0, 0, 0.5, 0.5),
grow_x=False,
grow_y=False,
min_height=1.2,
min_width=53.1,
margin_bottom=3
),
Spacer(grow_x=True, grow_y=False),
grow_y=False
),
margin_top=4
)
),
grow_x=True,
grow_y=True
)
else:
return Text(
"Der EZ LAN Manager wird\nauf mobilen Endgeräten nur\nim Querformat unterstützt.\nBitte drehe dein Gerät.",
align_x=0.5,
align_y=0.5,
style=TextStyle(fill=Color.from_hex("FFFFFF"), font_size=0.8)
)

View File

@ -0,0 +1,118 @@
from typing import Optional
from rio import Text, Column, TextStyle, Component, event, Button, Popup
from src.ez_lan_manager import ConfigurationService, UserService, TicketingService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ez_lan_manager.components.TicketBuyCard import TicketBuyCard
from src.ez_lan_manager.services.AccountingService import InsufficientFundsError
from src.ez_lan_manager.services.TicketingService import TicketNotAvailableError, UserAlreadyHasTicketError
from src.ez_lan_manager.types.SessionStorage import SessionStorage
from src.ez_lan_manager.types.Ticket import Ticket
from src.ez_lan_manager.types.User import User
class BuyTicketPage(Component):
user: Optional[User] = None
user_ticket: Optional[Ticket] = None
is_popup_open: bool = False
popup_message: str = ""
is_popup_success: bool = False
is_buying_enabled: bool = False
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Ticket kaufen")
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
if self.user is None: # No user logged in
self.is_buying_enabled = False
else: # User is logged in
possible_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id)
self.user_ticket = possible_ticket
if possible_ticket is not None: # User already has a ticket
self.is_buying_enabled = False
else:
self.is_buying_enabled = True
async def on_buy_pressed(self, category: str) -> None:
if not self.user:
return
self.is_buying_enabled = False
await self.force_refresh()
try:
t_s = self.session[TicketingService]
ticket = await t_s.purchase_ticket(self.user.user_id, category)
self.popup_message = f"Ticket erfolgreich gekauft. Deine Ticket-ID lautet: {ticket.ticket_id}."
self.is_popup_success = True
except TicketNotAvailableError:
self.popup_message = "Das ausgewählte Ticket ist nicht verfügbar."
self.is_popup_success = False
except InsufficientFundsError:
self.popup_message = "Dein Guthaben reicht nicht aus um dieses Ticket zu kaufen."
self.is_popup_success = False
except UserAlreadyHasTicketError:
self.popup_message = (f"Du besitzt bereits ein Ticket. Um dein aktuelles Ticket zu stornieren, kontaktiere bitte den Support unter "
f"{self.session[ConfigurationService].get_lan_info().organizer_mail}.")
self.is_popup_success = False
except RuntimeError:
self.popup_message = "Ein unbekannter Fehler ist aufgetreten."
self.is_popup_success = False
self.is_popup_open = True
await self.on_populate()
async def on_popup_close_pressed(self) -> None:
self.is_popup_open = False
self.popup_message = ""
def build(self) -> Component:
ticket_infos = self.session[ConfigurationService].get_ticket_info()
header = Text(
"Tickets & Preise",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
align_x=0.5
)
return Column(
MainViewContentBox(
Column(
header,
Popup(
anchor=header,
content=Column(
Text(
self.popup_message,
style=TextStyle(font_size=1.1, fill=self.session.theme.success_color if self.is_popup_success else self.session.theme.danger_color),
overflow="wrap",
grow_y=True,
margin=1
),
Button("Bestätigen", shape="rounded", grow_y=False, on_press=self.on_popup_close_pressed),
min_width=34,
min_height=10
),
is_open=self.is_popup_open,
position="bottom",
margin=1,
corner_radius=0.2,
color=self.session.theme.primary_color
),
*[TicketBuyCard(
description=t.description,
additional_info=t.additional_info,
price=t.price,
category=t.category,
pressed_cb=self.on_buy_pressed,
is_enabled=self.is_buying_enabled,
total_tickets=t.total_tickets,
user_ticket=self.user_ticket
) for t in ticket_infos]
),
),
align_y=0
)

View File

@ -0,0 +1,284 @@
from typing import Optional
from rio import Column, Component, event, TextStyle, Text, Spacer, Revealer, SwitcherBar, SwitcherBarChangeEvent, ProgressCircle
from src.ez_lan_manager import ConfigurationService, CateringService
from src.ez_lan_manager.components.CateringSelectionItem import CateringSelectionItem
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ez_lan_manager.components.ShoppingCartAndOrders import ShoppingCartAndOrders
from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory, CateringMenuItem
from src.ez_lan_manager.types.SessionStorage import SessionStorage
class CateringPage(Component):
show_cart = True
all_menu_items: Optional[list[CateringMenuItem]] = None
shopping_cart_and_orders: list[ShoppingCartAndOrders] = []
def __post_init__(self) -> None:
self.session[SessionStorage].subscribe_to_logged_in_or_out_event(self.__class__.__name__, self.on_user_logged_in_status_changed)
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Catering")
self.all_menu_items = await self.session[CateringService].get_menu()
async def on_user_logged_in_status_changed(self) -> None:
await self.force_refresh()
async def on_switcher_bar_changed(self, _: SwitcherBarChangeEvent) -> None:
await self.shopping_cart_and_orders[0].switch()
@staticmethod
def get_menu_items_by_category(all_menu_items: list[CateringMenuItem], category: Optional[CateringMenuItemCategory]) -> list[CateringMenuItem]:
return list(filter(lambda item: item.category == category, all_menu_items))
def build(self) -> Component:
user_id = self.session[SessionStorage].user_id
if len(self.shopping_cart_and_orders) == 0:
self.shopping_cart_and_orders.append(ShoppingCartAndOrders())
if len(self.shopping_cart_and_orders) > 1:
self.shopping_cart_and_orders.clear()
self.shopping_cart_and_orders.append(ShoppingCartAndOrders())
switcher_bar = SwitcherBar(
values=["cart", "orders"],
names=["Warenkorb", "Bestellungen"],
selected_value="cart",
margin_left=5,
margin_right=5,
margin_top=1,
margin_bottom=1,
color=self.session.theme.hud_color,
on_change=self.on_switcher_bar_changed
)
shopping_cart_and_orders_container = MainViewContentBox(
Column(
Text(
text="Catering",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=0,
align_x=0.5
),
switcher_bar,
self.shopping_cart_and_orders[0]
)
) if user_id else Spacer()
menu = [MainViewContentBox(
ProgressCircle(
color="secondary",
align_x=0.5,
margin_top=2,
margin_bottom=2
)
)] if not self.all_menu_items else [MainViewContentBox(
Revealer(
header="Snacks",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.shopping_cart_and_orders[0].on_add_item,
is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.SNACK))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Frühstück",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.shopping_cart_and_orders[0].on_add_item,
is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BREAKFAST))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Hauptspeisen",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.shopping_cart_and_orders[0].on_add_item,
is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.MAIN_COURSE))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Desserts",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.shopping_cart_and_orders[0].on_add_item,
is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.DESSERT))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Wasser & Softdrinks",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.shopping_cart_and_orders[0].on_add_item,
is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Alkoholische Getränke",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.shopping_cart_and_orders[0].on_add_item,
is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Cocktails & Longdrinks",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.shopping_cart_and_orders[0].on_add_item,
is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_COCKTAIL))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Shots",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.shopping_cart_and_orders[0].on_add_item,
is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_SHOT))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
),
MainViewContentBox(
Revealer(
header="Sonstiges",
content=Column(
*[CateringSelectionItem(
article_name=catering_menu_item.name,
article_price=catering_menu_item.price,
article_id=catering_menu_item.item_id,
on_add_callback=self.shopping_cart_and_orders[0].on_add_item,
is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled,
additional_info=catering_menu_item.additional_info,
is_grey=idx % 2 == 0
) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.NON_FOOD))],
),
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin=1,
align_y=0.5
)
)]
return Column(
# SHOPPING CART
shopping_cart_and_orders_container,
# ITEM SELECTION
*menu,
align_y=0
)

View File

@ -0,0 +1,130 @@
from datetime import datetime, timedelta
from typing import Optional
from rio import Text, Column, TextStyle, Component, event, TextInput, MultiLineTextInput, Row, Button
from src.ez_lan_manager import ConfigurationService, UserService, MailingService
from src.ez_lan_manager.components.AnimatedText import AnimatedText
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ez_lan_manager.types.SessionStorage import SessionStorage
from src.ez_lan_manager.types.User import User
class ContactPage(Component):
# Workaround: Can not reassign this value without rio triggering refresh
# Using list to bypass this behavior
last_message_sent: list[datetime] = [datetime(day=1, month=1, year=2000)]
display_printing: list[bool] = [False]
user: Optional[User] = None
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Kontakt")
if self.session[SessionStorage].user_id is not None:
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
else:
self.user = None
async def on_send_pressed(self) -> None:
error_msg = ""
self.submit_button.is_loading = True
await self.submit_button.force_refresh()
now = datetime.now()
if not self.email_input.text:
error_msg = "E-Mail darf nicht leer sein!"
elif not self.subject_input.text:
error_msg = "Betreff darf nicht leer sein!"
elif not self.message_input.text:
error_msg = "Nachricht darf nicht leer sein!"
elif (now - self.last_message_sent[0]) < timedelta(minutes=1):
error_msg = "Immer mit der Ruhe!"
if error_msg:
self.submit_button.is_loading = False
await self.animated_text.display_text(False, error_msg)
return
mail_recipient = self.session[ConfigurationService].get_lan_info().organizer_mail
msg = (f"Kontaktformular vom {now.strftime('%d.%m.%Y %H:%M')}:\n\n"
f"Betreff: {self.subject_input.text}\n"
f"Absender: {self.email_input.text}\n\n"
f"Inhalt:\n"
f"{self.message_input.text}\n")
await self.session[MailingService].send_email("Kontaktformular-Mitteilung", msg, mail_recipient)
self.last_message_sent[0] = datetime.now()
self.submit_button.is_loading = False
await self.animated_text.display_text(True, "Nachricht erfolgreich gesendet!")
def build(self) -> Component:
self.animated_text = AnimatedText(
margin_top=2,
margin_bottom=1,
align_x=0.1
)
self.email_input = TextInput(
label="E-Mail Adresse",
text="" if not self.user else self.user.user_mail,
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True
)
self.subject_input = TextInput(
label="Betreff",
text="",
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True
)
self.message_input = MultiLineTextInput(
label="Deine Nachricht an uns",
text="",
margin_left=1,
margin_right=1,
margin_bottom=1,
min_height=5
)
self.submit_button = Button(
content=Text(
"Absenden",
style=TextStyle(fill=self.session.theme.success_color, font_size=0.9),
align_x=0.2
),
align_x=0.9,
margin_top=2,
margin_bottom=1,
shape="rectangle",
style="major",
color="primary",
on_press=self.on_send_pressed
)
return Column(
MainViewContentBox(
Column(
Text(
text="Kontakt",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=1,
align_x=0.5
),
self.email_input,
self.subject_input,
self.message_input,
Row(
self.animated_text,
self.submit_button,
)
)
),
align_y=0
)

View File

@ -0,0 +1,89 @@
from __future__ import annotations
from asyncio import sleep
from typing import * # type: ignore
from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text
from src.ez_lan_manager.services.DatabaseService import DatabaseService
from src.ez_lan_manager import ConfigurationService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
class DbErrorPage(Component):
@event.on_window_size_change
async def on_window_size_change(self) -> None:
await self.force_refresh()
@event.on_mount
async def retry_db_connect(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Fehler")
while not await self.session[DatabaseService].is_healthy():
await sleep(2)
self.session.navigate_to("./")
def build(self) -> Component:
content = Card(
content=MainViewContentBox(
content=Text(
text="Ouh-oh, da läuft gerade irgendwas schief.\n\n"
"Unser Team kümmert sich bereits um das Problem.\n\n"
"Du wirst automatisch weitergeleitet sobald das System wieder verfügbar ist.",
margin=2,
style=TextStyle(
fill=self.session.theme.danger_color,
font_size=1.3
),
overflow="wrap"
)
),
color="secondary",
min_width=38,
corner_radius=(0, 0.5, 0, 0)
)
if self.session.window_width > 28:
return Container(
content=Column(
Column(
Row(
Spacer(grow_x=True, grow_y=True),
Card(
content=Spacer(),
color=self.session.theme.neutral_color,
min_width=15,
grow_y=True,
corner_radius=(0.5, 0, 0, 0),
margin_right=0.1
),
content,
Spacer(grow_x=True, grow_y=True),
grow_y=True
),
Row(
Spacer(grow_x=True, grow_y=False),
Card(
content=Text(f"EZ LAN Manager Version {self.session[ConfigurationService].APP_VERSION} © EZ GG e.V.", align_x=0.5, align_y=0.5, style=TextStyle(fill=self.session.theme.primary_color, font_size=0.5)),
color=self.session.theme.neutral_color,
corner_radius=(0, 0, 0.5, 0.5),
grow_x=False,
grow_y=False,
min_height=1.2,
min_width=53.1,
margin_bottom=3
),
Spacer(grow_x=True, grow_y=False),
grow_y=False
),
margin_top=4
)
),
grow_x=True,
grow_y=True
)
else:
return Text(
"Der EZ LAN Manager wird\nauf mobilen Endgeräten nur\nim Querformat unterstützt.\nBitte drehe dein Gerät.",
align_x=0.5,
align_y=0.5,
style=TextStyle(fill=Color.from_hex("FFFFFF"), font_size=0.8)
)

View File

@ -0,0 +1,26 @@
from typing import Optional
from rio import Column, Component, event, Spacer
from src.ez_lan_manager import ConfigurationService, UserService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ez_lan_manager.components.UserEditForm import UserEditForm
from src.ez_lan_manager.types.SessionStorage import SessionStorage
from src.ez_lan_manager.types.User import User
class EditProfilePage(Component):
user: Optional[User] = None
pfp: Optional[bytes] = None
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Profil bearbeiten")
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
self.pfp = await self.session[UserService].get_profile_picture(self.user.user_id)
def build(self) -> Component:
return Column(
MainViewContentBox(UserEditForm(is_own_profile=True)),
Spacer(grow_y=True)
)

View File

@ -0,0 +1,70 @@
from rio import Column, Component, event, TextStyle, Text, Revealer
from src.ez_lan_manager import ConfigurationService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
FAQ: list[list[str]] = [
["Wie melde ich mich für die LAN an?",
"Registriere dich auf dieser Seite, lade dein Guthabenkonto auf und kaufe ein Ticket. Danach such dir einen freien Sitzplatz auf dem Sitzplan aus."],
["Wie lade ich mein Guthabenkonto auf?",
"Logge dich in deinen Account ein und klicke auf die Schaltfläche 'Guthaben' in der Navigationsleiste. Dort findest du alle weiteren Informationen."],
["Wie kann ich mein Ticket stornieren?", "Schreibe uns eine Mail an tech@ezgg-ev.de, wir kümmern uns dann Zeitnah um die Stornierung."],
["Was soll ich zur LAN mitbringen?",
"Deinen PC inklusive aller zugehörigen Geräte (Maus, Tastatur, Monitor, Headset), sowie aller Anschlusskabel. Wir empfehlen ein LAN Kabel von mindestens 5 Metern Länge mitzubringen. Des weiteren benötigste du eine Mehrfachsteckdose, da dir an deinem Platz nur ein einzelner Steckplatz zugewiesen wird."],
["Wohin mit technischen Problemen?", "Melde dich einfach am Einlass bzw in der Orga-Ecke, wir helfen gerne weiter."],
["Wo entsorge ich meinen Müll?", "Im gesamten Veranstaltungsgebäude findest du Mülltüten/Mülleimer."],
["Darf ich Cannabis konsumieren?", "Generell verbieten wir den Konsum von Cannabis nicht. Beachte aber die allgemeine Gesetzeslage und ziehe ggf. die Bubatzkarte zu Rat."],
["Gibt es einen Discord oder TeamSpeak?",
"Du kannst gerne unseren Vereins-TeamSpeak3-Server unter ts3.ezgg-ev.de nutzen. Den Link zum offiziellen Discord findest du in der Navigationsleiste."],
["Wo bleibt mein Essen?",
"Vermutlich ist es auf dem Weg. Du kannst auf der Catering-Seite den Status deiner Bestellung überprüfen. Hast du Bedenken das sie verloren gegangen sein könnte, sprich ein Team-Mitglied an der Theke darauf an."],
["Wie lange dauert eine Aufladung per Überweißung?",
"In der Regel wird das Guthaben deinem Konto innerhalb von 2 bis 3 Werktagen gutgeschrieben. In Ausnahmefällen kann es bis zu 7 Tagen dauern."],
["Wie melde ich meinen Clan an?",
"Wenn in deiner Gruppe mehr als 3 Personen sind, dann schreib uns bitte eine Mail mit dem Betreff 'Gruppenticket' an tech@ezgg-ev.de. Schreibe uns dort die Nutzer-ID's sowie die Sitzplätze deiner Gruppe auf. Gehe sicher das jede Person in deiner Gruppe entweder bereits ein passendes Ticket besitzt oder über genug Guthaben verfügt um ein Ticket zu kaufen."],
["Wo kann ich schlafen?",
"Im Veranstaltungsgebäude sind offizielle Schlafbereiche ausgewiesen. Solange du keine Zugangs-, Durchgangs-, oder Rettungswege blockierst, darfst du überall schlafen."]
]
class FaqPage(Component):
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - FAQ")
def build(self) -> Component:
return Column(
MainViewContentBox(
Column(
Text(
text="FAQ",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=0,
align_x=0.5
),
*[Revealer(
header=question,
content=Text(
text=answer,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.9
),
margin=1,
overflow="wrap"
),
margin=1,
grow_x=True,
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.8
)
) for question, answer in FAQ]
)
),
align_y=0
)

View File

@ -0,0 +1,109 @@
from hashlib import sha256
from random import choices
from email_validator import validate_email, EmailNotValidError
from rio import Column, Component, event, Text, TextStyle, TextInput, TextInputChangeEvent, Button
from src.ez_lan_manager import ConfigurationService, UserService, MailingService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
class ForgotPasswordPage(Component):
def on_email_changed(self, change_event: TextInputChangeEvent) -> None:
try:
validate_email(change_event.text, check_deliverability=False)
self.email_input.is_valid = True
self.submit_button.is_sensitive = True
except EmailNotValidError:
self.email_input.is_valid = False
self.submit_button.is_sensitive = False
async def on_submit_button_pressed(self) -> None:
self.submit_button.is_loading = True
await self.submit_button.force_refresh()
lan_info = self.session[ConfigurationService].get_lan_info()
user_service = self.session[UserService]
mailing_service = self.session[MailingService]
user = await user_service.get_user(self.email_input.text.strip())
if user is not None:
new_password = "".join(choices(user_service.ALLOWED_USER_NAME_SYMBOLS, k=16))
user.user_password = sha256(new_password.encode(encoding="utf-8")).hexdigest()
await user_service.update_user(user)
await mailing_service.send_email(
subject=f"Dein neues Passwort für {lan_info.name}",
body=f"Du hast für den EZ-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.email_input.text.strip()
)
self.submit_button.is_loading = False
self.email_input.text = ""
self.info_text.text = "Falls für diese E-Mail ein Konto besteht, " \
"bekommst du in den nächsten Minuten ein neues Passwort zugeschickt. " \
"Bitte prüfe dein Spam-Postfach.",
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Passwort vergessen")
def build(self) -> Component:
self.email_input = TextInput(
label="E-Mail Adresse",
text="",
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True,
on_change=self.on_email_changed
)
self.submit_button = Button(
content=Text(
"Neues Passwort anfordern",
style=TextStyle(fill=self.session.theme.background_color, font_size=0.9),
align_x=0.5
),
grow_x=True,
margin_top=2,
margin_left=1,
margin_right=1,
margin_bottom=1,
shape="rectangle",
style="minor",
color=self.session.theme.secondary_color,
on_press=self.on_submit_button_pressed,
is_sensitive=False
)
self.info_text = Text(
text="",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin_top=2,
margin_left=1,
margin_right=1,
margin_bottom=2,
overflow="wrap"
)
return Column(
MainViewContentBox(
content=Column(
Text(
"Passwort vergessen",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
align_x=0.5
),
self.email_input,
self.submit_button,
self.info_text
)
),
align_y=0,
)

View File

@ -0,0 +1,94 @@
from typing import Optional
from rio import Column, Component, event, TextStyle, Text, Button, Row, TextInput, Spacer, TextInputChangeEvent
from src.ez_lan_manager import ConfigurationService, UserService, TicketingService, SeatingService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ez_lan_manager.types.Seat import Seat
from src.ez_lan_manager.types.User import User
class GuestsPage(Component):
table_elements: list[Button] = []
users_with_tickets: list[User] = []
users_with_seats: dict[User, Seat] = {}
user_filter: Optional[str] = None
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Teilnehmer")
user_service = self.session[UserService]
all_users = await user_service.get_all_users()
ticketing_service = self.session[TicketingService]
seating_service = self.session[SeatingService]
u_w_t = []
u_w_s = {}
for user in all_users:
ticket = await ticketing_service.get_user_ticket(user.user_id)
seat = await seating_service.get_user_seat(user.user_id)
if ticket is not None:
u_w_t.append(user)
if seat is not None:
u_w_s[user] = seat
self.users_with_tickets = u_w_t
self.users_with_seats = u_w_s
def on_searchbar_content_change(self, change_event: TextInputChangeEvent) -> None:
self.user_filter = change_event.text
def build(self) -> Component:
if self.user_filter:
users = [user for user in self.users_with_tickets if self.user_filter.lower() in user.user_name or self.user_filter.lower() in str(user.user_id)]
else:
users = self.users_with_tickets
self.table_elements.clear()
for idx, user in enumerate(users):
try:
seat = self.users_with_seats[user]
except KeyError:
seat = None
self.table_elements.append(
Button(
content=Row(Text(text=f"{user.user_id:0>4}", align_x=0, margin_right=1), Text(text=user.user_name, grow_x=True, overflow="ellipsize"),
Text(text="-" if seat is None else seat.seat_id, align_x=1)),
shape="rectangle",
grow_x=True,
color=self.session.theme.hud_color if idx % 2 == 0 else self.session.theme.primary_color
)
)
return Column(
MainViewContentBox(
Column(
Text(
text="Teilnehmer",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
align_x=0.5
),
TextInput(
label="Suche nach Name oder ID",
margin=1,
margin_left=3,
margin_right=3,
on_change=self.on_searchbar_content_change
),
Button(
content=Row(Text(text="ID ", align_x=0, margin_right=1), Text(text="Benutzername", grow_x=True), Text(text="Sitzplatz", align_x=1)),
shape="rectangle",
grow_x=True,
color=self.session.theme.primary_color,
style="plain-text",
is_sensitive=False
),
*self.table_elements,
Spacer(min_height=1)
)
),
align_y=0
)

View File

@ -0,0 +1,104 @@
from rio import Text, Column, TextStyle, Component, event, Link, Color
from src.ez_lan_manager import ConfigurationService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
class ImprintPage(Component):
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Impressum & DSGVO")
def build(self) -> Component:
return Column(
MainViewContentBox(
Column(
Text(
text="Impressum",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
align_x=0.5
),
Text(
text="Angaben gemäß § 5 TMG:\n\n"
"Einfach Zockem Gaming Gesellschaft e.V.\n"
"Im Elchgrund 18\n"
"35080 Bad Endbach - Bottenhorn\n\n"
"Vertreten durch:\n\n"
"1. Vorsitzender: David Rodenkirchen\n"
"2. Vorsitzender: Julia Albring\n"
"Schatzmeisterin: Jessica Rodenkirchen\n\n"
"Kontakt:\n\n"
"E-Mail: vorstand (at) ezgg-ev.de\n\n"
"Registereintrag:\n\n"
"Eingetragen im Vereinsregister.\n"
"Registergericht: Amtsgericht Marburg\n"
"Registernummer: VR 5837\n\n"
"Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:\n\n"
"David Rodenkirchen\n"
"Im Elchgrund 18\n"
"35080 Bad Endbach - Bottenhorn\n",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.9
),
margin=2,
overflow="wrap"
)
)
),
MainViewContentBox(
Column(
Text(
text="Datenschutzerklärung",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
align_x=0.5
),
Text(
text="Die Datenschutzerklärung kann über den untenstehenden Link eingesehen werden",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.9
),
margin_top=2,
margin_bottom=0,
overflow="wrap",
align_x=0.5,
grow_x=True,
min_width=30
),
Link(
content=Text(
text="Datenschutzerklärung",
style=TextStyle(
fill=Color.from_hex("000080"),
font_size=0.9,
underlined=True
),
margin_bottom=1,
margin_top=1,
overflow="wrap",
align_x=0.5
),
target_url="https://ezgg-ev.de/privacy",
open_in_new_tab=True
)
)
),
align_y=0
)

View File

@ -0,0 +1,32 @@
import logging
from rio import Column, Component, event, TextStyle, Text, Spacer
from src.ez_lan_manager import ConfigurationService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
logger = logging.getLogger(__name__.split(".")[-1])
class ManageCateringPage(Component):
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Catering Verwaltung")
def build(self) -> Component:
return Column(
MainViewContentBox(
Column(
Text(
text="Catering Verwaltung",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
align_x=0.5
)
)
),
Spacer()
)

View File

@ -0,0 +1,131 @@
import logging
from asyncio import sleep
from datetime import datetime
from time import strptime
from rio import Column, Component, event, TextStyle, Text
from src.ez_lan_manager import ConfigurationService, UserService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ez_lan_manager.components.NewsPost import EditableNewsPost
from src.ez_lan_manager.services.NewsService import NewsService
from src.ez_lan_manager.types.News import News
logger = logging.getLogger(__name__.split(".")[-1])
class ManageNewsPage(Component):
news_posts: list[News] = []
show_success_message = False
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - News Verwaltung")
self.news_posts = (await self.session[NewsService].get_news())[:8]
async def on_new_news_post(self, post: EditableNewsPost) -> None:
# @todo: For some reason, new posts do not appear through a force_refresh, only after visiting the page again
author = await self.session[UserService].get_user(post.author)
if author is None:
logger.warning(f"Tried to set news post author to '{post.author}', which does not exist.")
return
await self.session[NewsService].add_news(News(
news_id=None,
title=post.title,
subtitle=post.subtitle,
content=post.text,
author=author,
news_date=strptime(post.date, "%d.%m.%Y"),
))
self.news_posts = (await self.session[NewsService].get_news())[:8]
self.show_success_message = True
await self.force_refresh()
await sleep(3)
self.show_success_message = False
await self.force_refresh()
async def on_news_post_changed(self, post: EditableNewsPost) -> None:
author = await self.session[UserService].get_user(post.author)
if author is None:
logger.warning(f"Tried to set news post author to '{post.author}', which does not exist.")
return
await self.session[NewsService].update_news(News(
news_id=post.news_id,
title=post.title,
subtitle=post.subtitle,
content=post.text,
author=author,
news_date=strptime(post.date, "%d.%m.%Y"),
))
self.news_posts = (await self.session[NewsService].get_news())[:8]
async def on_news_post_deleted(self, news_id: int) -> None:
await self.session[NewsService].delete_news(news_id)
self.news_posts = (await self.session[NewsService].get_news())[:8]
def build(self) -> Component:
posts = [EditableNewsPost(
news_id=news.news_id,
title=news.title,
subtitle=news.subtitle,
text=news.content,
date=news.news_date.strftime("%d.%m.%Y"),
author=news.author.user_name,
save_cb=self.on_news_post_changed,
delete_cb=self.on_news_post_deleted
) for news in self.news_posts]
return Column(
MainViewContentBox(
Column(
Text(
text="News Verwaltung",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=0,
align_x=0.5
),
Text(
text="Neuen News Post erstellen",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.1
),
margin_top=2,
margin_bottom=0,
align_x=0.5
),
EditableNewsPost(
title="",
subtitle="",
text="",
date=datetime.now().strftime("%d.%m.%Y"),
author="",
save_cb=self.on_new_news_post
),
Text(
text="Post erfolgreich erstellt",
style=TextStyle(
fill=self.session.theme.success_color,
font_size=0.7 if self.show_success_message else 0
),
margin_top=0.1,
margin_bottom=0,
align_x=0.5
),
Text(
text="Bisherige Posts",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.1
),
margin_top=2,
margin_bottom=0,
align_x=0.5
),
*posts
)
),
align_y=0
)

View File

@ -0,0 +1,32 @@
import logging
from rio import Column, Component, event, TextStyle, Text, Spacer
from src.ez_lan_manager import ConfigurationService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
logger = logging.getLogger(__name__.split(".")[-1])
class ManageTournamentsPage(Component):
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turnier Verwaltung")
def build(self) -> Component:
return Column(
MainViewContentBox(
Column(
Text(
text="Turnier Verwaltung",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
align_x=0.5
)
)
),
Spacer()
)

View File

@ -0,0 +1,281 @@
import logging
from dataclasses import field
from typing import Optional
from rio import Column, Component, event, TextStyle, Text, TextInput, ThemeContextSwitcher, Grid, \
PointerEventListener, PointerEvent, Rectangle, CursorStyle, Color, TextInputChangeEvent, Spacer, Row, Switch, \
SwitchChangeEvent, EventHandler
from src.ez_lan_manager import ConfigurationService, UserService, AccountingService, SeatingService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ez_lan_manager.components.NewTransactionForm import NewTransactionForm
from src.ez_lan_manager.components.UserEditForm import UserEditForm
from src.ez_lan_manager.services.AccountingService import InsufficientFundsError
from src.ez_lan_manager.types.SessionStorage import SessionStorage
from src.ez_lan_manager.types.Transaction import Transaction
from src.ez_lan_manager.types.User import User
logger = logging.getLogger(__name__.split(".")[-1])
class ClickableGridContent(Component):
text: str = ""
is_hovered: bool = False
clicked_cb: EventHandler[str] = None
async def on_mouse_enter(self, _: PointerEvent) -> None:
self.is_hovered = True
async def on_mouse_leave(self, _: PointerEvent) -> None:
self.is_hovered = False
async def on_mouse_click(self, _: PointerEvent) -> None:
await self.call_event_handler(self.clicked_cb, self.text)
def build(self) -> Component:
return PointerEventListener(
content=Rectangle(
content=Text(
self.text,
style=TextStyle(fill=self.session.theme.success_color) if self.is_hovered else TextStyle(fill=self.session.theme.background_color),
grow_x=True
),
fill=Color.TRANSPARENT,
cursor=CursorStyle.POINTER
),
on_pointer_enter=self.on_mouse_enter,
on_pointer_leave=self.on_mouse_leave,
on_press=self.on_mouse_click
)
class ManageUsersPage(Component):
selected_user: Optional[User] = None
all_users: Optional[list] = None
search_results: list[User] = field(default_factory=list)
accounting_section_result_text: str = ""
accounting_section_result_success: bool = True
user_account_balance: str = "0.00 €"
user_seat: str = "-"
is_user_account_locked: bool = False
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Nutzer Verwaltung")
self.all_users = await self.session[UserService].get_all_users()
self.search_results = self.all_users
async def on_user_clicked(self, user_name: str) -> None:
self.selected_user = next(filter(lambda user: user.user_name == user_name, self.all_users))
user_account_balance_raw = await self.session[AccountingService].get_balance(self.selected_user.user_id)
self.user_account_balance = AccountingService.make_euro_string_from_int(user_account_balance_raw)
seat = await self.session[SeatingService].get_user_seat(self.selected_user.user_id)
self.user_seat = seat.seat_id if seat else "-"
self.is_user_account_locked = not self.selected_user.is_active
async def on_search_parameters_changed(self, e: TextInputChangeEvent) -> None:
self.search_results = list(filter(lambda user: (e.text.lower() in user.user_name.lower()) or e.text.lower() in str(user.user_id), self.all_users))
async def change_account_active(self, _: SwitchChangeEvent) -> None:
self.selected_user.is_active = not self.is_user_account_locked
await self.session[UserService].update_user(self.selected_user)
async def on_new_transaction(self, transaction: Transaction) -> None:
if not self.session[SessionStorage].is_team_member: # Better safe than sorry
return
logger.info(f"Got new transaction for user with ID '{transaction.user_id}' over "
f"{'-' if transaction.is_debit else '+'}"
f"{AccountingService.make_euro_string_from_int(transaction.value)} "
f"with reference '{transaction.reference}'")
if transaction.is_debit:
try:
await self.session[AccountingService].remove_balance(
transaction.user_id,
transaction.value,
transaction.reference
)
except InsufficientFundsError:
self.accounting_section_result_text = "Guthaben nicht ausreichend!"
self.accounting_section_result_success = False
return
else:
await self.session[AccountingService].add_balance(
transaction.user_id,
transaction.value,
transaction.reference
)
self.accounting_section_result_text = f"Guthaben {'entfernt' if transaction.is_debit else 'hinzugefügt'}!"
self.accounting_section_result_success = True
def build(self) -> Component:
return Column(
MainViewContentBox(
Column(
Text(
text="Nutzersuche",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
align_x=0.5
),
TextInput(
label="Nutzername oder ID",
margin=1,
on_change=self.on_search_parameters_changed
),
ThemeContextSwitcher(
Grid(
[
Text("Nutzername", margin_bottom=1, grow_x=True, style=TextStyle(font_size=1.1)),
Text("Nutzer-ID", margin_bottom=1, style=TextStyle(font_size=1.1))
],
*[[
ClickableGridContent(text=user.user_name, clicked_cb=self.on_user_clicked),
Text(
str(user.user_id),
justify="right"
)
] for user in self.search_results],
row_spacing=0.2,
margin=1
),
color="primary"
)
)
),
MainViewContentBox(
Column(
Text(
text="Konto & Sitzplatz",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
align_x=0.5
),
Row(
Text(
text="Kontostand:",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin_top=0.5,
margin_bottom=1,
margin_left=2
),
Text(
text=self.bind().user_account_balance,
style=TextStyle(
fill=self.session.theme.neutral_color,
font_size=1
),
margin_top=0.5,
margin_bottom=1,
margin_right=2,
justify="right"
),
),
Row(
Text(
text="Kontosperrung:",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin_top=0.5,
margin_bottom=1,
margin_left=2,
grow_x=True
),
ThemeContextSwitcher(
content=Switch(
is_on=self.bind().is_user_account_locked,
margin_top=0.5,
margin_bottom=1,
margin_right=2,
on_change=self.change_account_active
),
color="primary"
),
),
Row(
Text(
text="Sitzplatz:",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin_top=0.5,
margin_bottom=1,
margin_left=2
),
Text(
text=self.bind().user_seat,
style=TextStyle(
fill=self.session.theme.neutral_color,
font_size=1
),
margin_top=0.5,
margin_bottom=1,
margin_right=2,
justify="right"
),
),
Text(
text="Geld hinzufügen/entfernen",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin_top=0.5,
margin_bottom=1,
align_x=0.5
),
NewTransactionForm(user=self.selected_user, new_transaction_cb=self.on_new_transaction),
Text(
text=self.bind().accounting_section_result_text,
style=TextStyle(
fill=self.session.theme.success_color if self.accounting_section_result_success else self.session.theme.danger_color
),
margin_left=1,
margin_bottom=1
)
)
) if self.selected_user else Spacer(),
MainViewContentBox(
Column(
Text(
text="Allgemeines",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
align_x=0.5
) if self.selected_user else Spacer(),
UserEditForm(
is_own_profile=False,
user=self.selected_user
) if self.selected_user else Text(
text="Bitte Nutzer auswählen...",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
align_x=0.5
)
)
),
align_y=0
)

View File

@ -0,0 +1,27 @@
from rio import Column, Component, event
from src.ez_lan_manager import ConfigurationService, NewsService
from src.ez_lan_manager.components.NewsPost import NewsPost
from src.ez_lan_manager.types.News import News
class NewsPage(Component):
news_posts: list[News] = []
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Neuigkeiten")
self.news_posts = (await self.session[NewsService].get_news())[:8]
def build(self) -> Component:
posts = [NewsPost(
title=news.title,
subtitle=news.subtitle,
text=news.content,
date=news.news_date.strftime("%d.%m.%Y"),
author=news.author.user_name
) for news in self.news_posts]
return Column(
*posts,
align_y=0,
)

View File

@ -0,0 +1,22 @@
from rio import Column, Component, event
from src.ez_lan_manager import ConfigurationService
from src.ez_lan_manager.components.NewsPost import NewsPost
class PlaceholderPage(Component):
placeholder_name: str
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.placeholder_name}")
def build(self) -> Component:
return Column(
NewsPost(
title="Platzhalter",
text=f"Dies ist die Platzhalterseite für {self.placeholder_name}.",
date="99.99.9999"
),
align_y=0,
)

View File

@ -0,0 +1,178 @@
import logging
from email_validator import validate_email, EmailNotValidError
from rio import Column, Component, event, Text, TextStyle, TextInput, TextInputChangeEvent, Button
from src.ez_lan_manager import ConfigurationService, UserService, MailingService
from src.ez_lan_manager.components.AnimatedText import AnimatedText
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
MINIMUM_PASSWORD_LENGTH = 6
logger = logging.getLogger(__name__.split(".")[-1])
class RegisterPage(Component):
def on_pw_change(self, _: TextInputChangeEvent) -> None:
if not (self.pw_1.text == self.pw_2.text) or len(self.pw_1.text) < MINIMUM_PASSWORD_LENGTH:
self.pw_1.is_valid = False
self.pw_2.is_valid = False
return
self.pw_1.is_valid = True
self.pw_2.is_valid = True
def on_email_changed(self, change_event: TextInputChangeEvent) -> None:
try:
validate_email(change_event.text, check_deliverability=False)
self.email_input.is_valid = True
except EmailNotValidError:
self.email_input.is_valid = False
def on_user_name_input_change(self, _: TextInputChangeEvent) -> None:
current_text = self.user_name_input.text
if len(current_text) > UserService.MAX_USERNAME_LENGTH:
self.user_name_input.text = current_text[:UserService.MAX_USERNAME_LENGTH]
async def on_submit_button_pressed(self) -> None:
self.submit_button.is_loading = True
await self.submit_button.force_refresh()
if len(self.user_name_input.text) < 1:
await self.animated_text.display_text(False, "Nutzername darf nicht leer sein!")
self.submit_button.is_loading = False
return
if not (self.pw_1.text == self.pw_2.text):
await self.animated_text.display_text(False, "Passwörter stimmen nicht überein!")
self.submit_button.is_loading = False
return
if len(self.pw_1.text) < MINIMUM_PASSWORD_LENGTH:
await self.animated_text.display_text(False, f"Passwort muss mindestens {MINIMUM_PASSWORD_LENGTH} Zeichen lang sein!")
self.submit_button.is_loading = False
return
if not self.email_input.is_valid or len(self.email_input.text) < 3:
await self.animated_text.display_text(False, "E-Mail Adresse ungültig!")
self.submit_button.is_loading = False
return
user_service = self.session[UserService]
mailing_service = self.session[MailingService]
lan_info = self.session[ConfigurationService].get_lan_info()
if await user_service.get_user(self.email_input.text) is not None or await user_service.get_user(self.user_name_input.text) is not None:
await self.animated_text.display_text(False, "Benutzername oder E-Mail bereits regestriert!")
self.submit_button.is_loading = False
return
try:
new_user = await user_service.create_user(self.user_name_input.text, self.email_input.text, self.pw_1.text)
if not new_user:
raise RuntimeError("User could not be created")
except Exception as e:
logger.error(f"Unknown error during new user registration: {e}")
await self.animated_text.display_text(False, "Es ist ein unbekannter Fehler aufgetreten :(")
self.submit_button.is_loading = False
return
await mailing_service.send_email(
subject="Erfolgreiche Registrierung",
body=f"Hallo {self.user_name_input.text},\n\n"
f"Du hast dich erfolgreich beim EZ-LAN Manager für {lan_info.name} {lan_info.iteration} registriert.\n\n"
f"Wenn du dich nicht registriert hast, kontaktiere bitte unser Team über unsere Homepage.\n\n"
f"Liebe Grüße\nDein {lan_info.name} - Team",
receiver=self.email_input.text
)
self.submit_button.is_loading = False
await self.animated_text.display_text(True, "Erfolgreich registriert!")
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Registrieren")
def build(self) -> Component:
self.user_name_input = TextInput(
label="Benutzername",
text="",
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True,
on_change=self.on_user_name_input_change
)
self.email_input = TextInput(
label="E-Mail Adresse",
text="",
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True,
on_change=self.on_email_changed
)
self.pw_1 = TextInput(
label="Passwort",
text="",
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True,
is_secret=True,
on_change=self.on_pw_change
)
self.pw_2 = TextInput(
label="Passwort wiederholen",
text="",
margin_left=1,
margin_right=1,
margin_bottom=1,
grow_x=True,
is_secret=True,
on_change=self.on_pw_change
)
self.submit_button = Button(
content=Text(
"Registrieren",
style=TextStyle(fill=self.session.theme.background_color, font_size=0.9),
align_x=0.5
),
grow_x=True,
margin_top=2,
margin_left=1,
margin_right=1,
margin_bottom=1,
shape="rectangle",
style="minor",
color=self.session.theme.secondary_color,
on_press=self.on_submit_button_pressed
)
self.animated_text = AnimatedText(
margin_top=2,
margin_left=1,
margin_right=1,
margin_bottom=2
)
return Column(
MainViewContentBox(
content=Column(
Text(
"Neues Konto anlegen",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
align_x=0.5
),
self.user_name_input,
self.email_input,
self.pw_1,
self.pw_2,
self.submit_button,
self.animated_text
)
),
align_y=0,
)

View File

@ -0,0 +1,192 @@
from rio import Column, Component, event, TextStyle, Text, Revealer
from src.ez_lan_manager import ConfigurationService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
RULES: list[str] = [
"Respektvolles Verhalten: Sei höflich und respektvoll gegenüber anderen Gästen und dem Team.",
"Alkohol und Drogen: Konsumiere Alkohol in Maßen und halte dich an die gültige Gesetzeslage.",
"Sitzplätze: Respektiere die zugewiesenen Plätze und ändere sie nicht ohne Genehmigung.",
"Notausgänge und Sicherheitsvorschriften: Informiere dich über die Notausgänge und beachte die Sicherheitsanweisungen.",
"Müllentsorgung: Benutze die vorgesehenen Mülleimer und halte den Veranstaltungsort sauber.",
"Rauchen: Halte dich an die Rauchverbote und benutze nur die ausgewiesenen Raucherbereiche.",
"Hausrecht: Folge den Anweisungen des Veranstalters und des Sicherheitspersonals.",
"Illegales: Das brechen des deutschen Rechts, insbesondere des Urheberrechts, bleibt auch auf LAN verboten."
]
AGB: dict[str, list[str]] = {
"§1": [
"Die Veranstaltung wird von der Einfach Zocken Genuss Gesellschaft e.V. organisiert.",
"Unser Event verfolgt gemeinnützige Ziele und ist nicht auf Profit ausgerichtet. Die erhobenen Teilnahmebeiträge dienen lediglich der Kostendeckung. Überschüsse werden für die Organisation und Durchführung zukünftiger ähnlicher Veranstaltungen verwendet.",
"Die Organisatoren haben das Recht, unerwünschte oder störende Personen jederzeit von der Veranstaltung auszuschließen (siehe §3). Im Falle eines Ausschlusses aufgrund eines Regelverstoßes erfolgt keine Rückerstattung des Eintrittspreises."
],
"§2": [
"Die Teilnahme an der Veranstaltung ist nur Personen gestattet, die mindestens 18 Jahre alt sind. Ein amtlicher Altersnachweis ist erforderlich. Kann dieser Nachweis nicht erbracht werden, wird der Zugang zur Veranstaltung verweigert.",
"Jeder Teilnehmer muss die Teilnahmegebühr entrichtet haben und dies auf Anfrage nachweisen können. Mit der Bezahlung des Eintrittspreises erhält der Teilnehmer einen garantierten Platz auf der Veranstaltung.",
"Alle Teilnehmer sind verpflichtet, vor der Veranstaltung sicherheitsrelevante Patches und Updates für Betriebssysteme und Spiele einzuspielen. Es wird nicht garantiert, dass diese während der Veranstaltung heruntergeladen werden können."
],
"§3": [
"Innerhalb des Veranstaltungsgebäudes gilt ein striktes Rauchverbot.",
"Jeder Teilnehmer verpflichtet sich, während der Veranstaltung keine illegalen Handlungen durchzuführen.",
"Die unautorisierte Verbreitung von urheberrechtlich geschütztem Material ist strengstens untersagt.",
"Der Veranstalter übernimmt keine Haftung für Schäden an Geräten oder Daten der Teilnehmer, es sei denn, der Veranstalter oder seine Erfüllungsgehilfen haben die Schäden vorsätzlich oder grob fahrlässig verursacht. Ebenso wird keine Haftung bei Diebstahl oder Verlust persönlicher Gegenstände übernommen.",
"Teilnehmer dürfen den Ablauf der Veranstaltung nicht absichtlich stören, insbesondere nicht den Betrieb des Computer- und Stromnetzwerks. Als absichtliche Störung zählt auch die Nutzung von Software, die dem Spieler einen unfairen Vorteil verschafft (z.B. Cheats, Hacks) sowie das Ausnutzen von Bugs in Spielen, um einen Vorteil zu erzielen. Solche Verstöße führen zum sofortigen Ausschluss aus allen Turnieren. Betrifft der Verstoß ein Teammitglied, wird das gesamte Team disqualifiziert, auch wenn die anderen Mitglieder nicht direkt beteiligt waren. Wiederholte oder schwerwiegende Verstöße können zum Ausschluss von der gesamten Veranstaltung führen.",
"Die Nutzung von Aktivlautsprechern ist verboten, Kopfhörer sind Pflicht.",
"Verursacht ein Teilnehmer Schäden, haftet er vollumfänglich für die entstehenden Kosten.",
"Teilnehmer sind dazu verpflichtet, nach der Veranstaltung ihren Platz aufzuräumen und persönliche Gegenstände mitzunehmen."
],
"§4": [
"Der Veranstalter stellt während der Veranstaltung einen eingeschränkten Internetzugang zur Verfügung. Es wird jedoch keine Garantie für die Verfügbarkeit, Eignung oder Zuverlässigkeit des Zugangs übernommen. Der Veranstalter behält sich das Recht vor, den Zugang zeitweise oder vollständig einzuschränken oder zu sperren sowie bestimmte Dienste oder Websites zu blockieren.",
"Für alle über das Internet getätigten Aktivitäten, Datenübertragungen und Rechtsgeschäfte ist der Teilnehmer allein verantwortlich. Entstehende Kosten durch die Nutzung von Drittanbieterdiensten trägt der Teilnehmer. Es gilt das Einhalten der gesetzlichen Bestimmungen.",
"Der Teilnehmer stellt den Veranstalter von jeglichen Ansprüchen Dritter frei, die aus einer rechtswidrigen Nutzung des Internetzugangs oder einem Verstoß gegen diese Vereinbarung resultieren. Diese Freistellung schließt auch die Kosten für die Abwehr solcher Ansprüche ein.",
"Der Veranstalter behält sich das Recht vor, die Nutzung des Internetzugangs zu protokollieren, um im Bedarfsfall Beweise für die Nutzung durch bestimmte Teilnehmer vorzulegen und den Veranstalter vor Schäden zu schützen."
]
}
class RulesPage(Component):
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Regeln & AGB")
def build(self) -> Component:
return Column(
MainViewContentBox(
Column(
Text(
text="Regeln",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=0,
align_x=0.5
),
Text(
text="(AGB's in verständlichem deutsch)",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.5
),
margin_top=0.5,
margin_bottom=2,
align_x=0.5
),
*[Text(
f"{idx + 1}. {rule}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.9
),
margin_bottom=0.8,
margin_left=1,
margin_right=1,
overflow="wrap"
) for idx, rule in enumerate(RULES)],
)
),
MainViewContentBox(
Column(
Text(
text="AGB",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=1,
align_x=0.5
),
Revealer(
header="§ 1 Allgemeine Bestimmungen",
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin=1,
margin_top=2,
content=Column(
*[Text(
f"{idx + 1}. {rule}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.8
),
margin_bottom=0.8,
margin_left=1,
margin_right=1,
overflow="wrap"
) for idx, rule in enumerate(AGB["§1"])]
)
),
Revealer(
header="§ 2 Teilnahmevoraussetzungen",
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin=1,
margin_top=0,
content=Column(
*[Text(
f"{idx + 1}. {rule}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.8
),
margin_bottom=0.8,
margin_left=1,
margin_right=1,
overflow="wrap"
) for idx, rule in enumerate(AGB["§2"])]
)
),
Revealer(
header="§ 3 Verhaltensregeln",
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin=1,
margin_top=0,
content=Column(
*[Text(
f"{idx + 1}. {rule}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.8
),
margin_bottom=0.8,
margin_left=1,
margin_right=1,
overflow="wrap"
) for idx, rule in enumerate(AGB["§3"])]
)
),
Revealer(
header="§ 4 Internetzugang",
header_style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin=1,
margin_top=0,
content=Column(
*[Text(
f"{idx + 1}. {rule}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.8
),
margin_bottom=0.8,
margin_left=1,
margin_right=1,
overflow="wrap"
) for idx, rule in enumerate(AGB["§4"])]
)
)
)
),
align_y=0
)

View File

@ -0,0 +1,142 @@
import logging
from asyncio import sleep
from typing import Optional
from rio import Text, Column, TextStyle, Component, event, PressEvent, ProgressCircle
from src.ez_lan_manager import ConfigurationService, SeatingService, TicketingService, UserService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ez_lan_manager.components.SeatingPlan import SeatingPlan, SeatingPlanLegend
from src.ez_lan_manager.components.SeatingPlanInfoBox import SeatingPlanInfoBox
from src.ez_lan_manager.components.SeatingPurchaseBox import SeatingPurchaseBox
from src.ez_lan_manager.services.SeatingService import NoTicketError, SeatNotFoundError, WrongCategoryError, SeatAlreadyTakenError
from src.ez_lan_manager.types.Seat import Seat
from src.ez_lan_manager.types.SessionStorage import SessionStorage
from src.ez_lan_manager.types.User import User
logger = logging.getLogger(__name__.split(".")[-1])
class SeatingPlanPage(Component):
seating_info: Optional[list[Seat]] = None
current_seat_id: Optional[str] = None
current_seat_occupant: Optional[str] = None
current_seat_price: int = 0
current_seat_is_blocked: bool = False
user: Optional[User] = None
show_info_box: bool = True
show_purchase_box: bool = False
purchase_box_loading: bool = False
purchase_box_success_msg: Optional[str] = None
purchase_box_error_msg: Optional[str] = None
is_booking_blocked: bool = False
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Sitzplan")
self.seating_info = await self.session[SeatingService].get_seating()
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
if not self.user:
self.is_booking_blocked = True
else:
for seat in self.seating_info:
if not seat.user or not self.user:
continue
if seat.user.user_id == self.user.user_id:
self.is_booking_blocked = True
async def on_seat_clicked(self, seat_id: str, _: PressEvent) -> None:
self.show_info_box = True
self.show_purchase_box = False
seat = next(filter(lambda s: s.seat_id == seat_id, self.seating_info), None)
if not seat:
return
self.current_seat_is_blocked = seat.is_blocked
self.current_seat_id = seat.seat_id
ticket_info = self.session[TicketingService].get_ticket_info_by_category(seat.category)
price = 0 if not ticket_info else ticket_info.price
self.current_seat_price = price
if seat.user:
self.current_seat_occupant = seat.user.user_name
else:
self.current_seat_occupant = None
def set_error(self, msg: str) -> None:
self.purchase_box_error_msg = msg
self.purchase_box_success_msg = None
def set_success(self, msg: str) -> None:
self.purchase_box_error_msg = None
self.purchase_box_success_msg = msg
async def on_purchase_clicked(self) -> None:
self.show_info_box = False
self.show_purchase_box = True
async def on_purchase_confirmed(self) -> None:
self.purchase_box_loading = True
await self.force_refresh()
await sleep(0.5)
try:
await self.session[SeatingService].seat_user(self.user.user_id, self.current_seat_id)
except (NoTicketError, WrongCategoryError):
self.set_error("Du besitzt kein gültiges Ticket für diesen Platz")
except SeatNotFoundError:
self.set_error("Der angegebene Sitzplatz existiert nicht")
except SeatAlreadyTakenError:
self.set_error("Dieser Platz ist bereits vergeben")
except Exception as e:
self.set_error("Ein unbekannter Fehler ist aufgetreten")
logger.error(e)
else:
self.set_success("Platz erfolgreich gebucht!")
self.purchase_box_loading = False
await self.on_populate()
async def on_purchase_cancelled(self) -> None:
self.purchase_box_loading = False
self.show_info_box = True
self.show_purchase_box = False
self.purchase_box_error_msg = None
self.purchase_box_success_msg = None
def build(self) -> Component:
if not self.seating_info:
return Column(
MainViewContentBox(
ProgressCircle(
color="secondary",
align_x=0.5,
margin_top=2,
margin_bottom=2
)
),
align_y=0
)
return Column(
MainViewContentBox(
Column(
SeatingPlanInfoBox(seat_id=self.current_seat_id, seat_occupant=self.current_seat_occupant, seat_price=self.current_seat_price,
is_blocked=self.current_seat_is_blocked, is_booking_blocked=self.is_booking_blocked, show=self.show_info_box,
purchase_cb=self.on_purchase_clicked),
SeatingPurchaseBox(
show=self.show_purchase_box,
seat_id=self.current_seat_id,
is_loading=self.purchase_box_loading,
confirm_cb=self.on_purchase_confirmed,
cancel_cb=self.on_purchase_cancelled,
error_msg=self.purchase_box_error_msg,
success_msg=self.purchase_box_success_msg
)
)
),
MainViewContentBox(
SeatingPlan(seat_clicked_cb=self.on_seat_clicked, seating_info=self.seating_info) if self.seating_info else
Column(ProgressCircle(color=self.session.theme.secondary_color, margin=3),
Text("Sitzplan wird geladen", style=TextStyle(fill=self.session.theme.neutral_color), align_x=0.5, margin=1))
),
MainViewContentBox(
SeatingPlanLegend(),
),
align_y=0
)

View File

@ -0,0 +1,38 @@
from rio import Column, Component, event, TextStyle, Text
from src.ez_lan_manager import ConfigurationService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
class PAGENAME(Component):
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - PAGENAME")
def build(self) -> Component:
return Column(
MainViewContentBox(
Column(
Text(
text="HEADER",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=0,
align_x=0.5
),
Text(
text="BASIC TEXT",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.9
),
margin=1,
overflow="wrap"
)
)
),
align_y=0
)

View File

@ -0,0 +1,38 @@
from rio import Column, Component, event, TextStyle, Text
from src.ez_lan_manager import ConfigurationService
from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox
class TournamentsPage(Component):
@event.on_populate
async def on_populate(self) -> None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turniere")
def build(self) -> Component:
return Column(
MainViewContentBox(
Column(
Text(
text="Turniere",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=0,
align_x=0.5
),
Text(
text="Aktuell ist noch kein Turnierplan hinterlegt.",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.9
),
margin=1,
overflow="wrap"
)
)
),
align_y=0
)

View File

@ -0,0 +1,21 @@
from .BasePage import BasePage
from .NewsPage import NewsPage
from .PlaceholderPage import PlaceholderPage
from .Account import AccountPage
from .EditProfile import EditProfilePage
from .ForgotPassword import ForgotPasswordPage
from .RegisterPage import RegisterPage
from .ImprintPage import ImprintPage
from .ContactPage import ContactPage
from .RulesPage import RulesPage
from .FaqPage import FaqPage
from .TournamentsPage import TournamentsPage
from .GuestsPage import GuestsPage
from .CateringPage import CateringPage
from .DbErrorPage import DbErrorPage
from .SeatingPlanPage import SeatingPlanPage
from .BuyTicketPage import BuyTicketPage
from .ManageNewsPage import ManageNewsPage
from .ManageUsersPage import ManageUsersPage
from .ManageCateringPage import ManageCateringPage
from .ManageTournamentsPage import ManageTournamentsPage

View File

@ -0,0 +1,83 @@
import logging
from collections.abc import Callable
from datetime import datetime
from src.ez_lan_manager.services.DatabaseService import DatabaseService
from src.ez_lan_manager.types.Transaction import Transaction
logger = logging.getLogger(__name__.split(".")[-1])
class InsufficientFundsError(Exception):
pass
class AccountingService:
def __init__(self, db_service: DatabaseService) -> None:
self._db_service = db_service
self._update_hooks: set[Callable] = set()
def add_update_hook(self, update_hook: Callable) -> None:
""" Adds a function to this service, which is called whenever the account balance changes """
self._update_hooks.add(update_hook)
async def add_balance(self, user_id: int, balance_to_add: int, reference: str) -> int:
await self._db_service.add_transaction(Transaction(
user_id=user_id,
value=balance_to_add,
is_debit=False,
reference=reference,
transaction_date=datetime.now()
))
logger.debug(f"Added balance of {self.make_euro_string_from_int(balance_to_add)} to user with ID {user_id}")
for update_hook in self._update_hooks:
await update_hook()
return await self.get_balance(user_id)
async def remove_balance(self, user_id: int, balance_to_remove: int, reference: str) -> int:
current_balance = await self.get_balance(user_id)
if (current_balance - balance_to_remove) < 0:
raise InsufficientFundsError
await self._db_service.add_transaction(Transaction(
user_id=user_id,
value=balance_to_remove,
is_debit=True,
reference=reference,
transaction_date=datetime.now()
))
logger.debug(f"Removed balance of {self.make_euro_string_from_int(balance_to_remove)} to user with ID {user_id}")
for update_hook in self._update_hooks:
await update_hook()
return await self.get_balance(user_id)
async def get_balance(self, user_id: int) -> int:
balance_buffer = 0
for transaction in await self._db_service.get_all_transactions_for_user(user_id):
if transaction.is_debit:
balance_buffer -= transaction.value
else:
balance_buffer += transaction.value
return balance_buffer
async def get_transaction_history(self, user_id: int) -> list[Transaction]:
return await self._db_service.get_all_transactions_for_user(user_id)
@staticmethod
def make_euro_string_from_int(cent_int: int) -> str:
""" Internally, all money values are cents as ints. Only when showing them to the user we generate a string. Prevents float inaccuracy. """
as_str = str(cent_int)
if as_str[0] == "-":
is_negative = True
as_str = as_str[1:]
else:
is_negative = False
if len(as_str) == 1:
result = f"0.0{as_str}"
elif len(as_str) == 2:
result = f"0.{as_str}"
else:
result = f"{as_str[:-2]}.{as_str[-2:]}"
if is_negative:
result = f"-{result}"
return result

View File

@ -0,0 +1,136 @@
import logging
from enum import Enum
from typing import Optional
from src.ez_lan_manager.services.AccountingService import AccountingService
from src.ez_lan_manager.services.DatabaseService import DatabaseService
from src.ez_lan_manager.services.UserService import UserService
from src.ez_lan_manager.types.CateringOrder import CateringOrder, CateringOrderStatus, CateringMenuItemsWithAmount
from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory
logger = logging.getLogger(__name__.split(".")[-1])
class CateringErrorType(Enum):
INCLUDES_DISABLED_ITEM = 0
INSUFFICIENT_FUNDS = 1
GENERIC = 99
class CateringError(Exception):
def __init__(self, message: str, error_type: CateringErrorType = CateringErrorType.GENERIC) -> None:
self.message = message
self.error_type = error_type
class CateringService:
def __init__(self, db_service: DatabaseService, accounting_service: AccountingService, user_service: UserService):
self._db_service = db_service
self._accounting_service = accounting_service
self._user_service = user_service
self.cached_cart: dict[int, list[CateringMenuItem]] = {}
# ORDERS
async def place_order(self, menu_items: CateringMenuItemsWithAmount, user_id: int, is_delivery: bool = True) -> CateringOrder:
for menu_item in menu_items:
if menu_item.is_disabled:
raise CateringError("Order includes disabled items", CateringErrorType.INCLUDES_DISABLED_ITEM)
user = await self._user_service.get_user(user_id)
if not user:
raise CateringError("User does not exist")
total_price = sum([item.price * quantity for item, quantity in menu_items.items()])
if await self._accounting_service.get_balance(user_id) < total_price:
raise CateringError("Insufficient funds", CateringErrorType.INSUFFICIENT_FUNDS)
order = await self._db_service.add_new_order(menu_items, user_id, is_delivery)
if order:
await self._accounting_service.remove_balance(user_id, total_price, f"CATERING - {order.order_id}")
logger.info(f"User '{order.customer.user_name}' (ID:{order.customer.user_id}) ordered from catering for {self._accounting_service.make_euro_string_from_int(total_price)}")
# await self.cancel_order(order) # ToDo: Check if commented out before commit. Un-comment to auto-cancel every placed order
return order
async def update_order_status(self, order_id: int, new_status: CateringOrderStatus) -> bool:
if new_status == CateringOrderStatus.CANCELED:
# Cancelled orders need to be refunded
raise CateringError("Orders cannot be canceled this way, use CateringService.cancel_order")
return await self._db_service.change_order_status(order_id, new_status)
async def get_orders(self) -> list[CateringOrder]:
return await self._db_service.get_orders()
async def get_orders_for_user(self, user_id: int) -> list[CateringOrder]:
return await self._db_service.get_orders(user_id=user_id)
async def get_orders_by_status(self, status: CateringOrderStatus) -> list[CateringOrder]:
return await self._db_service.get_orders(status=status)
async def cancel_order(self, order: CateringOrder) -> bool:
change_result = await self._db_service.change_order_status(order.order_id, CateringOrderStatus.CANCELED)
if change_result:
await self._accounting_service.add_balance(order.customer.user_id, order.price, f"CATERING REFUND - {order.order_id}")
return True
return False
# MENU ITEMS
async def get_menu(self, category: Optional[CateringMenuItemCategory] = None) -> list[CateringMenuItem]:
items = await self._db_service.get_menu_items()
if not category:
return items
return list(filter(lambda item: item.category == category, items))
async def get_menu_item_by_id(self, menu_item_id: int) -> CateringMenuItem:
item = await self._db_service.get_menu_item(menu_item_id)
if not item:
raise CateringError("Menu item not found")
return item
async def add_menu_item(self, name: str, info: str, price: int, category: CateringMenuItemCategory, is_disabled: bool = False) -> CateringMenuItem:
if new_item := await self._db_service.add_menu_item(name, info, price, category, is_disabled):
return new_item
raise CateringError(f"Could not add item '{name}' to the menu.")
async def remove_menu_item(self, menu_item_id: int) -> bool:
return await self._db_service.delete_menu_item(menu_item_id)
async def change_menu_item(self, updated_item: CateringMenuItem) -> bool:
return await self._db_service.update_menu_item(updated_item)
async def disable_menu_item(self, menu_item_id: int) -> bool:
try:
item = await self.get_menu_item_by_id(menu_item_id)
except CateringError:
return False
item.is_disabled = True
return await self._db_service.update_menu_item(item)
async def enable_menu_item(self, menu_item_id: int) -> bool:
try:
item = await self.get_menu_item_by_id(menu_item_id)
except CateringError:
return False
item.is_disabled = False
return await self._db_service.update_menu_item(item)
async def disable_menu_items_by_category(self, category: CateringMenuItemCategory) -> bool:
items = await self.get_menu(category=category)
return all([self.disable_menu_item(item.item_id) for item in items])
async def enable_menu_items_by_category(self, category: CateringMenuItemCategory) -> bool:
items = await self.get_menu(category=category)
return all([self.enable_menu_item(item.item_id) for item in items])
# CART
def save_cart(self, user_id: Optional[int], cart: list[CateringMenuItem]) -> None:
if user_id:
self.cached_cart[user_id] = cart
def get_cart(self, user_id: Optional[int]) -> list[CateringMenuItem]:
if user_id is None:
return []
try:
return self.cached_cart[user_id]
except KeyError:
return []

View File

@ -0,0 +1,102 @@
import sys
from datetime import datetime
from pathlib import Path
import logging
import tomllib
from from_root import from_root
from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration, MailingServiceConfiguration, LanInfo, SeatingConfiguration, TicketInfo
logger = logging.getLogger(__name__.split(".")[-1])
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)
def get_database_configuration(self) -> DatabaseConfiguration:
try:
database_configuration = self._config["database"]
return DatabaseConfiguration(
db_user=database_configuration["db_user"],
db_password=database_configuration["db_password"],
db_host=database_configuration["db_host"],
db_port=database_configuration["db_port"],
db_name=database_configuration["db_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"]
)
except KeyError:
logger.fatal("Error loading LAN Info, exiting...")
sys.exit(1)
def get_seating_configuration(self) -> SeatingConfiguration:
try:
return SeatingConfiguration(
seats=self._config["seating"]
)
except KeyError:
logger.fatal("Error loading seating configuration, 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=self._config["tickets"][value]["price"],
description=self._config["tickets"][value]["description"],
additional_info=self._config["tickets"][value]["additional_info"],
is_default=self._config["tickets"][value]["is_default"]
) for value in self._config["tickets"]])
except KeyError as e:
logger.debug(e)
logger.fatal("Error loading seating configuration, exiting...")
sys.exit(1)
@property
def APP_VERSION(self) -> str:
return self._version
@property
def DEV_MODE_ACTIVE(self) -> bool:
return self._config["misc"]["dev_mode_active"]

View File

@ -0,0 +1,773 @@
import logging
from datetime import date, datetime
from typing import Optional
import aiomysql
from src.ez_lan_manager.types.CateringOrder import CateringOrder
from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory
from src.ez_lan_manager.types.CateringOrder import CateringMenuItemsWithAmount, CateringOrderStatus
from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration
from src.ez_lan_manager.types.News import News
from src.ez_lan_manager.types.Seat import Seat
from src.ez_lan_manager.types.Ticket import Ticket
from src.ez_lan_manager.types.Transaction import Transaction
from src.ez_lan_manager.types.User import User
logger = logging.getLogger(__name__.split(".")[-1])
class DuplicationError(Exception):
pass
class NoDatabaseConnectionError(Exception):
pass
class DatabaseService:
MAX_CONNECTION_RETRIES = 5
def __init__(self, database_config: DatabaseConfiguration) -> None:
self._database_config = database_config
self._connection_pool: Optional[aiomysql.Pool] = None
async def is_healthy(self) -> bool:
try:
async with self._connection_pool.acquire() as conn:
async with conn.cursor() as _:
return True
except aiomysql.OperationalError:
return False
except Exception as e:
logger.error(f"Failed to acquire a connection: {e}")
return False
async def init_db_pool(self) -> bool:
logger.info(
f"Connecting to database '{self._database_config.db_name}' on "
f"{self._database_config.db_user}@{self._database_config.db_host}:{self._database_config.db_port}"
)
try:
self._connection_pool = await aiomysql.create_pool(
host=self._database_config.db_host,
port=self._database_config.db_port,
user=self._database_config.db_user,
password=self._database_config.db_password,
db=self._database_config.db_name,
minsize=1,
maxsize=40
)
except aiomysql.OperationalError:
return False
return True
@staticmethod
def _map_db_result_to_user(data: tuple) -> User:
return User(
user_id=data[0],
user_name=data[1],
user_mail=data[2],
user_password=data[3],
user_first_name=data[4],
user_last_name=data[5],
user_birth_day=data[6],
is_active=bool(data[7]),
is_team_member=bool(data[8]),
is_admin=bool(data[9]),
created_at=data[10],
last_updated_at=data[11]
)
async def get_user_by_name(self, user_name: str) -> Optional[User]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
await cursor.execute("SELECT * FROM users WHERE user_name=%s", (user_name,))
result = await cursor.fetchone()
if not result:
return
return self._map_db_result_to_user(result)
async def get_user_by_id(self, user_id: int) -> Optional[User]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
await cursor.execute("SELECT * FROM users WHERE user_id=%s", (user_id,))
result = await cursor.fetchone()
if not result:
return
return self._map_db_result_to_user(result)
async def get_user_by_mail(self, user_mail: str) -> Optional[User]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
await cursor.execute("SELECT * FROM users WHERE user_mail=%s", (user_mail.lower(),))
result = await cursor.fetchone()
if not result:
return
return self._map_db_result_to_user(result)
async def create_user(self, user_name: str, user_mail: str, password_hash: str) -> User:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"INSERT INTO users (user_name, user_mail, user_password) "
"VALUES (%s, %s, %s)", (user_name, user_mail.lower(), password_hash)
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.create_user(user_name, user_mail, password_hash)
except aiomysql.IntegrityError as e:
logger.warning(f"Aborted duplication entry: {e}")
raise DuplicationError
return await self.get_user_by_name(user_name)
async def update_user(self, user: User) -> User:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"UPDATE users SET user_name=%s, user_mail=%s, user_password=%s, user_first_name=%s, user_last_name=%s, user_birth_date=%s, "
"is_active=%s, is_team_member=%s, is_admin=%s WHERE (user_id=%s)", (user.user_name, user.user_mail.lower(), user.user_password,
user.user_first_name, user.user_last_name, user.user_birth_day,
user.is_active, user.is_team_member, user.is_admin,
user.user_id)
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.update_user(user)
except aiomysql.IntegrityError as e:
logger.warning(f"Aborted duplication entry: {e}")
raise DuplicationError
return user
async def add_transaction(self, transaction: Transaction) -> Optional[Transaction]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"INSERT INTO transactions (user_id, value, is_debit, transaction_date, transaction_reference) "
"VALUES (%s, %s, %s, %s, %s)",
(transaction.user_id, transaction.value, transaction.is_debit, transaction.transaction_date, transaction.reference)
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.add_transaction(transaction)
except Exception as e:
logger.warning(f"Error adding Transaction: {e}")
return
return transaction
async def get_all_transactions_for_user(self, user_id: int) -> list[Transaction]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
transactions = []
try:
await cursor.execute("SELECT * FROM transactions WHERE user_id=%s", (user_id,))
await conn.commit()
result = await cursor.fetchall()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.get_all_transactions_for_user(user_id)
except aiomysql.Error as e:
logger.error(f"Error getting all transactions for user: {e}")
return []
for transaction_raw in result:
transactions.append(Transaction(
user_id=user_id,
value=int(transaction_raw[2]),
is_debit=bool(transaction_raw[3]),
transaction_date=transaction_raw[4],
reference=transaction_raw[5]
))
return transactions
async def add_news(self, news: News) -> None:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"INSERT INTO news (news_content, news_title, news_subtitle, news_author, news_date) "
"VALUES (%s, %s, %s, %s, %s)",
(news.content, news.title, news.subtitle, news.author.user_id, news.news_date)
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.add_news(news)
except Exception as e:
logger.warning(f"Error adding Transaction: {e}")
async def get_news(self, dt_start: date, dt_end: date) -> list[News]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
results = []
try:
await cursor.execute("SELECT * FROM news INNER JOIN users ON news.news_author = users.user_id WHERE news_date BETWEEN %s AND %s;", (dt_start, dt_end))
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
print(self._connection_pool)
raise NoDatabaseConnectionError
return await self.get_news(dt_start, dt_end)
except Exception as e:
logger.warning(f"Error fetching news: {e}")
return []
for news_raw in await cursor.fetchall():
user = self._map_db_result_to_user(news_raw[6:])
results.append(News(
news_id=news_raw[0],
title=news_raw[2],
subtitle=news_raw[3],
author=user,
content=news_raw[1],
news_date=news_raw[5]
))
return results
async def update_news(self, news: News) -> None:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"""
UPDATE news
SET news_content = %s,
news_title = %s,
news_subtitle = %s,
news_author = %s,
news_date = %s
WHERE news_id = %s
""",
(news.content, news.title, news.subtitle, news.author.user_id, news.news_date, news.news_id)
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.update_news(news)
except Exception as e:
logger.warning(f"Error updating news: {e}")
async def remove_news(self, news_id: int) -> None:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"DELETE FROM news WHERE news_id = %s",
(news_id,)
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.remove_news(news_id)
except Exception as e:
logger.warning(f"Error removing news with ID {news_id}: {e}")
async def get_tickets(self) -> list[Ticket]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
results = []
try:
await cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id;", ())
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.get_tickets()
except Exception as e:
logger.warning(f"Error fetching tickets: {e}")
return []
for ticket_raw in await cursor.fetchall():
user = self._map_db_result_to_user(ticket_raw[3:])
results.append(Ticket(
ticket_id=ticket_raw[0],
category=ticket_raw[1],
purchase_date=ticket_raw[3],
owner=user
))
return results
async def get_ticket_for_user(self, user_id: int) -> Optional[Ticket]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id WHERE user_id=%s;", (user_id, ))
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.get_ticket_for_user(user_id)
except Exception as e:
logger.warning(f"Error fetching ticket for user: {e}")
return
result = await cursor.fetchone()
if not result:
return
user = self._map_db_result_to_user(result[3:])
return Ticket(
ticket_id=result[0],
category=result[1],
purchase_date=result[3],
owner=user
)
async def generate_ticket_for_user(self, user_id: int, category: str) -> Optional[Ticket]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute("INSERT INTO tickets (ticket_category, user) VALUES (%s, %s)", (category, user_id))
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.generate_ticket_for_user(user_id, category)
except Exception as e:
logger.warning(f"Error generating ticket for user: {e}")
return
return await self.get_ticket_for_user(user_id)
async def change_ticket_owner(self, ticket_id: int, new_owner_id: int) -> bool:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute("UPDATE tickets SET user = %s WHERE ticket_id = %s;", (new_owner_id, ticket_id))
affected_rows = cursor.rowcount
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.change_ticket_owner(ticket_id, new_owner_id)
except Exception as e:
logger.warning(f"Error transferring ticket to user: {e}")
return False
return affected_rows > 0
async def delete_ticket(self, ticket_id: int) -> bool:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute("DELETE FROM tickets WHERE ticket_id = %s;", (ticket_id, ))
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.change_ticket_owner(ticket_id)
except Exception as e:
logger.warning(f"Error deleting ticket: {e}")
return False
return True
async def generate_fresh_seats_table(self, seats: list[tuple[str, str]]) -> None:
""" WARNING: THIS WILL DELETE ALL EXISTING DATA! DO NOT USE ON PRODUCTION DATABASE! """
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute("TRUNCATE seats;")
for seat in seats:
await cursor.execute("INSERT INTO seats (seat_id, seat_category) VALUES (%s, %s);", (seat[0], seat[1]))
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.generate_fresh_seats_table(seats)
except Exception as e:
logger.warning(f"Error generating fresh seats table: {e}")
return
async def get_seating_info(self) -> list[Seat]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
results = []
try:
await cursor.execute("SELECT seats.*, users.* FROM seats LEFT JOIN users ON seats.user = users.user_id;")
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.get_seating_info()
except Exception as e:
logger.warning(f"Error getting seats table: {e}")
return results
for seat_raw in await cursor.fetchall():
if seat_raw[3] is None: # Empty seat
results.append(Seat(seat_raw[0], bool(seat_raw[1]), seat_raw[2], None))
else:
user = self._map_db_result_to_user(seat_raw[4:])
results.append(Seat(seat_raw[0], bool(seat_raw[1]), seat_raw[2], user))
return results
async def seat_user(self, seat_id: str, user_id: int) -> bool:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute("UPDATE seats SET user = %s WHERE seat_id = %s;", (user_id, seat_id))
affected_rows = cursor.rowcount
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.seat_user(seat_id, user_id)
except Exception as e:
logger.warning(f"Error seating user: {e}")
return False
return affected_rows > 0
async def get_menu_items(self) -> list[CateringMenuItem]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
results = []
try:
await cursor.execute("SELECT * FROM catering_menu_items;")
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.get_menu_items()
except Exception as e:
logger.warning(f"Error fetching menu items: {e}")
return results
for menu_item_raw in await cursor.fetchall():
results.append(CateringMenuItem(
item_id=menu_item_raw[0],
name=menu_item_raw[1],
additional_info=menu_item_raw[2],
price=menu_item_raw[3],
category=CateringMenuItemCategory(menu_item_raw[4]),
is_disabled=bool(menu_item_raw[5])
))
return results
async def get_menu_item(self, menu_item_id) -> Optional[CateringMenuItem]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute("SELECT * FROM catering_menu_items WHERE catering_menu_item_id = %s;", (menu_item_id, ))
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.get_menu_item(menu_item_id)
except Exception as e:
logger.warning(f"Error fetching menu items: {e}")
return
raw_data = await cursor.fetchone()
if raw_data is None:
return
return CateringMenuItem(
item_id=raw_data[0],
name=raw_data[1],
additional_info=raw_data[2],
price=raw_data[3],
category=CateringMenuItemCategory(raw_data[4]),
is_disabled=bool(raw_data[5])
)
async def add_menu_item(self, name: str, info: str, price: int, category: CateringMenuItemCategory, is_disabled: bool = False) -> Optional[CateringMenuItem]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"INSERT INTO catering_menu_items (name, additional_info, price, category, is_disabled) VALUES (%s, %s, %s, %s, %s);",
(name, info, price, category.value, is_disabled)
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.add_menu_item(name, info, price, category, is_disabled)
except Exception as e:
logger.warning(f"Error adding menu item: {e}")
return
return CateringMenuItem(
item_id=cursor.lastrowid,
name=name,
additional_info=info,
price=price,
category=category,
is_disabled=is_disabled
)
async def delete_menu_item(self, menu_item_id: int) -> bool:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute("DELETE FROM catering_menu_items WHERE catering_menu_item_id = %s;", (menu_item_id,))
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.delete_menu_item(menu_item_id)
except Exception as e:
logger.warning(f"Error deleting menu item: {e}")
return False
return cursor.affected_rows > 0
async def update_menu_item(self, updated_item: CateringMenuItem) -> bool:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"UPDATE catering_menu_items SET name = %s, additional_info = %s, price = %s, category = %s, is_disabled = %s WHERE catering_menu_item_id = %s;",
(updated_item.name, updated_item.additional_info, updated_item.price, updated_item.category.value, updated_item.is_disabled, updated_item.item_id)
)
affected_rows = cursor.rowcount
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.update_menu_item(updated_item)
except Exception as e:
logger.warning(f"Error updating menu item: {e}")
return False
return affected_rows > 0
async def add_new_order(self, menu_items: CateringMenuItemsWithAmount, user_id: int, is_delivery: bool) -> Optional[CateringOrder]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
now = datetime.now()
try:
await cursor.execute(
"INSERT INTO orders (status, user, is_delivery, order_date) VALUES (%s, %s, %s, %s);",
(CateringOrderStatus.RECEIVED.value, user_id, is_delivery, now)
)
order_id = cursor.lastrowid
for menu_item, quantity in menu_items.items():
await cursor.execute(
"INSERT INTO order_catering_menu_item (order_id, catering_menu_item_id, quantity) VALUES (%s, %s, %s);",
(order_id, menu_item.item_id, quantity)
)
await conn.commit()
return CateringOrder(
order_id=order_id,
order_date=now,
status=CateringOrderStatus.RECEIVED,
items=menu_items,
customer=await self.get_user_by_id(user_id),
is_delivery=is_delivery
)
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.add_new_order(menu_items, user_id, is_delivery)
except Exception as e:
logger.warning(f"Error placing order: {e}")
return
async def change_order_status(self, order_id: int, status: CateringOrderStatus) -> bool:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"UPDATE orders SET status = %s WHERE order_id = %s;",
(status.value, order_id)
)
affected_rows = cursor.rowcount
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.change_order_status(order_id, status)
except Exception as e:
logger.warning(f"Error updating menu item: {e}")
return False
return affected_rows > 0
async def get_orders(self, user_id: Optional[int] = None, status: Optional[CateringOrderStatus] = None) -> list[CateringOrder]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
fetched_orders = []
query = "SELECT * FROM orders LEFT JOIN users ON orders.user = users.user_id"
if user_id is not None and status is None:
query += f" WHERE user = {user_id};"
elif status is not None and user_id is None:
query += f" WHERE status = '{status.value}';"
elif status is not None and user_id is not None:
query += f" WHERE user = {user_id} AND status = '{status.value}';"
else:
query += ";"
try:
await cursor.execute(query)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.get_orders(user_id, status)
except Exception as e:
logger.warning(f"Error getting orders: {e}")
return fetched_orders
for raw_order in await cursor.fetchall():
fetched_orders.append(
CateringOrder(
order_id=raw_order[0],
status=CateringOrderStatus(raw_order[1]),
customer=self._map_db_result_to_user(raw_order[5:]),
items=await self.get_menu_items_for_order(raw_order[0]),
is_delivery=bool(raw_order[4]),
order_date=raw_order[3],
)
)
return fetched_orders
async def get_menu_items_for_order(self, order_id: int) -> CateringMenuItemsWithAmount:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
result = {}
try:
await cursor.execute(
"SELECT * FROM order_catering_menu_item "
"LEFT JOIN catering_menu_items ON order_catering_menu_item.catering_menu_item_id = catering_menu_items.catering_menu_item_id "
"WHERE order_id = %s;",
(order_id, )
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.get_menu_items_for_order(order_id)
except Exception as e:
logger.warning(f"Error getting order items: {e}")
return result
for order_catering_menu_item_raw in await cursor.fetchall():
result[CateringMenuItem(
item_id=order_catering_menu_item_raw[1],
name=order_catering_menu_item_raw[4],
additional_info=order_catering_menu_item_raw[5],
price=order_catering_menu_item_raw[6],
category=CateringMenuItemCategory(order_catering_menu_item_raw[7]),
is_disabled=bool(order_catering_menu_item_raw[8])
)] = order_catering_menu_item_raw[2]
return result
async def set_user_profile_picture(self, user_id: int, picture_data: bytes) -> None:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"INSERT INTO user_profile_picture (user_id, picture) VALUES (%s, %s) ON DUPLICATE KEY UPDATE picture = VALUES(picture)",
(user_id, picture_data)
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.set_user_profile_picture(user_id, picture_data)
except Exception as e:
logger.warning(f"Error setting user profile picture: {e}")
async def get_user_profile_picture(self, user_id: int) -> Optional[bytes]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute("SELECT (picture) FROM user_profile_picture WHERE user_id = %s", (user_id, ))
await conn.commit()
r = await cursor.fetchone()
if r is None:
return
return r[0]
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.get_user_profile_picture(user_id)
except Exception as e:
logger.warning(f"Error setting user profile picture: {e}")
return None
async def get_all_users(self) -> list[User]:
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
results = []
try:
await cursor.execute("SELECT * FROM users;")
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.get_all_users()
except Exception as e:
logger.warning(f"Error getting all users: {e}")
return results
for user_raw in await cursor.fetchall():
results.append(self._map_db_result_to_user(user_raw))
return results
async def remove_profile_picture(self, user_id: int):
async with self._connection_pool.acquire() as conn:
async with conn.cursor(aiomysql.Cursor) as cursor:
try:
await cursor.execute(
"DELETE FROM user_profile_picture WHERE user_id = %s",
user_id
)
await conn.commit()
except aiomysql.InterfaceError:
pool_init_result = await self.init_db_pool()
if not pool_init_result:
raise NoDatabaseConnectionError
return await self.remove_profile_picture(user_id)
except Exception as e:
logger.warning(f"Error deleting user profile picture: {e}")

View File

@ -0,0 +1,37 @@
import logging
from email.message import EmailMessage
from asyncio import sleep
import aiosmtplib
from src.ez_lan_manager.services.ConfigurationService import ConfigurationService
logger = logging.getLogger(__name__.split(".")[-1])
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.")
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}")

View File

@ -0,0 +1,39 @@
import logging
from datetime import date
from typing import Optional
from src.ez_lan_manager.services.DatabaseService import DatabaseService
from src.ez_lan_manager.types.News import News
logger = logging.getLogger(__name__.split(".")[-1])
class NewsService:
def __init__(self, db_service: DatabaseService) -> None:
self._db_service = db_service
async def add_news(self, news: News) -> None:
if news.news_id is not None:
logger.warning("Can not add news with ID, ignoring...")
return
await self._db_service.add_news(news)
async def get_news(self, dt_start: Optional[date] = None, dt_end: Optional[date] = None, newest_first: bool = True) -> list[News]:
if not dt_end:
dt_end = date.today()
if not dt_start:
dt_start = date(1900, 1, 1)
fetched_news = await self._db_service.get_news(dt_start, dt_end)
return sorted(fetched_news, key=lambda news: news.news_date, reverse=newest_first)
async def update_news(self, news: News) -> None:
return await self._db_service.update_news(news)
async def delete_news(self, news_id: int) -> None:
return await self._db_service.remove_news(news_id)
async def get_latest_news(self) -> Optional[News]:
try:
all_news = await self.get_news(None, date.today())
return all_news[0]
except IndexError:
logger.debug("There are no news to fetch")

View File

@ -0,0 +1,63 @@
import logging
from typing import Optional
from src.ez_lan_manager.services.DatabaseService import DatabaseService
from src.ez_lan_manager.services.TicketingService import TicketingService
from src.ez_lan_manager.types.ConfigurationTypes import LanInfo, SeatingConfiguration
from src.ez_lan_manager.types.Seat import Seat
logger = logging.getLogger(__name__.split(".")[-1])
class NoTicketError(Exception):
pass
class SeatNotFoundError(Exception):
pass
class WrongCategoryError(Exception):
pass
class SeatAlreadyTakenError(Exception):
pass
class SeatingService:
def __init__(self, seating_configuration: SeatingConfiguration, lan_info: LanInfo, db_service: DatabaseService, ticketing_service: TicketingService) -> None:
self._seating_configuration = seating_configuration
self._lan_info = lan_info
self._db_service = db_service
self._ticketing_service = ticketing_service
async def get_seating(self) -> list[Seat]:
return await self._db_service.get_seating_info()
async def get_seat(self, seat_id: str, cached_data: Optional[list[Seat]] = None) -> Optional[Seat]:
all_seats = await self.get_seating() if not cached_data else cached_data
for seat in all_seats:
if seat.seat_id == seat_id:
return seat
async def get_user_seat(self, user_id: int) -> Optional[Seat]:
all_seats = await self.get_seating()
for seat in all_seats:
if seat.user and seat.user.user_id == user_id:
return seat
async def seat_user(self, user_id: int, seat_id: str) -> None:
user_ticket = await self._ticketing_service.get_user_ticket(user_id)
if not user_ticket:
raise NoTicketError
seat = await self.get_seat(seat_id)
if not seat:
raise SeatNotFoundError
if seat.category != user_ticket.category:
raise WrongCategoryError
if seat.user is not None:
raise SeatAlreadyTakenError
await self._db_service.seat_user(seat_id, user_id)
# ToDo: Make function that creates database table `seats` from config

View File

@ -0,0 +1,88 @@
import logging
from typing import Optional
from src.ez_lan_manager.services.AccountingService import AccountingService, InsufficientFundsError
from src.ez_lan_manager.services.DatabaseService import DatabaseService
from src.ez_lan_manager.types.ConfigurationTypes import TicketInfo
from src.ez_lan_manager.types.Ticket import Ticket
logger = logging.getLogger(__name__.split(".")[-1])
class TicketNotAvailableError(Exception):
def __init__(self, category: str):
self.category = category
class UserAlreadyHasTicketError(Exception):
pass
class TicketingService:
def __init__(self, ticket_infos: tuple[TicketInfo, ...], db_service: DatabaseService, accounting_service: AccountingService) -> None:
self._ticket_infos = ticket_infos
self._db_service = db_service
self._accounting_service = accounting_service
def get_ticket_info_by_category(self, category: str) -> Optional[TicketInfo]:
return next(filter(lambda t: t.category == category, self._ticket_infos), None)
def get_total_tickets(self) -> int:
return sum([t_i.total_tickets for t_i in self._ticket_infos])
async def get_available_tickets_for_category(self, category: str) -> int:
ticket_info = self.get_ticket_info_by_category(category)
if not ticket_info or ticket_info.total_tickets < 1:
return 0
result = ticket_info.total_tickets
all_tickets = await self._db_service.get_tickets()
for ticket in all_tickets:
if ticket.category == category:
result -= 1
return result
async def purchase_ticket(self, user_id: int, category: str) -> Ticket:
all_categories = [t_i.category for t_i in self._ticket_infos]
if category not in all_categories or (await self.get_available_tickets_for_category(category)) < 1:
raise TicketNotAvailableError(category)
user_balance = await self._accounting_service.get_balance(user_id)
ticket_info = self.get_ticket_info_by_category(category)
if not ticket_info:
raise TicketNotAvailableError(category)
if ticket_info.price > user_balance:
raise InsufficientFundsError
if await self.get_user_ticket(user_id):
raise UserAlreadyHasTicketError
if new_ticket := await self._db_service.generate_ticket_for_user(user_id, category):
await self._accounting_service.remove_balance(
user_id,
ticket_info.price,
f"TICKET {new_ticket.ticket_id}"
)
logger.debug(f"User {user_id} purchased ticket {new_ticket.ticket_id}")
return new_ticket
raise RuntimeError("An unknown error occurred while purchasing ticket")
async def refund_ticket(self, user_id: int) -> bool:
user_ticket = await self.get_user_ticket(user_id)
if not user_ticket:
return False
ticket_info = self.get_ticket_info_by_category(user_ticket.category)
if await self._db_service.delete_ticket(user_ticket.ticket_id):
await self._accounting_service.add_balance(user_id, ticket_info.price, f"TICKET REFUND {user_ticket.ticket_id}")
logger.debug(f"User {user_id} refunded ticket {user_ticket.ticket_id}")
return True
return False
async def transfer_ticket(self, ticket_id: int, user_id: int) -> bool:
return await self._db_service.change_ticket_owner(ticket_id, user_id)
async def get_user_ticket(self, user_id: int) -> Optional[Ticket]:
return await self._db_service.get_ticket_for_user(user_id)

View File

@ -0,0 +1,69 @@
from hashlib import sha256
from typing import Union, Optional
from string import ascii_letters, digits
from src.ez_lan_manager.services.DatabaseService import DatabaseService
from src.ez_lan_manager.types.User import User
class NameNotAllowedError(Exception):
def __init__(self, disallowed_char: str) -> None:
self.disallowed_char = disallowed_char
class UserService:
ALLOWED_USER_NAME_SYMBOLS = ascii_letters + digits + "!#$%&*+,-./:;<=>?[]^_{|}~"
MAX_USERNAME_LENGTH = 14
def __init__(self, db_service: DatabaseService) -> None:
self._db_service = db_service
async def get_all_users(self) -> list[User]:
return await self._db_service.get_all_users()
async def get_user(self, accessor: Optional[Union[str, int]]) -> Optional[User]:
if accessor is None:
return
if isinstance(accessor, int):
return await self._db_service.get_user_by_id(accessor)
accessor = accessor.lower()
if "@" in accessor:
return await self._db_service.get_user_by_mail(accessor)
return await self._db_service.get_user_by_name(accessor)
async def set_profile_picture(self, user_id: int, picture: bytes) -> None:
await self._db_service.set_user_profile_picture(user_id, picture)
async def remove_profile_picture(self, user_id: int) -> None:
await self._db_service.remove_profile_picture(user_id)
async def get_profile_picture(self, user_id: int) -> bytes:
return await self._db_service.get_user_profile_picture(user_id)
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)
user_name = user_name.lower()
hashed_pw = sha256(password_clear_text.encode(encoding="utf-8")).hexdigest()
return await self._db_service.create_user(user_name, user_mail, hashed_pw)
async def update_user(self, user: User) -> User:
disallowed_char = self._check_for_disallowed_char(user.user_name)
if disallowed_char:
raise NameNotAllowedError(disallowed_char)
user.user_name = user.user_name.lower()
return await self._db_service.update_user(user)
async def is_login_valid(self, user_name_or_mail: str, password_clear_text: str) -> bool:
user = await self.get_user(user_name_or_mail)
if not user:
return False
return user.user_password == sha256(password_clear_text.encode(encoding="utf-8")).hexdigest()
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

View File

View File

@ -0,0 +1,28 @@
from dataclasses import dataclass
from enum import StrEnum
from typing import Self
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"
@dataclass(frozen=False)
class CateringMenuItem:
item_id: int
name: str
price: int
category: CateringMenuItemCategory
additional_info: str = str()
is_disabled: bool = False
def __hash__(self) -> int:
return hash(str(self.item_id) + self.name)

View File

@ -0,0 +1,33 @@
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory
from src.ez_lan_manager.types.User import User
CateringMenuItemsWithAmount = dict[CateringMenuItem, int]
class CateringOrderStatus(StrEnum):
RECEIVED = "RECEIVED"
DELAYED = "DELAYED"
READY_FOR_PICKUP = "READY_FOR_PICKUP"
EN_ROUTE = "EN_ROUTE"
COMPLETED = "COMPLETED"
CANCELED = "CANCELED"
@dataclass(frozen=True)
class CateringOrder:
order_id: int
order_date: datetime
status: CateringOrderStatus
items: CateringMenuItemsWithAmount
customer: User
is_delivery: bool = True
@property
def price(self) -> int:
total = 0
for item, amount in self.items.items():
total += (item.price * amount)
return total

View File

@ -0,0 +1,44 @@
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
class NoSuchCategoryError(Exception):
pass
@dataclass(frozen=True)
class DatabaseConfiguration:
db_user: str
db_password: str
db_host: str
db_port: int
db_name: str
@dataclass(frozen=True)
class TicketInfo:
category: str
total_tickets: int
price: int
description: str
additional_info: str
is_default: bool
@dataclass(frozen=True)
class MailingServiceConfiguration:
smtp_server: str
smtp_port: int
sender: str
username: str
password: str
@dataclass(frozen=True)
class LanInfo:
name: str
iteration: str
date_from: datetime
date_till: datetime
organizer_mail: str
@dataclass(frozen=True)
class SeatingConfiguration:
seats: dict[str, str]

View File

@ -0,0 +1,15 @@
from dataclasses import dataclass
from datetime import date
from typing import Optional
from src.ez_lan_manager.types.User import User
@dataclass(frozen=True)
class News:
news_id: Optional[int]
title: str
subtitle: str
content: str
author: User
news_date: date

View File

@ -0,0 +1,12 @@
from dataclasses import dataclass
from typing import Optional
from src.ez_lan_manager.types.User import User
@dataclass(frozen=True)
class Seat:
seat_id: str
is_blocked: bool
category: str
user: Optional[User]

View File

@ -0,0 +1,36 @@
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Optional
logger = logging.getLogger(__name__.split(".")[-1])
# ToDo: Persist between reloads: https://rio.dev/docs/howto/persistent-settings
# Note for ToDo: rio.UserSettings are saved LOCALLY, do not just read a user_id here!
@dataclass(frozen=False)
class SessionStorage:
_user_id: Optional[int] = None # DEBUG: Put user ID here to skip login
_is_team_member: bool = False
_notification_callbacks: dict[str, Callable] = field(default_factory=dict)
async def clear(self) -> None:
await self.set_user_id_and_team_member_flag(None, False)
def subscribe_to_logged_in_or_out_event(self, component_id: str, callback: Callable) -> None:
self._notification_callbacks[component_id] = callback
@property
def user_id(self) -> Optional[int]:
return self._user_id
@property
def is_team_member(self) -> bool:
return self._is_team_member
async def set_user_id_and_team_member_flag(self, user_id: Optional[int], is_team_member: bool) -> None:
self._user_id = user_id
self._is_team_member = is_team_member
for component_id, callback in self._notification_callbacks.items():
logger.debug(f"Calling logged in callback from {component_id}")
await callback()

View File

@ -0,0 +1,13 @@
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from src.ez_lan_manager.types.User import User
@dataclass(frozen=True)
class Ticket:
ticket_id: int
category: str
purchase_date: datetime
owner: Optional[User] = None

View File

@ -0,0 +1,11 @@
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class Transaction:
user_id: int
value: int
is_debit: bool
reference: str
transaction_date: datetime

View File

@ -0,0 +1,22 @@
from dataclasses import dataclass
from datetime import date, datetime
from typing import Optional
@dataclass
class User:
user_id: int
user_name: str
user_mail: str
user_password: str
user_first_name: Optional[str]
user_last_name: Optional[str]
user_birth_day: Optional[date]
is_active: bool
is_team_member: bool
is_admin: bool
created_at: datetime
last_updated_at: datetime
def __hash__(self) -> int:
return hash(f"{self.user_id}{self.user_name}{self.user_mail}")

View File