diff --git a/config.example.toml b/config.example.toml index a7ab78b..aebc5e1 100644 --- a/config.example.toml +++ b/config.example.toml @@ -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="" diff --git a/src/elm/__init__.py b/src/elm/__init__.py index 2765df8..84fd3b5 100644 --- a/src/elm/__init__.py +++ b/src/elm/__init__.py @@ -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"), diff --git a/src/elm/components/UserNavigation.py b/src/elm/components/UserNavigation.py index 6953ba5..ea06479 100644 --- a/src/elm/components/UserNavigation.py +++ b/src/elm/components/UserNavigation.py @@ -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) diff --git a/src/elm/pages/MyBalancePage.py b/src/elm/pages/MyBalancePage.py index 7d19046..74b07fa 100644 --- a/src/elm/pages/MyBalancePage.py +++ b/src/elm/pages/MyBalancePage.py @@ -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) - approval_url = await self.session[AccountingService].start_paypal_process(self.session[UserSession].user_name, 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 diff --git a/src/elm/pages/PayPalReturnPage.py b/src/elm/pages/PayPalReturnPage.py new file mode 100644 index 0000000..86d6175 --- /dev/null +++ b/src/elm/pages/PayPalReturnPage.py @@ -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 + ) diff --git a/src/elm/pages/TicketsPage.py b/src/elm/pages/TicketsPage.py new file mode 100644 index 0000000..b52753a --- /dev/null +++ b/src/elm/pages/TicketsPage.py @@ -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 + ) \ No newline at end of file diff --git a/src/elm/services/AccountingService.py b/src/elm/services/AccountingService.py index 2dada8d..d8a96ef 100644 --- a/src/elm/services/AccountingService.py +++ b/src/elm/services/AccountingService.py @@ -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( - link["href"] - for link in response.json()["links"] - if link["rel"] == "approve" - ) - return approval_url + try: + payer_action_url = next( + link["href"] + for link in response.json()["links"] + if link["rel"] == "payer-action" + ) + except StopIteration: + logger.error("No payer action url found: %s", response.text) + return "#" + self._pending_paypal_orders[response.json()["id"]] = (user_name, amount) + + return payer_action_url + + async def finalize_paypal_process(self, order_id: str) -> bool: + url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL + async with httpx.AsyncClient() as client: + access_token = await self.get_paypal_access_token() + response = await client.get( + url=f"{url}/v2/checkout/orders/{order_id}", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json" + } + ) + is_approved = response.json()["status"] == "APPROVED" + + if is_approved: + response = await client.post( + f"{url}/v2/checkout/orders/{order_id}/capture", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + ) + is_completed = response.json()["status"] == "COMPLETED" + if is_completed: + await self.add_balance(self._pending_paypal_orders[order_id][0], self._pending_paypal_orders[order_id][1], "PayPal Aufladung") + self._pending_paypal_orders.pop(order_id) + return True + return False async def add_balance(self, user_name: str, balance_to_add: Decimal, title: str) -> Decimal: user = await User.find_one(User.user_name == user_name) @@ -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) diff --git a/src/elm/services/ConfigurationService.py b/src/elm/services/ConfigurationService.py index a7f5c26..061808f 100644 --- a/src/elm/services/ConfigurationService.py +++ b/src/elm/services/ConfigurationService.py @@ -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"] diff --git a/src/elm/services/DatabaseService.py b/src/elm/services/DatabaseService.py index 94d24bf..90a53ca 100644 --- a/src/elm/services/DatabaseService.py +++ b/src/elm/services/DatabaseService.py @@ -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] ) diff --git a/src/elm/types/ConfigurationTypes.py b/src/elm/types/ConfigurationTypes.py index 665041d..0a543f4 100644 --- a/src/elm/types/ConfigurationTypes.py +++ b/src/elm/types/ConfigurationTypes.py @@ -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 diff --git a/src/elm/types/Ticket.py b/src/elm/types/Ticket.py new file mode 100644 index 0000000..6032575 --- /dev/null +++ b/src/elm/types/Ticket.py @@ -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" diff --git a/src/elm/types/__init__.py b/src/elm/types/__init__.py index e4ae08a..33a4a1d 100644 --- a/src/elm/types/__init__.py +++ b/src/elm/types/__init__.py @@ -2,3 +2,4 @@ from .User import User from .UserSession import UserSession from .ConfigurationTypes import * from .Transaction import Transaction +from .Ticket import Ticket