Compare commits

...

2 Commits

Author SHA1 Message Date
40f8bc1049 Merge pull request 'bugfix/rounding-taxes' (#10) from bugfix/rounding-taxes into main
Reviewed-on: Vereins-IT/ez-lan-manager#10
Reviewed-by: David Rodenkirchen <typhus@ezgg-ev.de>
2025-02-07 22:31:50 +00:00
tcprod
a419ee8885 Replace float with Decimal for price calculations
Fix Decimal precision issue

Fix Decimal precision issue

Fix Decimal precision issue

Fix old prices for tickets

Fix Decimal precision issue
2025-02-07 23:20:57 +01:00
24 changed files with 342 additions and 235 deletions

View File

@ -28,14 +28,14 @@
[tickets]
[tickets."NORMAL"]
total_tickets=30
price=2500
price="25.00"
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
price="35.00"
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

View File

@ -28,7 +28,7 @@ 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,
`price` varchar(45) NOT NULL DEFAULT '0',
`category` varchar(80) NOT NULL,
`is_disabled` tinyint(4) DEFAULT 0,
PRIMARY KEY (`catering_menu_item_id`)

View File

@ -1,4 +1,5 @@
from typing import Callable
from decimal import Decimal
import rio
from rio import Component, Row, Text, IconButton, TextStyle
@ -9,7 +10,7 @@ MAX_LEN = 24
class CateringCartItem(Component):
article_name: str
article_price: int
article_price: Decimal
article_id: int
list_id: int
remove_item_cb: Callable
@ -24,7 +25,7 @@ class CateringCartItem(Component):
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)),
Text(AccountingService.make_euro_string_from_decimal(self.article_price), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)),
IconButton(icon="material/close", min_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

@ -1,3 +1,4 @@
from decimal import Decimal
from typing import Callable
import rio
@ -7,9 +8,10 @@ from src.ez_lan_manager import AccountingService
MAX_LEN = 24
class CateringSelectionItem(Component):
article_name: str
article_price: int
article_price: Decimal
article_id: int
on_add_callback: Callable
is_sensitive: bool
@ -33,15 +35,16 @@ class CateringSelectionItem(Component):
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)),
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_decimal(self.article_price),
style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)),
IconButton(
icon="material/add",
min_size=2,
@ -53,7 +56,10 @@ class CateringSelectionItem(Component):
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)),
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,

View File

