prerelease/0.6.0 #1

Merged
Typhus merged 29 commits from prerelease/0.6.0 into main 2026-05-27 23:17:52 +00:00
12 changed files with 321 additions and 29 deletions
Showing only changes of commit 6c8c0c7a4f - Show all commits
+2 -1
View File
@@ -43,8 +43,9 @@
password="Alkohol1"
[misc]
base_url="https://ezgg-lan.de" # In dev mode, this is localhost
default_profile_picture="src/elm/assets/img/anon.png"
dev_mode_active=true # Supresses E-Mail sending
dev_mode_active=true # Supresses E-Mail sending, activates PayPal sandbox API
[paypal]
client_id_sandbox=""
+3 -2
View File
@@ -48,6 +48,7 @@ Icon.register_single_icon(
configuration_service = ConfigurationService(from_root("config.toml"))
database_service = DatabaseService(configuration_service.get_database_configuration())
mailing_service = MailingService(configuration_service)
lan_info = configuration_service.get_lan_info()
def is_mobile(self: Session) -> bool:
@@ -56,8 +57,8 @@ def is_mobile(self: Session) -> bool:
Session.is_mobile = is_mobile
async def on_session_start(session: Session) -> None:
# Use this line to fake being any user without having to log in
if configuration_service.DEV_MODE_ACTIVE:
# Use this line to fake being any user without having to log in
dev_user = await session[UserService].get_user("Typhus")
if not dev_user:
logger.fatal("DEV MODE USER DOES NOT EXIST")
@@ -78,7 +79,7 @@ app = App(
theme=theme,
assets_dir=Path(__file__).parent / "assets",
build=RootComponent,
default_attachments=[LocalData(), configuration_service, database_service, UserService(), LocalDataService(), MailingService(configuration_service), AccountingService(configuration_service.get_paypal_configuration())],
default_attachments=[LocalData(), configuration_service, database_service, UserService(), LocalDataService(), mailing_service, AccountingService(configuration_service, mailing_service)],
on_app_start=on_app_start,
on_session_start=on_session_start,
icon=from_root("src/elm/assets/img/favicon.png"),
+24 -3
View File
@@ -1,6 +1,11 @@
from typing import Optional, Callable
from asyncio import sleep
from decimal import Decimal
from typing import Callable
from rio import Component, Row, Column, Color, PointerEventListener, PointerEvent, Rectangle, Text, TextStyle, Icon, event
from rio import Component, Row, Column, Color, PointerEventListener, PointerEvent, Rectangle, Text, TextStyle, event
from elm.types import UserSession
from elm.services import AccountingService
class UserNavigationButton(Component):
@@ -61,15 +66,31 @@ class UserNavigationButton(Component):
class UserNavigation(Component):
close_navigation: Callable
balance: Decimal = Decimal(0)
@event.on_page_change
async def on_page_change(self) -> None:
await self.close_navigation()
async def update_balance(self) -> None:
try:
balance = await self.session[AccountingService].get_balance(self.session[UserSession].user_name)
if balance != self.balance:
self.balance = balance
except KeyError:
pass
await sleep(5)
self.session.create_task(self.update_balance())
@event.on_populate
async def on_populate(self) -> None:
self.session.create_task(self.update_balance())
def build(self) -> Component:
return Rectangle(
content=Column(
UserNavigationButton("Guthaben: 0,00 €", "/balance", self.close_navigation),
UserNavigationButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_decimal(self.balance)}", "/balance", self.close_navigation),
UserNavigationButton("Mein Profil", "/my-profile", self.close_navigation),
UserNavigationButton("Mein Clan", "/my-clans", self.close_navigation),
UserNavigationButton("Ausloggen", "/logout", self.close_navigation)
+23 -5
View File
@@ -1,16 +1,17 @@
from __future__ import annotations
import logging
from datetime import date, datetime
from asyncio import sleep
from datetime import datetime
from decimal import Decimal, ROUND_DOWN
from typing import Optional
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, TextInput, GuardEvent, DateInput, PointerEventListener, Revealer, Image, NumberInput
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, GuardEvent, Revealer, Image, NumberInput
from rio.event import on_populate
from elm.types import UserSession, Transaction
from elm.services import UserService, AccountingService
from elm.components import ElmButton, AvatarEditBox, AccountInfoBox
from elm.services import AccountingService
from elm.components import ElmButton
logger = logging.getLogger(__name__.split(".")[-1])
@@ -84,13 +85,30 @@ class MyBalancePage(Component):
"DE47517624340019856607",
f"AUFLADUNG - {self.session[UserSession].user_name}")
async def check_if_paypal_process_done(self) -> None:
await sleep(2)
if await self.session[AccountingService].has_user_open_orders(self.session[UserSession].user_name):
self.session.create_task(self.check_if_paypal_process_done())
else:
self.paypal_charge_in_progress = False
self.paypal_charge_amount = 0.00
self.current_balance = self.session[AccountingService].make_euro_string_from_decimal(
await self.session[AccountingService].get_balance(self.session[UserSession].user_name)
)
self.last_20_transactions = (await self.session[AccountingService].get_transaction_history(self.session[UserSession].user_name))[:20]
self.paypal_revealer_open = False
async def pay_with_paypal(self) -> None:
self.paypal_charge_in_progress = True
logger.info("Starting PayPal transaction over %s for user %s", f"{self.paypal_charge_amount}", self.session[UserSession].user_name)
amount = Decimal(self.paypal_charge_amount)
try:
approval_url = await self.session[AccountingService].start_paypal_process(self.session[UserSession].user_name, amount)
except Exception as e:
logger.error(e)
return
self.session.open_url_in_browser(approval_url)
# ToDo: Catch return URL somehow and notify user
self.session.create_task(self.check_if_paypal_process_done())
async def toggle_bank_revealer(self) -> None:
self.bank_revealer_open = not self.bank_revealer_open
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import logging
from asyncio import sleep
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, QueryParameter, ProgressCircle
from rio.event import on_populate
from elm.services import AccountingService
logger = logging.getLogger(__name__.split(".")[-1])
@page(name="PayPal Return", url_segment="return-paypal")
class PayPalReturnPage(Component):
token: QueryParameter[str] = "No Value"
in_progress: bool = True
error_message: str = ""
success_message: str = ""
@on_populate
async def on_populate(self) -> None:
result = await self.session[AccountingService].finalize_paypal_process(self.token)
await sleep(1)
if result:
self.in_progress = False
self.success_message = "Aufladung erfolgreich. Du kannst dieses Fenster schließen."
else:
self.in_progress = False
self.error_message = "Es ist ein Fehler aufgetreten, bitte kontaktiere uns"
def build(self) -> Component:
col_contents = []
if self.in_progress:
col_contents.append(ProgressCircle(min_size=5, color=self.session.theme.primary_color))
col_contents.append(Text("Wir prüfen deine Aufladung", overflow="wrap", justify="center"))
else:
if self.error_message:
col_contents.append(Text(self.error_message, overflow="wrap", justify="center", fill=self.session.theme.danger_color))
elif self.success_message:
col_contents.append(Text(self.success_message, overflow="wrap", justify="center", fill=self.session.theme.success_color))
return Row(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Text("Paypal Aufladung", margin=0.5, selectable=False, overflow="wrap"),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
*col_contents,
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
min_width=1 if self.session.is_mobile() else 25
),
align_x=0.5,
align_y=0.5
)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from rio import Component, Column, Row, Text, Spacer, page, Color, TextStyle, Rectangle, TextInput, ProgressBar, Dict
from rio.event import on_populate
from elm.services import ConfigurationService, AccountingService
from elm.components import LanCountdownBox, LanInfoBox, LandingPageBoxFull, LandingPageBoxHalf, ElmButton
from elm.types import Ticket
@page(name="Tickets", url_segment="tickets")
class TicketsPage(Component):
sold_tickets_by_category: Dict[str, int] = Dict()
"""
ToDo: Implement conditional ticket buying (check login!)
"""
@on_populate
async def on_populate(self) -> None:
for ticket_info in self.session[ConfigurationService].get_ticket_info():
self.sold_tickets_by_category[ticket_info.category] = len(await Ticket.find_many(Ticket.category == ticket_info.category).to_list())
def get_available_tickets_by_category(self, category: str, total_tickets: int) -> int:
return total_tickets - self.sold_tickets_by_category.get(category, 0)
def build(self) -> Component:
row_col = Column if self.session.is_mobile() else Row
ticket_boxes = []
for ticket_info in self.session[ConfigurationService].get_ticket_info():
ticket_boxes.append(
Column(
Rectangle(
content=Column(
Rectangle(
content=Rectangle(
content=Row(
Text(ticket_info.description, margin=0.5, selectable=False, overflow="wrap", grow_x=True),
Text(self.session[AccountingService].make_euro_string_from_decimal(ticket_info.price), justify="right", margin_right=0.5, fill=self.session.theme.warning_color)
),
fill=self.session.theme.header_box_background_color,
margin=0.4
),
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color,
),
Column(
Text(ticket_info.additional_info, overflow="wrap", margin_bottom=1),
Text("Du besitzt dieses Ticket!", margin_bottom=3, overflow="wrap", fill=self.session.theme.success_color),
Row(Text("Verfügbar:", font_size=0.8 if self.session.is_mobile() else 1), Text(f"{self.get_available_tickets_by_category(ticket_info.category, ticket_info.total_tickets)} / {ticket_info.total_tickets}", justify="right", font_size=0.8 if self.session.is_mobile() else 1)),
ProgressBar(progress=self.get_available_tickets_by_category(ticket_info.category, ticket_info.total_tickets) / ticket_info.total_tickets, min_height=1),
ElmButton(text="Kaufen"),
margin=1,
spacing=1
),
Spacer()
),
fill=self.session.theme.box_color,
stroke_width=0.1,
stroke_color=self.session.theme.box_border_color
),
Spacer()
)
)
return row_col(
*ticket_boxes,
spacing=1,
margin=1
)
+78 -11
View File
@@ -7,6 +7,7 @@ import httpx
import qrcode
from elm.types import Transaction, User, PayPalConfiguration
from elm.services import MailingService, ConfigurationService
logger = logging.getLogger(__name__.split(".")[-1])
@@ -16,16 +17,29 @@ class InsufficientFundsError(Exception):
class AccountingService:
def __init__(self, paypal_config: PayPalConfiguration) -> None:
self._paypal_config = paypal_config
PAYPAL_SANDBOX_URL = "https://api-m.sandbox.paypal.com"
PAYPAL_PROD_URL = "https://api-m.paypal.com"
def __init__(self, configuration_service: ConfigurationService, mailing_service: MailingService) -> None:
self._configuration_service = configuration_service
self._paypal_config: PayPalConfiguration = configuration_service.get_paypal_configuration()
self._pending_paypal_orders: dict[str, tuple[str, Decimal]] = {}
self._mailing_service = mailing_service
async def has_user_open_orders(self, user_name: str) -> bool:
for pending_paypal_order in self._pending_paypal_orders.values():
if pending_paypal_order[0] == user_name:
return True
return False
async def get_paypal_access_token(self) -> str:
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api-m.sandbox.paypal.com/v1/oauth2/token",
f"{url}/v1/oauth2/token",
auth=(
self._paypal_config.client_id_sandbox,
self._paypal_config.secret_sandbox,
self._paypal_config.client_id_sandbox if self._configuration_service.DEV_MODE_ACTIVE else self._paypal_config.client_id,
self._paypal_config.secret_sandbox if self._configuration_service.DEV_MODE_ACTIVE else self._paypal_config.secret,
),
headers={
"Content-Type": "application/x-www-form-urlencoded",
@@ -41,10 +55,13 @@ class AccountingService:
return data["access_token"]
async def start_paypal_process(self, user_name: str, amount: Decimal) -> str:
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
return_domain = "http://localhost:8000" if self._configuration_service.DEV_MODE_ACTIVE else self._configuration_service.BASE_URL
amount = amount.quantize(Decimal(".01"))
async with httpx.AsyncClient() as client:
access_token = await self.get_paypal_access_token()
response = await client.post(
url="https://api-m.sandbox.paypal.com/v2/checkout/orders/",
url=f"{url}/v2/checkout/orders/",
headers={
"Authorization": f"Bearer {access_token}"
},
@@ -58,16 +75,60 @@ class AccountingService:
"value": str(amount)
}
}
]
],
"payment_source": {
"paypal": {
"experience_context": {
"return_url": f"{return_domain}/return-paypal",
"cancel_url": f"{return_domain}/cancel-paypal",
"user_action": "PAY_NOW",
"shipping_preference": "NO_SHIPPING"
}
}
}
}
)
approval_url = next(
try:
payer_action_url = next(
link["href"]
for link in response.json()["links"]
if link["rel"] == "approve"
if link["rel"] == "payer-action"
)
return approval_url
except StopIteration:
logger.error("No payer action url found: %s", response.text)
return "#"
self._pending_paypal_orders[response.json()["id"]] = (user_name, amount)
return payer_action_url
async def finalize_paypal_process(self, order_id: str) -> bool:
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
async with httpx.AsyncClient() as client:
access_token = await self.get_paypal_access_token()
response = await client.get(
url=f"{url}/v2/checkout/orders/{order_id}",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
)
is_approved = response.json()["status"] == "APPROVED"
if is_approved:
response = await client.post(
f"{url}/v2/checkout/orders/{order_id}/capture",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
)
is_completed = response.json()["status"] == "COMPLETED"
if is_completed:
await self.add_balance(self._pending_paypal_orders[order_id][0], self._pending_paypal_orders[order_id][1], "PayPal Aufladung")
self._pending_paypal_orders.pop(order_id)
return True
return False
async def add_balance(self, user_name: str, balance_to_add: Decimal, title: str) -> Decimal:
user = await User.find_one(User.user_name == user_name)
@@ -80,7 +141,13 @@ class AccountingService:
title=title
).save()
logger.debug(f"Added balance of {self.make_euro_string_from_decimal(balance_to_add)} to user '{user_name}'")
return await self.get_balance(user_name)
new_balance = await self.get_balance(user_name)
await self._mailing_service.send_email(
"Dein Guthaben wurde aufgeladen",
self._mailing_service.generate_account_balance_added_mail_body(user, balance_to_add, new_balance),
user.user_mail
)
return new_balance
async def remove_balance(self, user_name: str, balance_to_remove: Decimal, title: str) -> Decimal:
current_balance = await self.get_balance(user_name)
+21 -1
View File
@@ -1,12 +1,13 @@
import sys
from datetime import datetime
from decimal import Decimal
from pathlib import Path
import logging
import tomllib
from from_root import from_root
from elm.types.ConfigurationTypes import MailingServiceConfiguration, LanInfo, ReceiptPrintingConfiguration, DatabaseConfiguration, PayPalConfiguration
from elm.types.ConfigurationTypes import MailingServiceConfiguration, LanInfo, ReceiptPrintingConfiguration, DatabaseConfiguration, PayPalConfiguration, TicketInfo
logger = logging.getLogger(__name__.split(".")[-1])
logger.setLevel(logging.DEBUG)
@@ -46,6 +47,21 @@ class ConfigurationService:
logger.fatal("Error loading DatabaseConfiguration, exiting...")
sys.exit(1)
def get_ticket_info(self) -> tuple[TicketInfo, ...]:
try:
return tuple([TicketInfo(
category=value,
total_tickets=self._config["tickets"][value]["total_tickets"],
price=Decimal(self._config["tickets"][value]["price"]),
description=self._config["tickets"][value]["description"],
additional_info=self._config["tickets"][value]["additional_info"]
) for value in self._config["tickets"]])
except KeyError as e:
logger.debug(e)
logger.fatal("Error loading ticket configuration, exiting...")
sys.exit(1)
def get_database_configuration(self) -> DatabaseConfiguration:
try:
return DatabaseConfiguration(
@@ -117,3 +133,7 @@ class ConfigurationService:
@property
def DEFAULT_PROFILE_PICTURE(self) -> bytes:
return self._DEFAULT_PROFILE_PICTURE
@property
def BASE_URL(self) -> str:
return self._config["misc"]["base_url"]
+2 -2
View File
@@ -4,7 +4,7 @@ from beanie import init_beanie
from pymongo import AsyncMongoClient
from pymongo.asynchronous.collection import AsyncCollection
from elm.types import User, Transaction
from elm.types import User, Transaction, Ticket
from elm.types.ConfigurationTypes import DatabaseConfiguration
logger = logging.getLogger(__name__.split(".")[-1])
@@ -33,5 +33,5 @@ class DatabaseService:
self._users: AsyncCollection = self._database["users"]
await init_beanie(
database=self._database,
document_models=[User, Transaction]
document_models=[User, Transaction, Ticket]
)
+10
View File
@@ -1,5 +1,7 @@
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
class NoSuchCategoryError(Exception):
pass
@@ -44,3 +46,11 @@ class PayPalConfiguration:
secret_sandbox: str
client_id: str
secret: str
@dataclass(frozen=True)
class TicketInfo:
category: str
total_tickets: int
price: Decimal
description: str
additional_info: str
+15
View File
@@ -0,0 +1,15 @@
from datetime import datetime
from typing import Optional
from beanie import Document, Link
from elm.types import User
class Ticket(Document):
category: str
purchase_date: datetime
owner: Optional[Link[User]] = None
class Settings:
name = "tickets"
+1
View File
@@ -2,3 +2,4 @@ from .User import User
from .UserSession import UserSession
from .ConfigurationTypes import *
from .Transaction import Transaction
from .Ticket import Ticket