@ -1,4 +1,5 @@
from datetime import datetime
from decimal import Decimal
from typing import Optional
from rio import Component, Column, NumberInput, ThemeContextSwitcher, TextInput, Row, Button, EventHandler
@ -18,7 +19,7 @@ class NewTransactionForm(Component):
self.new_transaction_cb,
Transaction(
user_id=self.user.user_id,
value=round(self.input_value * 100),
value=Decimal(str(self.input_value)),
is_debit=True,
reference=self.input_reason,
transaction_date=datetime.now()
@ -30,7 +31,7 @@ class NewTransactionForm(Component):
self.new_transaction_cb,
Transaction(
user_id=self.user.user_id,
value=round(self.input_value * 100),
value=Decimal(str(self.input_value)),
is_debit=False,
reference=self.input_reason,
transaction_date=datetime.now()

View File

@ -1,3 +1,4 @@
from decimal import Decimal
from typing import Optional, Callable
from rio import Component, Column, Text, TextStyle, Button, Spacer
@ -9,23 +10,30 @@ class SeatingPlanInfoBox(Component):
is_booking_blocked: bool
seat_id: Optional[str] = None
seat_occupant: Optional[str] = None
seat_price: int = 0
seat_price: Decimal = Decimal("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)
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"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"),
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"),
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",

View File

@ -1,4 +1,5 @@
from asyncio import sleep, create_task
from decimal import Decimal
import rio
from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer, Popup, Table
@ -96,7 +97,7 @@ class ShoppingCartAndOrders(Component):
{
"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)],
"Preis": [AccountingService.make_euro_string_from_decimal(item.price) for item in order.items.keys()] + [AccountingService.make_euro_string_from_decimal(order.price)],
},
show_row_numbers=False
)
@ -158,7 +159,7 @@ class ShoppingCartAndOrders(Component):
),
Row(
Text(
text=f"Preis: {AccountingService.make_euro_string_from_int(sum(cart_item.price for cart_item in cart))}",
text=f"Preis: {AccountingService.make_euro_string_from_decimal(sum((cart_item.price for cart_item in cart), Decimal(0)))}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=0.8

View File

@ -1,5 +1,6 @@
from functools import partial
from typing import Callable, Optional
from decimal import Decimal
import rio
from rio import Component, Card, Column, Text, Row, Button, TextStyle, ProgressBar, event, Spacer
@ -12,7 +13,7 @@ from src.ez_lan_manager.types.Ticket import Ticket
class TicketBuyCard(Component):
description: str
additional_info: str
price: int
price: Decimal
category: str
pressed_cb: Callable
is_enabled: bool
@ -67,7 +68,7 @@ class TicketBuyCard(Component):
margin_right=1
),
Row(
Text(f"{AccountingService.make_euro_string_from_int(self.price)}", margin_left=1, margin_top=1, grow_x=True),
Text(f"{AccountingService.make_euro_string_from_decimal(self.price)}", margin_left=1, margin_top=1, grow_x=True),
Button(
Text("Kaufen", align_x=0.5, margin=0.4),
margin_right=1,

View File

@ -1,5 +1,6 @@
from random import choice
from typing import Optional
from decimal import Decimal
from rio import Component, TextStyle, Color, Button, Text, Rectangle, Column, Row, Spacer, Link, event, EventHandler
@ -23,26 +24,27 @@ class StatusButton(Component):
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
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_balance: Optional[Decimal] = Decimal("0")
user_ticket: Optional[Ticket] = None
user_seat: Optional[Seat] = None
@ -80,8 +82,10 @@ class UserInfoBox(Component):
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"),
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),
@ -91,7 +95,9 @@ class UserInfoBox(Component):
grow_y=False
),
UserInfoBoxButton("Profil bearbeiten", "./edit-profile"),
UserInfoBoxButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_int(self.user_balance)}", "./account"),
UserInfoBoxButton(
f"Guthaben: {self.session[AccountingService].make_euro_string_from_decimal(self.user_balance)}",
"./account"),
Button(
content=Text("Ausloggen", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.6)),
shape="rectangle",

View File

@ -1,6 +1,7 @@
# USE THIS ON AN EMPTY DATABASE TO GENERATE DEMO DATA
import asyncio
from datetime import date
from decimal import Decimal
import sys
@ -9,13 +10,14 @@ 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
{"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()
@ -31,112 +33,143 @@ async def run() -> None:
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"])
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")
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, Decimal("1000.00"), "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")
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, Decimal("1000.00"), "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 = 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, Decimal("1000.00"), "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 = 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, Decimal("1000.00"), "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)
await catering_service.add_menu_item("Schnitzel Wiener Art", "mit Pommes", Decimal("10.00"),
CateringMenuItemCategory.MAIN_COURSE)
await catering_service.add_menu_item("Jäger Schnitzel mit Champignonrahm Sauce", "mit Pommes", Decimal("11.50"),
CateringMenuItemCategory.MAIN_COURSE)
await catering_service.add_menu_item("Tortellini in Käsesauce mit Fleischfüllung", "", Decimal("10.50"),
CateringMenuItemCategory.MAIN_COURSE)
await catering_service.add_menu_item("Tortellini in Käsesauce ohne Fleischfüllung", "Vegetarisch", Decimal("10.50"),
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)
await catering_service.add_menu_item("Käse Schinken Wrap", "", Decimal("5.00"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Puten Paprika Wrap", "", Decimal("7.00"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Tomate Mozzarella Wrap", "", Decimal("6.00"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Portion Pommes", "", Decimal("4.00"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Rinds-Currywurst", "", Decimal("4.50"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Rinds-Currywurst mit Pommes", "", Decimal("6.50"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Nudelsalat", "", Decimal("4.50"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Nudelsalat mit Bockwurst", "", Decimal("6.00"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Kartoffelsalat", "", Decimal("4.50"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Kartoffelsalat mit Bockwurst", "", Decimal("6.00"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Sandwichtoast - Schinken", "", Decimal("1.80"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Sandwichtoast - Käse", "", Decimal("1.80"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Sandwichtoast - Schinken/Käse", "", Decimal("2.10"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Sandwichtoast - Salami", "", Decimal("1.80"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Sandwichtoast - Salami/Käse", "", Decimal("2.10"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Chips - Western Style", "", Decimal("1.30"), CateringMenuItemCategory.SNACK)
await catering_service.add_menu_item("Nachos - Salted", "", Decimal("1.30"), 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)
await catering_service.add_menu_item("Panna Cotta mit Erdbeersauce", "", Decimal("7.00"), CateringMenuItemCategory.DESSERT)
await catering_service.add_menu_item("Panna Cotta mit Blaubeersauce", "", Decimal("7.00"), CateringMenuItemCategory.DESSERT)
await catering_service.add_menu_item("Mousse au Chocolat", "", Decimal("7.00"), 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)
await catering_service.add_menu_item("Fruit Loops", "", Decimal("1.50"), CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Smacks", "", Decimal("1.50"), CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Knuspermüsli", "Schoko", Decimal("2.00"), CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Cini Minis", "", Decimal("2.50"), CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Schinken", "mit Margarine", Decimal("1.20"),
CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Käse", "mit Margarine", Decimal("1.20"),
CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Schinken/Käse", "mit Margarine", Decimal("1.40"),
CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Salami", "mit Margarine", Decimal("1.20"),
CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Salami/Käse", "mit Margarine", Decimal("1.40"),
CateringMenuItemCategory.BREAKFAST)
await catering_service.add_menu_item("Brötchen - Nutella", "mit Margarine", Decimal("1.20"),
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)
await catering_service.add_menu_item("Wasser - Still", "1L Flasche", Decimal("2.00"),
CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Wasser - Medium", "1L Flasche", Decimal("2.00"),
CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Wasser - Spritzig", "1L Flasche", Decimal("2.00"),
CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Coca-Cola", "1L Flasche", Decimal("2.00"),
CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Coca-Cola Zero", "1L Flasche", Decimal("2.00"),
CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Fanta", "1L Flasche", Decimal("2.00"),
CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Sprite", "1L Flasche", Decimal("2.00"),
CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Spezi", "von Paulaner, 0,5L Flasche", Decimal("1.50"),
CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Red Bull", "", Decimal("2.00"), CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC)
await catering_service.add_menu_item("Energy", "Hausmarke", Decimal("1.50"),
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)
await catering_service.add_menu_item("Pils", "0,33L Flasche", Decimal("1.90"), CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
await catering_service.add_menu_item("Radler", "0,33L Flasche", Decimal("1.90"),
CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
await catering_service.add_menu_item("Diesel", "0,33L Flasche", Decimal("1.90"),
CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
await catering_service.add_menu_item("Apfelwein Pur", "0,33L Flasche", Decimal("1.90"),
CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
await catering_service.add_menu_item("Apfelwein Sauer", "0,33L Flasche", Decimal("1.90"),
CateringMenuItemCategory.BEVERAGE_ALCOHOLIC)
await catering_service.add_menu_item("Apfelwein Cola", "0,33L Flasche", Decimal("1.90"),
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)
await catering_service.add_menu_item("Vodka Energy", "", Decimal("4.00"), CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Vodka O-Saft", "", Decimal("4.00"), CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Whiskey Cola", "mit Bourbon", Decimal("4.00"),
CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Jägermeister Energy", "", Decimal("4.00"), CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Sex on the Beach", "", Decimal("5.50"), CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Long Island Ice Tea", "", Decimal("5.50"), CateringMenuItemCategory.BEVERAGE_COCKTAIL)
await catering_service.add_menu_item("Caipirinha", "", Decimal("5.50"), 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)
await catering_service.add_menu_item("Jägermeister", "", Decimal("2.00"), CateringMenuItemCategory.BEVERAGE_SHOT)
await catering_service.add_menu_item("Tequila", "", Decimal("2.00"), CateringMenuItemCategory.BEVERAGE_SHOT)
await catering_service.add_menu_item("PfEZzi", "Getunter Pfefferminz-Schnaps", Decimal("1.99"),
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)
await catering_service.add_menu_item("Zigaretten", "Elixyr", Decimal("8.00"), CateringMenuItemCategory.NON_FOOD)
await catering_service.add_menu_item("Mentholfilter", "passend für Elixyr", Decimal("1.20"),
CateringMenuItemCategory.NON_FOOD)
if not input("Generate default new post? (Y/n): ").lower() == "n":
loops = 0
@ -154,11 +187,14 @@ async def run() -> None:
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)",
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

@ -159,7 +159,7 @@ class AccountPage(Component):
align_x=0
),
Text(
f"{'-' if transaction.is_debit else '+'}{AccountingService.make_euro_string_from_int(transaction.value)}",
f"{'-' if transaction.is_debit else '+'}{AccountingService.make_euro_string_from_decimal(transaction.value)}",
style=TextStyle(
fill=self.session.theme.danger_color if transaction.is_debit else self.session.theme.success_color,
font_size=0.8
@ -175,7 +175,7 @@ class AccountPage(Component):
return Column(
MainViewContentBox(
content=Text(
f"Kontostand: {AccountingService.make_euro_string_from_int(self.balance)}",
f"Kontostand: {AccountingService.make_euro_string_from_decimal(self.balance)}",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2

View File

@ -13,6 +13,7 @@ from src.ez_lan_manager.types.Seat import Seat
logger = logging.getLogger(__name__.split(".")[-1])
class CateringOrderInfoPopup(Component):
order: Optional[CateringOrder] = None
close_cb: Optional[Callable] = None
@ -20,17 +21,18 @@ class CateringOrderInfoPopup(Component):
def build(self) -> Component:
if not self.order:
return Card(
content=Text(""),
margin=1,
color=self.session.theme.hud_color,
min_width=40,
min_height=40,
on_press=self.close_cb
)
content=Text(""),
margin=1,
color=self.session.theme.hud_color,
min_width=40,
min_height=40,
on_press=self.close_cb
)
rows = []
is_contrast_line = True
for item, amount in self.order.items.items():
style = TextStyle(fill=self.session.theme.secondary_color if is_contrast_line else self.session.theme.neutral_color)
style = TextStyle(
fill=self.session.theme.secondary_color if is_contrast_line else self.session.theme.neutral_color)
is_contrast_line = not is_contrast_line
rows.append(
Row(
@ -44,7 +46,8 @@ class CateringOrderInfoPopup(Component):
Text(f"Bestellung {self.order.order_id}", style=TextStyle(font_size=1.2), margin_bottom=1),
*rows,
Spacer(),
Row(Text("Gesamtpreis:"), Spacer(), Text(self.session[AccountingService].make_euro_string_from_int(self.order.price)))
Row(Text("Gesamtpreis:"), Spacer(),
Text(self.session[AccountingService].make_euro_string_from_decimal(self.order.price)))
),
margin=1,
color=self.session.theme.hud_color,
@ -56,11 +59,13 @@ class CateringOrderInfoPopup(Component):
colorize_on_hover=False
)
@dataclass
class CateringOrderWithSeat:
catering_order: CateringOrder
seat: Optional[Seat]
class ManageCateringPage(Component):
all_orders: list[CateringOrderWithSeat] = field(default_factory=list)
last_updated: Optional[datetime] = None
@ -73,7 +78,6 @@ class ManageCateringPage(Component):
self.all_orders = await self.populate_seating(await self.session[CateringService].get_orders())
self.last_updated = datetime.now()
@event.periodic(30)
async def update_orders(self) -> None:
polled_orders = await self.session[CateringService].get_orders()
@ -88,12 +92,16 @@ class ManageCateringPage(Component):
return result
def get_all_pending_orders(self) -> list[CateringOrderWithSeat]:
filtered_list = list(filter(lambda o: o.catering_order.status != CateringOrderStatus.COMPLETED and o.catering_order.status != CateringOrderStatus.CANCELED, self.all_orders))
filtered_list = list(filter(lambda
o: o.catering_order.status != CateringOrderStatus.COMPLETED and o.catering_order.status != CateringOrderStatus.CANCELED,
self.all_orders))
sorted_list = sorted(filtered_list, key=lambda o: o.catering_order.order_date)
return sorted_list
def get_all_completed_orders(self) -> list[CateringOrderWithSeat]:
filtered_list = list(filter(lambda o: o.catering_order.status == CateringOrderStatus.COMPLETED or o.catering_order.status == CateringOrderStatus.CANCELED, self.all_orders))
filtered_list = list(filter(lambda
o: o.catering_order.status == CateringOrderStatus.COMPLETED or o.catering_order.status == CateringOrderStatus.CANCELED,
self.all_orders))
sorted_list = sorted(filtered_list, key=lambda o: o.catering_order.order_date)
return sorted_list
@ -154,7 +162,8 @@ class ManageCateringPage(Component):
margin_bottom=1,
on_press=self.update_orders
),
*[CateringManagementOrderDisplay(v.catering_order, v.seat, self.order_clicked) for v in self.get_all_pending_orders()],
*[CateringManagementOrderDisplay(v.catering_order, v.seat, self.order_clicked) for v in
self.get_all_pending_orders()],
)
),
MainViewContentBox(
@ -169,7 +178,8 @@ class ManageCateringPage(Component):
margin_bottom=0.2,
align_x=0.5
),
*[CateringManagementOrderDisplay(v.catering_order, v.seat, self.order_clicked) for v in self.get_all_completed_orders()],
*[CateringManagementOrderDisplay(v.catering_order, v.seat, self.order_clicked) for v in
self.get_all_completed_orders()],
)
),
Spacer()

View File

@ -17,6 +17,7 @@ from src.ez_lan_manager.types.User import User
logger = logging.getLogger(__name__.split(".")[-1])
class ClickableGridContent(Component):
text: str = ""
is_hovered: bool = False
@ -36,7 +37,8 @@ class ClickableGridContent(Component):
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),
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,
@ -47,6 +49,7 @@ class ClickableGridContent(Component):
on_press=self.on_mouse_click
)
class ManageUsersPage(Component):
selected_user: Optional[User] = None
all_users: Optional[list] = None
@ -66,13 +69,15 @@ class ManageUsersPage(Component):
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)
self.user_account_balance = AccountingService.make_euro_string_from_decimal(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))
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
@ -84,7 +89,7 @@ class ManageUsersPage(Component):
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"{AccountingService.make_euro_string_from_decimal(transaction.value)} "
f"with reference '{transaction.reference}'")
if transaction.is_debit:
@ -108,7 +113,6 @@ class ManageUsersPage(Component):
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(

View File

@ -1,5 +1,6 @@
import logging
from asyncio import sleep
from decimal import Decimal
from typing import Optional
from rio import Text, Column, TextStyle, Component, event, PressEvent, ProgressCircle
@ -21,7 +22,7 @@ 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_price: Decimal = Decimal("0")
current_seat_is_blocked: bool = False
user: Optional[User] = None
show_info_box: bool = True
@ -54,7 +55,7 @@ class SeatingPlanPage(Component):
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
price = Decimal("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

View File

@ -1,15 +1,18 @@
import logging
from collections.abc import Callable
from datetime import datetime
from decimal import Decimal, ROUND_DOWN
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
@ -19,7 +22,7 @@ class AccountingService:
""" 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:
async def add_balance(self, user_id: int, balance_to_add: Decimal, reference: str) -> Decimal:
await self._db_service.add_transaction(Transaction(
user_id=user_id,
value=balance_to_add,
@ -27,12 +30,12 @@ class AccountingService:
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}")
logger.debug(f"Added balance of {self.make_euro_string_from_decimal(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:
async def remove_balance(self, user_id: int, balance_to_remove: Decimal, reference: str) -> Decimal:
current_balance = await self.get_balance(user_id)
if (current_balance - balance_to_remove) < 0:
raise InsufficientFundsError
@ -43,13 +46,14 @@ class AccountingService:
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}")
logger.debug(
f"Removed balance of {self.make_euro_string_from_decimal(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
async def get_balance(self, user_id: int) -> Decimal:
balance_buffer = Decimal("0")
for transaction in await self._db_service.get_all_transactions_for_user(user_id):
if transaction.is_debit:
balance_buffer -= transaction.value
@ -61,23 +65,9 @@ class AccountingService:
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
def make_euro_string_from_decimal(euros: Decimal) -> str:
"""
Internally, all money values are euros as decimal. Only when showing them to the user we generate a string.
"""
rounded_decimal = str(euros.quantize(Decimal(".01"), rounding=ROUND_DOWN))
return f"{rounded_decimal}"

View File

@ -1,4 +1,5 @@
import logging
from decimal import Decimal
from enum import Enum
from typing import Optional
@ -10,11 +11,13 @@ from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, Catering
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
@ -30,7 +33,8 @@ class CateringService:
# ORDERS
async def place_order(self, menu_items: CateringMenuItemsWithAmount, user_id: int, is_delivery: bool = True) -> CateringOrder:
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)
@ -39,14 +43,15 @@ class CateringService:
if not user:
raise CateringError("User does not exist")
total_price = sum([item.price * quantity for item, quantity in menu_items.items()])
total_price = sum([item.price * quantity for item, quantity in menu_items.items()], Decimal(0))
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)}")
logger.info(
f"User '{order.customer.user_name}' (ID:{order.customer.user_id}) ordered from catering for {self._accounting_service.make_euro_string_from_decimal(total_price)}")
# await self.cancel_order(order) # ToDo: Check if commented out before commit. Un-comment to auto-cancel every placed order
return order
@ -68,7 +73,8 @@ class CateringService:
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}")
await self._accounting_service.add_balance(order.customer.user_id, order.price,
f"CATERING REFUND - {order.order_id}")
return True
return False
@ -86,7 +92,8 @@ class CateringService:
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:
async def add_menu_item(self, name: str, info: str, price: Decimal, 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.")
@ -134,3 +141,4 @@ class CateringService:
return self.cached_cart[user_id]
except KeyError:
return []

View File

@ -1,15 +1,18 @@
import sys
from datetime import datetime
from decimal import Decimal
from pathlib import Path
import logging
import tomllib
from from_root import from_root
from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration, MailingServiceConfiguration, LanInfo, SeatingConfiguration, TicketInfo
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:
@ -40,7 +43,6 @@ class ConfigurationService:
logger.fatal("Error loading DatabaseConfiguration, exiting...")
sys.exit(1)
def get_mailing_service_configuration(self) -> MailingServiceConfiguration:
try:
mailing_configuration = self._config["mailing"]
@ -83,7 +85,7 @@ class ConfigurationService:
return tuple([TicketInfo(
category=value,
total_tickets=self._config["tickets"][value]["total_tickets"],
price=self._config["tickets"][value]["price"],
price=Decimal(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"]

View File

@ -2,6 +2,7 @@ import logging
from datetime import date, datetime
from typing import Optional
from decimal import Decimal
import aiomysql
@ -17,14 +18,18 @@ 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
@ -85,7 +90,6 @@ class DatabaseService:
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:
@ -110,7 +114,7 @@ class DatabaseService:
try:
await cursor.execute(
"INSERT INTO users (user_name, user_mail, user_password) "
"VALUES (%s, %s, %s)", (user_name, user_mail.lower(), password_hash)
"VALUES (%s, %s, %s)", (user_name, user_mail.lower(), password_hash)
)
await conn.commit()
except aiomysql.InterfaceError:
@ -123,19 +127,19 @@ class DatabaseService:
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)
"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:
@ -155,7 +159,8 @@ class DatabaseService:
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)
(transaction.user_id, transaction.value, transaction.is_debit, transaction.transaction_date,
transaction.reference)
)
await conn.commit()
except aiomysql.InterfaceError:
@ -189,14 +194,13 @@ class DatabaseService:
for transaction_raw in result:
transactions.append(Transaction(
user_id=user_id,
value=int(transaction_raw[2]),
value=Decimal(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:
@ -215,13 +219,15 @@ class DatabaseService:
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 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()
@ -315,12 +321,13 @@ class DatabaseService:
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 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()
@ -347,7 +354,8 @@ class DatabaseService:
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 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()
@ -360,12 +368,12 @@ class DatabaseService:
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))
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:
@ -382,7 +390,7 @@ class DatabaseService:
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 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()
@ -401,7 +409,8 @@ class DatabaseService:
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 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()
@ -417,7 +426,8 @@ class DatabaseService:
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 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()
@ -475,7 +485,7 @@ class DatabaseService:
item_id=menu_item_raw[0],
name=menu_item_raw[1],
additional_info=menu_item_raw[2],
price=menu_item_raw[3],
price=Decimal(menu_item_raw[3]),
category=CateringMenuItemCategory(menu_item_raw[4]),
is_disabled=bool(menu_item_raw[5])
))
@ -486,7 +496,8 @@ class DatabaseService:
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 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()
@ -501,20 +512,22 @@ class DatabaseService:
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])
)
item_id=raw_data[0],
name=raw_data[1],
additional_info=raw_data[2],
price=Decimal(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 def add_menu_item(self, name: str, info: str, price: Decimal, 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);",
"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()
@ -540,7 +553,8 @@ class DatabaseService:
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 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()
@ -557,8 +571,10 @@ class DatabaseService:
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)
"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()
@ -584,7 +600,8 @@ class DatabaseService:
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);",
"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()
@ -673,7 +690,7 @@ class DatabaseService:
"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, )
(order_id,)
)
await conn.commit()
except aiomysql.InterfaceError:
@ -690,7 +707,7 @@ class DatabaseService:
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],
price=Decimal(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]
@ -718,7 +735,7 @@ class DatabaseService:
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 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:

View File

@ -8,15 +8,19 @@ 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:
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
@ -75,7 +79,8 @@ class TicketingService:
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}")
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

View File

@ -1,4 +1,5 @@
from dataclasses import dataclass
from decimal import Decimal
from enum import StrEnum
from typing import Self
@ -19,7 +20,7 @@ class CateringMenuItemCategory(StrEnum):
class CateringMenuItem:
item_id: int
name: str
price: int
price: Decimal
category: CateringMenuItemCategory
additional_info: str = str()
is_disabled: bool = False

View File

@ -1,5 +1,6 @@
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from typing import Optional, Iterable, Self
@ -17,6 +18,7 @@ class CateringOrderStatus(StrEnum):
COMPLETED = "COMPLETED"
CANCELED = "CANCELED"
@dataclass(frozen=True)
class CateringOrder:
order_id: int
@ -27,8 +29,8 @@ class CateringOrder:
is_delivery: bool = True
@property
def price(self) -> int:
total = 0
def price(self) -> Decimal:
total = Decimal("0")
for item, amount in self.items.items():
total += (item.price * amount)
return total

View File

@ -1,11 +1,13 @@
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from decimal import Decimal
class NoSuchCategoryError(Exception):
pass
@dataclass(frozen=True)
class DatabaseConfiguration:
db_user: str
@ -14,15 +16,17 @@ class DatabaseConfiguration:
db_port: int
db_name: str
@dataclass(frozen=True)
class TicketInfo:
category: str
total_tickets: int
price: int
price: Decimal
description: str
additional_info: str
is_default: bool
@dataclass(frozen=True)
class MailingServiceConfiguration:
smtp_server: str
@ -31,6 +35,7 @@ class MailingServiceConfiguration:
username: str
password: str
@dataclass(frozen=True)
class LanInfo:
name: str
@ -39,6 +44,7 @@ class LanInfo:
date_till: datetime
organizer_mail: str
@dataclass(frozen=True)
class SeatingConfiguration:
seats: dict[str, str]

View File

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

View File

@ -1,6 +1,7 @@
import unittest
from datetime import datetime
from unittest.mock import MagicMock, AsyncMock
from decimal import Decimal
from src.ez_lan_manager.services.AccountingService import AccountingService, InsufficientFundsError
from src.ez_lan_manager.types.Transaction import Transaction
@ -12,7 +13,6 @@ class AccountingServiceTests(unittest.IsolatedAsyncioTestCase):
self.mock_database_service.add_transaction = AsyncMock()
self.accounting_service = AccountingService(self.mock_database_service)
def test_importing_unit_under_test_works(self) -> None:
"""
This test asserts that the object produced in setUp is AccountingService object,
@ -21,59 +21,59 @@ class AccountingServiceTests(unittest.IsolatedAsyncioTestCase):
self.assertIsInstance(self.accounting_service, AccountingService)
def test_making_string_from_euro_value_works_correctly(self) -> None:
test_value = 13466
test_value = Decimal("134.66")
expected_result = "134.66 €"
self.assertEqual(expected_result, AccountingService.make_euro_string_from_int(test_value))
self.assertEqual(expected_result, AccountingService.make_euro_string_from_decimal(test_value))
def test_making_euro_string_from_negative_value_works_correctly(self) -> None:
test_value = -99741
test_value = Decimal("-997.41")
expected_result = "-997.41 €"
self.assertEqual(expected_result, AccountingService.make_euro_string_from_int(test_value))
self.assertEqual(expected_result, AccountingService.make_euro_string_from_decimal(test_value))
def test_making_euro_string_from_less_than_ten_cents_works_correctly(self) -> None:
test_value = 4
test_value = Decimal("0.04")
expected_result = "0.04 €"
self.assertEqual(expected_result, AccountingService.make_euro_string_from_int(test_value))
self.assertEqual(expected_result, AccountingService.make_euro_string_from_decimal(test_value))
async def test_get_balance_correctly_adds_up_transactions(self) -> None:
self.mock_database_service.get_all_transactions_for_user = AsyncMock(return_value=[
Transaction(
user_id=0,
value=5,
value=Decimal("0.05"),
is_debit=True,
reference="",
transaction_date=datetime.now()
),
Transaction(
user_id=0,
value=99,
value=Decimal("0.99"),
is_debit=False,
reference="",
transaction_date=datetime.now()
),
Transaction(
user_id=0,
value=101,
value=Decimal("1.01"),
is_debit=False,
reference="",
transaction_date=datetime.now()
),
Transaction(
user_id=0,
value=77,
value=Decimal("0.77"),
is_debit=True,
reference="",
transaction_date=datetime.now()
),
])
expected_result = 118
expected_result = Decimal("1.18")
actual_result = await self.accounting_service.get_balance(0)
self.assertEqual(expected_result, actual_result)
async def test_trying_to_remove_more_than_is_on_account_balance_raises_exception(self) -> None:
user_balance = 100
balance_to_remove = 101
user_balance = Decimal("1.00")
balance_to_remove = Decimal("1.01")
self.mock_database_service.get_all_transactions_for_user = AsyncMock(return_value=[
Transaction(
user_id=0,
@ -88,8 +88,8 @@ class AccountingServiceTests(unittest.IsolatedAsyncioTestCase):
await self.accounting_service.remove_balance(0, balance_to_remove, "TestRef")
async def test_trying_to_remove_less_than_is_on_account_balance_spawns_correct_transaction(self) -> None:
user_balance = 101
balance_to_remove = 100
user_balance = Decimal("1.01")
balance_to_remove = Decimal("1.00")
reference = "Yey, a reference"
self.mock_database_service.get_all_transactions_for_user = AsyncMock(return_value=[