From 49602854e23594dabce4ff8ff072b076317265c2 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 18 Aug 2024 15:15:06 +0200 Subject: [PATCH 01/85] add config and db base --- config/config.example.toml | 6 ++ requirements.txt | Bin 38 -> 68 bytes src/EzLanManager.py | 17 ++++ src/ez_lan_manager/__init__.py | 2 + .../services/ConfigurationService.py | 31 ++++++ .../services/DatabaseService.py | 92 ++++++++++++++++++ src/ez_lan_manager/services/__init__.py | 0 .../types/ConfigurationTypes.py | 9 ++ src/ez_lan_manager/types/User.py | 20 ++++ src/ez_lan_manager/types/__init__.py | 0 10 files changed, 177 insertions(+) create mode 100644 config/config.example.toml create mode 100644 src/EzLanManager.py create mode 100644 src/ez_lan_manager/__init__.py create mode 100644 src/ez_lan_manager/services/ConfigurationService.py create mode 100644 src/ez_lan_manager/services/DatabaseService.py create mode 100644 src/ez_lan_manager/services/__init__.py create mode 100644 src/ez_lan_manager/types/ConfigurationTypes.py create mode 100644 src/ez_lan_manager/types/User.py create mode 100644 src/ez_lan_manager/types/__init__.py diff --git a/config/config.example.toml b/config/config.example.toml new file mode 100644 index 0000000..f5c24bb --- /dev/null +++ b/config/config.example.toml @@ -0,0 +1,6 @@ +[database] + db_user="demo_user" + db_password="demo_password" + db_host="127.0.0.1" + db_port=3306 + db_name="ez_lan_manager" diff --git a/requirements.txt b/requirements.txt index 1b2c6c19decffe5c0b94a3fcac379c2f594f9007..c5cde3760f3f37ebd29b72e2a460e87a6566d180 100644 GIT binary patch delta 35 jcmY#$nV=?@%aF)W#E=OjQy7vM>KJSp3>oyG*nj~5fHei_ delta 4 LcmZ=!o1g{&0?Yv+ diff --git a/src/EzLanManager.py b/src/EzLanManager.py new file mode 100644 index 0000000..fb285e1 --- /dev/null +++ b/src/EzLanManager.py @@ -0,0 +1,17 @@ +import logging + +from from_root import from_root + +from src.ez_lan_manager.services.ConfigurationService import ConfigurationService +from src.ez_lan_manager.services.DatabaseService import DatabaseService + +from random import randint + +logger = logging.getLogger(__name__.split(".")[-1]) + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + configuration_service = ConfigurationService(from_root("config.toml")) + db_config = configuration_service.get_database_configuration() + db_service = DatabaseService(db_config) + print(db_service.create_user(f"TestUser{randint(0, 9999)}", f"TestMail{randint(0, 9999)}", "pw123")) diff --git a/src/ez_lan_manager/__init__.py b/src/ez_lan_manager/__init__.py new file mode 100644 index 0000000..8ba3caa --- /dev/null +++ b/src/ez_lan_manager/__init__.py @@ -0,0 +1,2 @@ +from src.ez_lan_manager.services import * +from src.ez_lan_manager.types import * diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py new file mode 100644 index 0000000..bb261a3 --- /dev/null +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -0,0 +1,31 @@ +import sys +from pathlib import Path +import logging +import tomllib + +from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration + +logger = logging.getLogger(__name__.split(".")[-1]) + +class ConfigurationService: + def __init__(self, config_file_path: Path): + try: + with open(config_file_path, "rb") as config_file: + self._config = tomllib.load(config_file) + except FileNotFoundError: + logger.fatal(f"Could not find config file at \"{config_file_path}\", exiting...") + exit(1) + + def get_database_configuration(self) -> DatabaseConfiguration: + try: + database_configuration = self._config["database"] + return DatabaseConfiguration( + db_user=database_configuration["db_user"], + db_password=database_configuration["db_password"], + db_host=database_configuration["db_host"], + db_port=database_configuration["db_port"], + db_name=database_configuration["db_name"] + ) + except KeyError: + logger.fatal("Error loading DatabaseConfiguration, exiting...") + sys.exit(1) diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py new file mode 100644 index 0000000..c58fb00 --- /dev/null +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -0,0 +1,92 @@ +import logging +import sys +from typing import Optional + +import mariadb +from mariadb import Cursor + +from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration +from src.ez_lan_manager.types.User import User + +logger = logging.getLogger(__name__.split(".")[-1]) + +class DuplicationError(Exception): + pass + +class DatabaseService: + def __init__(self, database_config: DatabaseConfiguration): + self._database_config = database_config + try: + logger.info( + f"Connecting to database '{self._database_config.db_name}' on " + f"{self._database_config.db_user}@{self._database_config.db_host}:{self._database_config.db_port}" + ) + self._connection = mariadb.connect( + user=self._database_config.db_user, + password=self._database_config.db_password, + host=self._database_config.db_host, + port=self._database_config.db_port, + database=self._database_config.db_name + ) + except mariadb.Error as e: + logger.fatal(f"Error connecting to database: {e}") + sys.exit(1) + + def _get_cursor(self) -> Cursor: + return self._connection.cursor() + + @staticmethod + def _map_db_result_to_user(data: tuple) -> User: + return User( + user_id=data[0], + user_name=data[1], + user_mail=data[2], + user_password=data[3], + user_first_name=data[4], + user_last_name=data[5], + user_birth_day=data[6], + is_active=bool(data[7]), + is_team_member=bool(data[8]), + is_admin=bool(data[9]), + created_at=data[10], + last_updated_at=data[11], + balance=int(data[12]) + ) + + def get_user_by_name(self, user_name: str) -> Optional[User]: + cursor = self._get_cursor() + cursor.execute("SELECT * FROM users WHERE user_name=?", (user_name,)) + result = cursor.fetchone() + if not result: + return + return self._map_db_result_to_user(result) + + def get_user_by_id(self, user_id: int) -> Optional[User]: + cursor = self._get_cursor() + cursor.execute("SELECT * FROM users WHERE user_id=?", (user_id,)) + result = cursor.fetchone() + if not result: + return + return self._map_db_result_to_user(result) + + def get_user_by_main(self, user_mail: str) -> Optional[User]: + cursor = self._get_cursor() + cursor.execute("SELECT * FROM users WHERE user_mail=?", (user_mail,)) + result = cursor.fetchone() + if not result: + return + return self._map_db_result_to_user(result) + + def create_user(self, user_name: str, user_mail: str, password_hash: str) -> User: + cursor = self._get_cursor() + try: + cursor.execute( + "INSERT INTO users (user_name, user_mail, user_password) " + "VALUES (?, ?, ?)", (user_name, user_mail, password_hash) + ) + self._connection.commit() + except mariadb.IntegrityError as e: + logger.warning(f"Aborted duplication entry: {e}") + raise DuplicationError + + return self.get_user_by_name(user_name) diff --git a/src/ez_lan_manager/services/__init__.py b/src/ez_lan_manager/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ez_lan_manager/types/ConfigurationTypes.py b/src/ez_lan_manager/types/ConfigurationTypes.py new file mode 100644 index 0000000..9aa58ab --- /dev/null +++ b/src/ez_lan_manager/types/ConfigurationTypes.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +@dataclass(frozen=True) +class DatabaseConfiguration: + db_user: str + db_password: str + db_host: str + db_port: int + db_name: str diff --git a/src/ez_lan_manager/types/User.py b/src/ez_lan_manager/types/User.py new file mode 100644 index 0000000..bb03b9c --- /dev/null +++ b/src/ez_lan_manager/types/User.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from datetime import date, datetime +from typing import Optional + + +@dataclass +class User: + user_id: int + user_name: str + user_mail: str + user_password: str + user_first_name: Optional[str] + user_last_name: Optional[str] + user_birth_day: Optional[date] + is_active: bool + is_team_member: bool + is_admin: bool + created_at: datetime + last_updated_at: datetime + balance: int diff --git a/src/ez_lan_manager/types/__init__.py b/src/ez_lan_manager/types/__init__.py new file mode 100644 index 0000000..e69de29 -- 2.45.2 From 8b87d78d5d06420f9e2f51331c8af42f985fde1a Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 19 Aug 2024 10:38:57 +0200 Subject: [PATCH 02/85] implement UserService --- src/EzLanManager.py | 14 +++++- .../services/ConfigurationService.py | 2 +- .../services/DatabaseService.py | 24 +++++++++-- src/ez_lan_manager/services/UserService.py | 43 +++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 src/ez_lan_manager/services/UserService.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index fb285e1..fa14888 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from from_root import from_root @@ -7,6 +8,9 @@ from src.ez_lan_manager.services.DatabaseService import DatabaseService from random import randint +from src.ez_lan_manager.services.UserService import UserService +from src.ez_lan_manager.types.User import User + logger = logging.getLogger(__name__.split(".")[-1]) if __name__ == "__main__": @@ -14,4 +18,12 @@ if __name__ == "__main__": configuration_service = ConfigurationService(from_root("config.toml")) db_config = configuration_service.get_database_configuration() db_service = DatabaseService(db_config) - print(db_service.create_user(f"TestUser{randint(0, 9999)}", f"TestMail{randint(0, 9999)}", "pw123")) + user_service = UserService(db_service) + user_service.create_user("Mamfred", "Peter@peterson.com", "MamaHalloDoo") + # print(db_service.create_user(f"TestUser{randint(0, 9999)}", f"TestMail{randint(0, 9999)}", "pw123")) + # print(db_service.update_user( + # User(user_id=19, user_name='TestUser838', user_mail='TestMail3142', user_password='pw123', user_first_name=None, user_last_name=None, + # user_birth_day=None, is_active=False, is_team_member=False, is_admin=False, created_at=datetime(2024, 8, 19, 10, 10, 39), + # last_updated_at=datetime(2024, 8, 19, 10, 10, 39), balance=0) + # + # )) \ No newline at end of file diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py index bb261a3..44a6788 100644 --- a/src/ez_lan_manager/services/ConfigurationService.py +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -8,7 +8,7 @@ from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration logger = logging.getLogger(__name__.split(".")[-1]) class ConfigurationService: - def __init__(self, config_file_path: Path): + def __init__(self, config_file_path: Path) -> None: try: with open(config_file_path, "rb") as config_file: self._config = tomllib.load(config_file) diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index c58fb00..bfd12e5 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -14,7 +14,7 @@ class DuplicationError(Exception): pass class DatabaseService: - def __init__(self, database_config: DatabaseConfiguration): + def __init__(self, database_config: DatabaseConfiguration) -> None: self._database_config = database_config try: logger.info( @@ -69,9 +69,9 @@ class DatabaseService: return return self._map_db_result_to_user(result) - def get_user_by_main(self, user_mail: str) -> Optional[User]: + def get_user_by_mail(self, user_mail: str) -> Optional[User]: cursor = self._get_cursor() - cursor.execute("SELECT * FROM users WHERE user_mail=?", (user_mail,)) + cursor.execute("SELECT * FROM users WHERE user_mail=?", (user_mail.lower(),)) result = cursor.fetchone() if not result: return @@ -82,7 +82,7 @@ class DatabaseService: try: cursor.execute( "INSERT INTO users (user_name, user_mail, user_password) " - "VALUES (?, ?, ?)", (user_name, user_mail, password_hash) + "VALUES (?, ?, ?)", (user_name, user_mail.lower(), password_hash) ) self._connection.commit() except mariadb.IntegrityError as e: @@ -90,3 +90,19 @@ class DatabaseService: raise DuplicationError return self.get_user_by_name(user_name) + + def update_user(self, user: User) -> User: + cursor = self._get_cursor() + try: + cursor.execute( + "UPDATE users SET user_name=?, user_mail=?, user_password=?, user_first_name=?, user_last_name=?, user_birth_date=?, " + "is_active=?, is_team_member=?, is_admin=?, balance=? WHERE (user_id=?)", (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.balance, user.user_id) + ) + self._connection.commit() + except mariadb.IntegrityError as e: + logger.warning(f"Aborted duplication entry: {e}") + raise DuplicationError + return user diff --git a/src/ez_lan_manager/services/UserService.py b/src/ez_lan_manager/services/UserService.py new file mode 100644 index 0000000..c1e056f --- /dev/null +++ b/src/ez_lan_manager/services/UserService.py @@ -0,0 +1,43 @@ +from hashlib import sha256 +from typing import Union, Optional +from string import ascii_letters, digits + +from src.ez_lan_manager.services.DatabaseService import DatabaseService +from src.ez_lan_manager.types.User import User + + +class NameNotAllowedError(Exception): + def __init__(self, disallowed_char: str) -> None: + self.disallowed_char = disallowed_char + +class UserService: + ALLOWED_USER_NAME_SYMBOLS = ascii_letters + digits + "!#$%&*+,-./:;<=>?[]^_{|}~" + + def __init__(self, db_service: DatabaseService) -> None: + self._db_service = db_service + + def get_user(self, accessor: Union[str, int]) -> User: + if isinstance(accessor, int): + return self._db_service.get_user_by_id(accessor) + if "@" in accessor: + return self._db_service.get_user_by_mail(accessor) + return self._db_service.get_user_by_name(accessor) + + def create_user(self, user_name: str, user_mail: str, password_clear_text: str) -> User: + disallowed_char = self._check_for_disallowed_char(user_name) + if disallowed_char: + raise NameNotAllowedError(disallowed_char) + + hashed_pw = sha256(password_clear_text.encode(encoding="utf-8")).hexdigest() + return self._db_service.create_user(user_name, user_mail, hashed_pw) + + def update_user(self, user: User) -> User: + disallowed_char = self._check_for_disallowed_char(user.user_name) + if disallowed_char: + raise NameNotAllowedError(disallowed_char) + return self._db_service.update_user(user) + + def _check_for_disallowed_char(self, name: str) -> Optional[str]: + for c in name: + if c not in self.ALLOWED_USER_NAME_SYMBOLS: + return c -- 2.45.2 From 02134f61f5c450c57adb1a7eddda57c0dd137e59 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 19 Aug 2024 11:29:00 +0200 Subject: [PATCH 03/85] add AccountingService --- src/EzLanManager.py | 13 +++- .../services/AccountingService.py | 72 +++++++++++++++++++ .../services/DatabaseService.py | 44 ++++++++++-- src/ez_lan_manager/types/Transaction.py | 11 +++ src/ez_lan_manager/types/User.py | 1 - 5 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 src/ez_lan_manager/services/AccountingService.py create mode 100644 src/ez_lan_manager/types/Transaction.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index fa14888..3fbd57c 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -3,12 +3,14 @@ from datetime import datetime from from_root import from_root +from src.ez_lan_manager.services.AccountingService import AccountingService from src.ez_lan_manager.services.ConfigurationService import ConfigurationService from src.ez_lan_manager.services.DatabaseService import DatabaseService from random import randint from src.ez_lan_manager.services.UserService import UserService +from src.ez_lan_manager.types.Transaction import Transaction from src.ez_lan_manager.types.User import User logger = logging.getLogger(__name__.split(".")[-1]) @@ -19,7 +21,16 @@ if __name__ == "__main__": db_config = configuration_service.get_database_configuration() db_service = DatabaseService(db_config) user_service = UserService(db_service) - user_service.create_user("Mamfred", "Peter@peterson.com", "MamaHalloDoo") + accounting_service = AccountingService(db_service, user_service) + #user_service.create_user("Mamfred", "Peter@peterson.com", "MamaHalloDoo") + # db_service.add_transaction(Transaction( + # user_id=19, + # value=50, + # is_debit=True, + # reference="Ein teures Bier", + # transaction_date=datetime.now() + # )) + #print(accounting_service.remove_balance(19, 150, "EinsFuffzig")) # print(db_service.create_user(f"TestUser{randint(0, 9999)}", f"TestMail{randint(0, 9999)}", "pw123")) # print(db_service.update_user( # User(user_id=19, user_name='TestUser838', user_mail='TestMail3142', user_password='pw123', user_first_name=None, user_last_name=None, diff --git a/src/ez_lan_manager/services/AccountingService.py b/src/ez_lan_manager/services/AccountingService.py new file mode 100644 index 0000000..e2a0ba0 --- /dev/null +++ b/src/ez_lan_manager/services/AccountingService.py @@ -0,0 +1,72 @@ +import logging +from datetime import datetime + +from src.ez_lan_manager.services.DatabaseService import DatabaseService +from src.ez_lan_manager.services.UserService import UserService +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, user_service: UserService) -> None: + self._db_service = db_service + self._user_service = user_service + + def add_balance(self, user_id: int, balance_to_add: int, reference: str) -> int: + self._db_service.add_transaction(Transaction( + user_id=user_id, + value=balance_to_add, + is_debit=False, + reference=reference, + transaction_date=datetime.now() + )) + logger.debug(f"Added balance of {self.make_euro_string_from_int(balance_to_add)} to user with ID {user_id}") + return self.get_balance(user_id) + + def remove_balance(self, user_id: int, balance_to_remove: int, reference: str) -> int: + current_balance = self.get_balance(user_id) + if (current_balance - balance_to_remove) < 0: + raise InsufficientFundsError + self._db_service.add_transaction(Transaction( + user_id=user_id, + value=balance_to_remove, + is_debit=True, + reference=reference, + transaction_date=datetime.now() + )) + logger.debug(f"Removed balance of {self.make_euro_string_from_int(balance_to_remove)} to user with ID {user_id}") + return self.get_balance(user_id) + + def get_balance(self, user_id: int) -> int: + balance_buffer = 0 + for transaction in self._db_service.get_all_transactions_for_user(user_id): + if transaction.is_debit: + balance_buffer -= transaction.value + else: + balance_buffer += transaction.value + return balance_buffer + + @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 diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index bfd12e5..a72eea7 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -6,6 +6,7 @@ import mariadb from mariadb import Cursor from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration +from src.ez_lan_manager.types.Transaction import Transaction from src.ez_lan_manager.types.User import User logger = logging.getLogger(__name__.split(".")[-1]) @@ -49,8 +50,7 @@ class DatabaseService: is_team_member=bool(data[8]), is_admin=bool(data[9]), created_at=data[10], - last_updated_at=data[11], - balance=int(data[12]) + last_updated_at=data[11] ) def get_user_by_name(self, user_name: str) -> Optional[User]: @@ -96,13 +96,49 @@ class DatabaseService: try: cursor.execute( "UPDATE users SET user_name=?, user_mail=?, user_password=?, user_first_name=?, user_last_name=?, user_birth_date=?, " - "is_active=?, is_team_member=?, is_admin=?, balance=? WHERE (user_id=?)", (user.user_name, user.user_mail.lower(), user.user_password, + "is_active=?, is_team_member=?, is_admin=? WHERE (user_id=?)", (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.balance, user.user_id) + user.user_id) ) self._connection.commit() except mariadb.IntegrityError as e: logger.warning(f"Aborted duplication entry: {e}") raise DuplicationError return user + + def add_transaction(self, transaction: Transaction) -> Optional[Transaction]: + cursor = self._get_cursor() + try: + cursor.execute( + "INSERT INTO transactions (user_id, value, is_debit, transaction_date, transaction_reference) " + "VALUES (?, ?, ?, ?, ?)", + (transaction.user_id, transaction.value, transaction.is_debit, transaction.transaction_date, transaction.reference) + ) + self._connection.commit() + except Exception as e: + logger.warning(f"Error adding Transaction: {e}") + return + + return transaction + + def get_all_transactions_for_user(self, user_id: int) -> list[Transaction]: + transactions = [] + + cursor = self._get_cursor() + try: + cursor.execute("SELECT * FROM transactions WHERE user_id=?", (user_id,)) + result = cursor.fetchall() + except mariadb.Error as e: + logger.error(f"Error getting all transactions for user: {e}") + return [] + + for transaction_raw in result: + transactions.append(Transaction( + user_id=user_id, + value=int(transaction_raw[2]), + is_debit=bool(transaction_raw[3]), + transaction_date=transaction_raw[4], + reference=transaction_raw[5] + )) + return transactions diff --git a/src/ez_lan_manager/types/Transaction.py b/src/ez_lan_manager/types/Transaction.py new file mode 100644 index 0000000..18758cd --- /dev/null +++ b/src/ez_lan_manager/types/Transaction.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class Transaction: + user_id: int + value: int + is_debit: bool + reference: str + transaction_date: datetime diff --git a/src/ez_lan_manager/types/User.py b/src/ez_lan_manager/types/User.py index bb03b9c..164abac 100644 --- a/src/ez_lan_manager/types/User.py +++ b/src/ez_lan_manager/types/User.py @@ -17,4 +17,3 @@ class User: is_admin: bool created_at: datetime last_updated_at: datetime - balance: int -- 2.45.2 From 51f1a5a2d82f48e2d9b774f65a35ff05fa9e8a18 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 19 Aug 2024 12:04:18 +0200 Subject: [PATCH 04/85] add NewsService --- src/EzLanManager.py | 33 +++++++++-------- .../services/AccountingService.py | 4 +-- .../services/DatabaseService.py | 36 +++++++++++++++++++ src/ez_lan_manager/services/NewsService.py | 31 ++++++++++++++++ src/ez_lan_manager/types/News.py | 15 ++++++++ 5 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 src/ez_lan_manager/services/NewsService.py create mode 100644 src/ez_lan_manager/types/News.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 3fbd57c..f43686d 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, date from from_root import from_root @@ -9,7 +9,9 @@ from src.ez_lan_manager.services.DatabaseService import DatabaseService from random import randint +from src.ez_lan_manager.services.NewsService import NewsService from src.ez_lan_manager.services.UserService import UserService +from src.ez_lan_manager.types.News import News from src.ez_lan_manager.types.Transaction import Transaction from src.ez_lan_manager.types.User import User @@ -21,20 +23,17 @@ if __name__ == "__main__": db_config = configuration_service.get_database_configuration() db_service = DatabaseService(db_config) user_service = UserService(db_service) - accounting_service = AccountingService(db_service, user_service) - #user_service.create_user("Mamfred", "Peter@peterson.com", "MamaHalloDoo") - # db_service.add_transaction(Transaction( - # user_id=19, - # value=50, - # is_debit=True, - # reference="Ein teures Bier", - # transaction_date=datetime.now() + accounting_service = AccountingService(db_service) + news_service = NewsService(db_service) + print(news_service.get_latest_news()) + + # news_service.add_news(News( + # news_id=None, + # title=f"TITLE{randint(0, 9999)}", + # subtitle="", + # content="", + # author=user_service.get_user(19), + # news_date=date(2024, 8, 30) # )) - #print(accounting_service.remove_balance(19, 150, "EinsFuffzig")) - # print(db_service.create_user(f"TestUser{randint(0, 9999)}", f"TestMail{randint(0, 9999)}", "pw123")) - # print(db_service.update_user( - # User(user_id=19, user_name='TestUser838', user_mail='TestMail3142', user_password='pw123', user_first_name=None, user_last_name=None, - # user_birth_day=None, is_active=False, is_team_member=False, is_admin=False, created_at=datetime(2024, 8, 19, 10, 10, 39), - # last_updated_at=datetime(2024, 8, 19, 10, 10, 39), balance=0) - # - # )) \ No newline at end of file + + diff --git a/src/ez_lan_manager/services/AccountingService.py b/src/ez_lan_manager/services/AccountingService.py index e2a0ba0..2190319 100644 --- a/src/ez_lan_manager/services/AccountingService.py +++ b/src/ez_lan_manager/services/AccountingService.py @@ -2,7 +2,6 @@ import logging from datetime import datetime from src.ez_lan_manager.services.DatabaseService import DatabaseService -from src.ez_lan_manager.services.UserService import UserService from src.ez_lan_manager.types.Transaction import Transaction logger = logging.getLogger(__name__.split(".")[-1]) @@ -11,9 +10,8 @@ class InsufficientFundsError(Exception): pass class AccountingService: - def __init__(self, db_service: DatabaseService, user_service: UserService) -> None: + def __init__(self, db_service: DatabaseService) -> None: self._db_service = db_service - self._user_service = user_service def add_balance(self, user_id: int, balance_to_add: int, reference: str) -> int: self._db_service.add_transaction(Transaction( diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index a72eea7..370422f 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -1,11 +1,13 @@ import logging import sys +from datetime import date from typing import Optional import mariadb from mariadb import Cursor from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration +from src.ez_lan_manager.types.News import News from src.ez_lan_manager.types.Transaction import Transaction from src.ez_lan_manager.types.User import User @@ -142,3 +144,37 @@ class DatabaseService: reference=transaction_raw[5] )) return transactions + + def add_news(self, news: News) -> None: + cursor = self._get_cursor() + try: + cursor.execute( + "INSERT INTO news (news_content, news_title, news_subtitle, news_author, news_date) " + "VALUES (?, ?, ?, ?, ?)", + (news.content, news.title, news.subtitle, news.author.user_id, news.news_date) + ) + self._connection.commit() + except Exception as e: + logger.warning(f"Error adding Transaction: {e}") + + def get_news(self, dt_start: date, dt_end: date) -> list[News]: + results = [] + cursor = self._get_cursor() + try: + cursor.execute("SELECT * FROM news INNER JOIN users ON news.news_author = users.user_id WHERE news_date BETWEEN ? AND ?;", (dt_start, dt_end)) + except Exception as e: + logger.warning(f"Error fetching news: {e}") + return [] + + for news_raw in cursor.fetchall(): + user = self._map_db_result_to_user(news_raw[6:]) + results.append(News( + news_id=news_raw[0], + title=news_raw[2], + subtitle=news_raw[3], + author=user, + content=news_raw[1], + news_date=news_raw[5] + )) + + return results diff --git a/src/ez_lan_manager/services/NewsService.py b/src/ez_lan_manager/services/NewsService.py new file mode 100644 index 0000000..14ab77b --- /dev/null +++ b/src/ez_lan_manager/services/NewsService.py @@ -0,0 +1,31 @@ +import logging +from datetime import date, datetime +from typing import Optional + +from src.ez_lan_manager.services.DatabaseService import DatabaseService +from src.ez_lan_manager.types.News import News + +logger = logging.getLogger(__name__.split(".")[-1]) + +class NewsService: + def __init__(self, db_service: DatabaseService) -> None: + self._db_service = db_service + + def add_news(self, news: News) -> None: + if news.news_id is not None: + logger.warning("Can not add news with ID, ignoring...") + return + self._db_service.add_news(news) + + def get_news(self, dt_start: Optional[date] = None, dt_end: Optional[date] = None) -> list[News]: + if not dt_end: + dt_end = date.today() + if not dt_start: + dt_start = date(1900, 1, 1) + return self._db_service.get_news(dt_start, dt_end) + + def get_latest_news(self) -> Optional[News]: + try: + return self.get_news(None, date.today())[0] + except IndexError: + logger.debug("There are no news to fetch") diff --git a/src/ez_lan_manager/types/News.py b/src/ez_lan_manager/types/News.py new file mode 100644 index 0000000..3bc92bb --- /dev/null +++ b/src/ez_lan_manager/types/News.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from datetime import date +from typing import Optional + +from src.ez_lan_manager.types.User import User + + +@dataclass(frozen=True) +class News: + news_id: Optional[int] + title: str + subtitle: str + content: str + author: User + news_date: date -- 2.45.2 From c20d437b55beb66a7f4887fa57a496848c49e0c7 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 19 Aug 2024 12:09:56 +0200 Subject: [PATCH 05/85] add password auth --- src/ez_lan_manager/services/UserService.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ez_lan_manager/services/UserService.py b/src/ez_lan_manager/services/UserService.py index c1e056f..a671aa1 100644 --- a/src/ez_lan_manager/services/UserService.py +++ b/src/ez_lan_manager/services/UserService.py @@ -37,6 +37,13 @@ class UserService: raise NameNotAllowedError(disallowed_char) return self._db_service.update_user(user) + def is_login_valid(self, user_name_or_mail: str, password_clear_text: str) -> bool: + user = self.get_user(user_name_or_mail) + if not user: + return False + return user.user_password == sha256(password_clear_text.encode(encoding="utf-8")).hexdigest() + + def _check_for_disallowed_char(self, name: str) -> Optional[str]: for c in name: if c not in self.ALLOWED_USER_NAME_SYMBOLS: -- 2.45.2 From 364f11f6337569ae9daa9b68a3318cd549e28bde Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 19 Aug 2024 13:30:50 +0200 Subject: [PATCH 06/85] add Mailing Service --- config/config.example.toml | 7 +++++ src/EzLanManager.py | 8 ++++- .../services/ConfigurationService.py | 17 ++++++++++- src/ez_lan_manager/services/MailingService.py | 30 +++++++++++++++++++ .../types/ConfigurationTypes.py | 8 +++++ 5 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 src/ez_lan_manager/services/MailingService.py diff --git a/config/config.example.toml b/config/config.example.toml index f5c24bb..84d67d2 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -4,3 +4,10 @@ db_host="127.0.0.1" db_port=3306 db_name="ez_lan_manager" + +[mailing] + smtp_server="" + smtp_port=587 + sender="" + username="" + password="" diff --git a/src/EzLanManager.py b/src/EzLanManager.py index f43686d..0571daa 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -9,6 +9,7 @@ from src.ez_lan_manager.services.DatabaseService import DatabaseService from random import randint +from src.ez_lan_manager.services.MailingService import MailingService from src.ez_lan_manager.services.NewsService import NewsService from src.ez_lan_manager.services.UserService import UserService from src.ez_lan_manager.types.News import News @@ -25,7 +26,12 @@ if __name__ == "__main__": user_service = UserService(db_service) accounting_service = AccountingService(db_service) news_service = NewsService(db_service) - print(news_service.get_latest_news()) + mailing_service = MailingService(configuration_service.get_mailing_service_configuration()) + #mailing_service.send_email("Hallo von EZ LAN Mananger", "Grüße :)", "davidr.develop@gmail.com") + + + #user_service.create_user("Alex", "alex@gmail.com", "MeinPasswort") + #print(user_service.is_login_valid("Alex@gmail.com", "MeinPasswort")) # news_service.add_news(News( # news_id=None, diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py index 44a6788..151523f 100644 --- a/src/ez_lan_manager/services/ConfigurationService.py +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -3,7 +3,7 @@ from pathlib import Path import logging import tomllib -from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration +from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration, MailingServiceConfiguration logger = logging.getLogger(__name__.split(".")[-1]) @@ -29,3 +29,18 @@ class ConfigurationService: except KeyError: logger.fatal("Error loading DatabaseConfiguration, exiting...") sys.exit(1) + + + def get_mailing_service_configuration(self) -> MailingServiceConfiguration: + try: + database_configuration = self._config["mailing"] + return MailingServiceConfiguration( + smtp_server=database_configuration["smtp_server"], + smtp_port=database_configuration["smtp_port"], + sender=database_configuration["sender"], + username=database_configuration["username"], + password=database_configuration["password"] + ) + except KeyError: + logger.fatal("Error loading MailingServiceConfiguration, exiting...") + sys.exit(1) diff --git a/src/ez_lan_manager/services/MailingService.py b/src/ez_lan_manager/services/MailingService.py new file mode 100644 index 0000000..f16e479 --- /dev/null +++ b/src/ez_lan_manager/services/MailingService.py @@ -0,0 +1,30 @@ +import logging +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from smtplib import SMTP + +from src.ez_lan_manager.types.ConfigurationTypes import MailingServiceConfiguration + +logger = logging.getLogger(__name__.split(".")[-1]) + +class MailingService: + def __init__(self, configuration: MailingServiceConfiguration): + self._config = configuration + + def send_email(self, subject: str, body: str, receiver: str) -> None: + # ToDo: Check with Rio/FastAPI if this needs to be ASYNC + try: + msg = MIMEMultipart() + msg['From'] = self._config.sender + msg['To'] = receiver + msg['Subject'] = subject + + msg.attach(MIMEText(body, 'plain')) + + with SMTP(self._config.smtp_server, self._config.smtp_port) as server: + server.starttls() + server.login(self._config.username, self._config.password) + server.sendmail(self._config.sender, receiver, msg.as_string()) + + except Exception as e: + logger.error(f"Failed to send email: {e}") diff --git a/src/ez_lan_manager/types/ConfigurationTypes.py b/src/ez_lan_manager/types/ConfigurationTypes.py index 9aa58ab..2f164fc 100644 --- a/src/ez_lan_manager/types/ConfigurationTypes.py +++ b/src/ez_lan_manager/types/ConfigurationTypes.py @@ -7,3 +7,11 @@ class DatabaseConfiguration: db_host: str db_port: int db_name: str + +@dataclass(frozen=True) +class MailingServiceConfiguration: + smtp_server: str + smtp_port: int + sender: str + username: str + password: str -- 2.45.2 From b1bd1b11b5235502bfb79609f15d12db15d98bc2 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 19 Aug 2024 13:52:26 +0200 Subject: [PATCH 07/85] add LAN Info config and TicketingService Boilerplate --- config/config.example.toml | 8 +++++ .../services/ConfigurationService.py | 34 +++++++++++++++---- .../services/TicketingService.py | 7 ++++ .../types/ConfigurationTypes.py | 30 ++++++++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 src/ez_lan_manager/services/TicketingService.py diff --git a/config/config.example.toml b/config/config.example.toml index 84d67d2..675f2d7 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -1,3 +1,11 @@ +[lan] + name="EZ LAN" + iteration="0.5" + tickets={ "LUXUS" = 40, "NORMAL" = 10 } + prices={ "LUXUS" = 3000, "NORMAL" = 2500 } # Eurocent + date_from="2024-10-30 15:00:00" + date_till="2024-11-01 12:00:00" + [database] db_user="demo_user" db_password="demo_password" diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py index 151523f..f2e5f84 100644 --- a/src/ez_lan_manager/services/ConfigurationService.py +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -1,9 +1,10 @@ import sys +from datetime import datetime from pathlib import Path import logging import tomllib -from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration, MailingServiceConfiguration +from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration, MailingServiceConfiguration, LanInfo, TicketInfo logger = logging.getLogger(__name__.split(".")[-1]) @@ -33,14 +34,33 @@ class ConfigurationService: def get_mailing_service_configuration(self) -> MailingServiceConfiguration: try: - database_configuration = self._config["mailing"] + mailing_configuration = self._config["mailing"] return MailingServiceConfiguration( - smtp_server=database_configuration["smtp_server"], - smtp_port=database_configuration["smtp_port"], - sender=database_configuration["sender"], - username=database_configuration["username"], - password=database_configuration["password"] + smtp_server=mailing_configuration["smtp_server"], + smtp_port=mailing_configuration["smtp_port"], + sender=mailing_configuration["sender"], + username=mailing_configuration["username"], + password=mailing_configuration["password"] ) except KeyError: logger.fatal("Error loading MailingServiceConfiguration, exiting...") sys.exit(1) + + def get_lan_info(self) -> LanInfo: + try: + lan_info = self._config["lan"] + ticket_info = TicketInfo( + categories=list(lan_info["tickets"].keys()), + _prices=lan_info["prices"], + _available_tickets=lan_info["tickets"] + ) + return LanInfo( + name=lan_info["name"], + iteration=lan_info["iteration"], + ticket_info=ticket_info, + date_from=datetime.strptime(lan_info["date_from"], "%Y-%m-%d %H:%M:%S"), + date_till=datetime.strptime(lan_info["date_till"], "%Y-%m-%d %H:%M:%S") + ) + except KeyError: + logger.fatal("Error loading LAN Info, exiting...") + sys.exit(1) diff --git a/src/ez_lan_manager/services/TicketingService.py b/src/ez_lan_manager/services/TicketingService.py new file mode 100644 index 0000000..3b92ad0 --- /dev/null +++ b/src/ez_lan_manager/services/TicketingService.py @@ -0,0 +1,7 @@ +import logging + +logger = logging.getLogger(__name__.split(".")[-1]) + +class TicketingService: + def __init__(self) -> None: + pass \ No newline at end of file diff --git a/src/ez_lan_manager/types/ConfigurationTypes.py b/src/ez_lan_manager/types/ConfigurationTypes.py index 2f164fc..b3152ec 100644 --- a/src/ez_lan_manager/types/ConfigurationTypes.py +++ b/src/ez_lan_manager/types/ConfigurationTypes.py @@ -1,4 +1,8 @@ from dataclasses import dataclass +from datetime import datetime + +class NoSuchCategoryError(Exception): + pass @dataclass(frozen=True) class DatabaseConfiguration: @@ -8,6 +12,24 @@ class DatabaseConfiguration: db_port: int db_name: str +@dataclass(frozen=True) +class TicketInfo: + categories: list[str] + _prices: dict[str, int] + _available_tickets: dict[str, int] + + def get_price(self, category: str) -> int: + try: + return self._prices[category] + except KeyError: + raise NoSuchCategoryError + + def get_available_tickets(self, category: str) -> int: + try: + return self._available_tickets[category] + except KeyError: + raise NoSuchCategoryError + @dataclass(frozen=True) class MailingServiceConfiguration: smtp_server: str @@ -15,3 +37,11 @@ class MailingServiceConfiguration: sender: str username: str password: str + +@dataclass(frozen=True) +class LanInfo: + name: str + iteration: str + ticket_info: TicketInfo + date_from: datetime + date_till: datetime -- 2.45.2 From 96278258ef94f99ae691372585f6f25aa8ebbcc6 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 20 Aug 2024 10:57:45 +0200 Subject: [PATCH 08/85] add ticketing service --- src/EzLanManager.py | 7 +- .../services/DatabaseService.py | 73 +++++++++++++++++++ .../services/TicketingService.py | 70 +++++++++++++++++- .../types/ConfigurationTypes.py | 5 ++ src/ez_lan_manager/types/Ticket.py | 13 ++++ 5 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 src/ez_lan_manager/types/Ticket.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 0571daa..e716252 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -11,6 +11,7 @@ from random import randint from src.ez_lan_manager.services.MailingService import MailingService from src.ez_lan_manager.services.NewsService import NewsService +from src.ez_lan_manager.services.TicketingService import TicketingService from src.ez_lan_manager.services.UserService import UserService from src.ez_lan_manager.types.News import News from src.ez_lan_manager.types.Transaction import Transaction @@ -21,13 +22,17 @@ logger = logging.getLogger(__name__.split(".")[-1]) if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) configuration_service = ConfigurationService(from_root("config.toml")) + lan_info = configuration_service.get_lan_info() db_config = configuration_service.get_database_configuration() db_service = DatabaseService(db_config) user_service = UserService(db_service) accounting_service = AccountingService(db_service) news_service = NewsService(db_service) mailing_service = MailingService(configuration_service.get_mailing_service_configuration()) - #mailing_service.send_email("Hallo von EZ LAN Mananger", "Grüße :)", "davidr.develop@gmail.com") + ticketing_service = TicketingService(lan_info, db_service, accounting_service) + + print(ticketing_service.refund_ticket(19)) + #print(ticketing_service.get_available_tickets()) #user_service.create_user("Alex", "alex@gmail.com", "MeinPasswort") diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 370422f..26ab4c8 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -8,6 +8,7 @@ from mariadb import Cursor from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration from src.ez_lan_manager.types.News import News +from src.ez_lan_manager.types.Ticket import Ticket from src.ez_lan_manager.types.Transaction import Transaction from src.ez_lan_manager.types.User import User @@ -178,3 +179,75 @@ class DatabaseService: )) return results + + def get_tickets(self) -> list[Ticket]: + results = [] + cursor = self._get_cursor() + try: + cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id;", ()) + except Exception as e: + logger.warning(f"Error fetching tickets: {e}") + return [] + + for ticket_raw in cursor.fetchall(): + user = self._map_db_result_to_user(ticket_raw[3:]) + results.append(Ticket( + ticket_id=ticket_raw[0], + category=ticket_raw[1], + purchase_date=ticket_raw[3], + owner=user + )) + + return results + + def get_ticket_for_user(self, user_id: int) -> Optional[Ticket]: + cursor = self._get_cursor() + try: + cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id WHERE user_id=?;", (user_id, )) + except Exception as e: + logger.warning(f"Error fetching ticket for user: {e}") + return + + result = cursor.fetchone() + if not result: + return + + user = self._map_db_result_to_user(result[3:]) + return Ticket( + ticket_id=result[0], + category=result[1], + purchase_date=result[3], + owner=user + ) + + def generate_ticket_for_user(self, user_id: int, category: str) -> Optional[Ticket]: + cursor = self._get_cursor() + try: + cursor.execute("INSERT INTO tickets (ticket_category, user) VALUES (?, ?)", (category, user_id)) + self._connection.commit() + except Exception as e: + logger.warning(f"Error generating ticket for user: {e}") + return + + return self.get_ticket_for_user(user_id) + + def change_ticket_owner(self, ticket_id: int, new_owner_id: int) -> bool: + cursor = self._get_cursor() + try: + cursor.execute("UPDATE tickets SET user = ? WHERE ticket_id = ?;", (new_owner_id, ticket_id)) + affected_rows = cursor.rowcount + self._connection.commit() + except Exception as e: + logger.warning(f"Error transferring ticket to user: {e}") + return False + return bool(affected_rows) + + def delete_ticket(self, ticket_id: int) -> bool: + cursor = self._get_cursor() + try: + cursor.execute("DELETE FROM tickets WHERE ticket_id = ?;", (ticket_id, )) + self._connection.commit() + except Exception as e: + logger.warning(f"Error deleting ticket: {e}") + return False + return True diff --git a/src/ez_lan_manager/services/TicketingService.py b/src/ez_lan_manager/services/TicketingService.py index 3b92ad0..13975cb 100644 --- a/src/ez_lan_manager/services/TicketingService.py +++ b/src/ez_lan_manager/services/TicketingService.py @@ -1,7 +1,73 @@ import logging +from typing import Optional + +from src.ez_lan_manager.services.AccountingService import AccountingService, InsufficientFundsError +from src.ez_lan_manager.services.DatabaseService import DatabaseService +from src.ez_lan_manager.types.ConfigurationTypes import LanInfo +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) -> None: - pass \ No newline at end of file + def __init__(self, lan_info: LanInfo, db_service: DatabaseService, accounting_service: AccountingService) -> None: + self._lan_info = lan_info + self._db_service = db_service + self._accounting_service = accounting_service + + def get_total_tickets(self) -> int: + return sum([self._lan_info.ticket_info.get_available_tickets(c) for c in self._lan_info.ticket_info.categories]) + + def get_available_tickets(self) -> dict[str, int]: + result = self._lan_info.ticket_info.total_available_tickets + all_tickets = self._db_service.get_tickets() + for ticket in all_tickets: + result[ticket.category] -= 1 + + return result + + def purchase_ticket(self, user_id: int, category: str) -> Ticket: + if category not in self._lan_info.ticket_info.categories or self.get_available_tickets()[category] < 1: + raise TicketNotAvailableError(category) + + user_balance = self._accounting_service.get_balance(user_id) + if self._lan_info.ticket_info.get_price(category) > user_balance: + raise InsufficientFundsError + + if self.get_user_ticket(user_id): + raise UserAlreadyHasTicketError + + if new_ticket := self._db_service.generate_ticket_for_user(user_id, category): + self._accounting_service.remove_balance( + user_id, + self._lan_info.ticket_info.get_price(new_ticket.category), + f"TICKET {new_ticket.ticket_id}" + ) + logger.debug(f"User {user_id} purchased ticket {new_ticket.ticket_id}") + return new_ticket + + raise RuntimeError("An unknown error occurred while purchasing ticket") + + def refund_ticket(self, user_id: int) -> bool: + user_ticket = self.get_user_ticket(user_id) + if not user_ticket: + return False + + if self._db_service.delete_ticket(user_ticket.ticket_id): + self._accounting_service.add_balance(user_id, self._lan_info.ticket_info.get_price(user_ticket.category), f"TICKET REFUND {user_ticket.ticket_id}") + logger.debug(f"User {user_id} refunded ticket {user_ticket.ticket_id}") + return True + + return False + + def transfer_ticket(self, ticket_id: int, user_id: int) -> bool: + return self._db_service.change_ticket_owner(ticket_id, user_id) + + def get_user_ticket(self, user_id: int) -> Optional[Ticket]: + return self._db_service.get_ticket_for_user(user_id) diff --git a/src/ez_lan_manager/types/ConfigurationTypes.py b/src/ez_lan_manager/types/ConfigurationTypes.py index b3152ec..edb32fa 100644 --- a/src/ez_lan_manager/types/ConfigurationTypes.py +++ b/src/ez_lan_manager/types/ConfigurationTypes.py @@ -1,3 +1,4 @@ +from copy import copy from dataclasses import dataclass from datetime import datetime @@ -30,6 +31,10 @@ class TicketInfo: except KeyError: raise NoSuchCategoryError + @property + def total_available_tickets(self): + return copy(self._available_tickets) + @dataclass(frozen=True) class MailingServiceConfiguration: smtp_server: str diff --git a/src/ez_lan_manager/types/Ticket.py b/src/ez_lan_manager/types/Ticket.py new file mode 100644 index 0000000..9b7a5e3 --- /dev/null +++ b/src/ez_lan_manager/types/Ticket.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from src.ez_lan_manager.types.User import User + + +@dataclass(frozen=True) +class Ticket: + ticket_id: int + category: str + purchase_date: datetime + owner: Optional[User] = None -- 2.45.2 From b9b5e0ede06670ba9fb7373c47dd8a797a9fb7b0 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 20 Aug 2024 15:34:36 +0200 Subject: [PATCH 09/85] add SeatingService with seating plan generation --- config/README.md | 19 + config/config.example.toml | 4 + config/seating_plan.example.drawio | 256 +++++ config/seating_plan_base.example.svg | 973 ++++++++++++++++++ src/EzLanManager.py | 29 +- .../services/ConfigurationService.py | 19 +- .../services/DatabaseService.py | 43 + src/ez_lan_manager/services/SeatingService.py | 152 +++ .../types/ConfigurationTypes.py | 7 + src/ez_lan_manager/types/Seat.py | 12 + 10 files changed, 1487 insertions(+), 27 deletions(-) create mode 100644 config/README.md create mode 100644 config/seating_plan.example.drawio create mode 100644 config/seating_plan_base.example.svg create mode 100644 src/ez_lan_manager/services/SeatingService.py create mode 100644 src/ez_lan_manager/types/Seat.py diff --git a/config/README.md b/config/README.md new file mode 100644 index 0000000..1bcf39d --- /dev/null +++ b/config/README.md @@ -0,0 +1,19 @@ +# Configuration + +## config.toml + +TBD + +## seating plan + +### creation + +- Create a new seating plan via diagrams.net editor (make sure to use the "editable XML" version) +- Boxes with a letter followed by up to 3 numbers are detected as seats +- Add the property "category" to designate seats as specific category (otherwise, the default will be applied) + +### exporting + +- Export as SVG when ready +- remove the onclick listener from the root element (optional) +- add path of svg to seating configuration diff --git a/config/config.example.toml b/config/config.example.toml index 675f2d7..aeb244d 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -1,6 +1,7 @@ [lan] name="EZ LAN" iteration="0.5" + default_category="NORMAL" tickets={ "LUXUS" = 40, "NORMAL" = 10 } prices={ "LUXUS" = 3000, "NORMAL" = 2500 } # Eurocent date_from="2024-10-30 15:00:00" @@ -19,3 +20,6 @@ sender="" username="" password="" + +[seating] + base_svg_path="" diff --git a/config/seating_plan.example.drawio b/config/seating_plan.example.drawio new file mode 100644 index 0000000..00d993c --- /dev/null +++ b/config/seating_plan.example.drawio @@ -0,0 +1,256 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/seating_plan_base.example.svg b/config/seating_plan_base.example.svg new file mode 100644 index 0000000..2a5410f --- /dev/null +++ b/config/seating_plan_base.example.svg @@ -0,0 +1,973 @@ + + + + + + + + +
+
+
+ + Bürgerhaus Bottenhorn + +
+
+
+
+ + Bürgerhaus Bottenhorn + +
+
+ + + + + + + + + + + + + +
+
+
+ + Schlarfsaal + +
+
+
+
+ + Schlarfsaal + +
+
+ + + + + + + +
+
+
+ + Einlass
und
Orga +
+
+
+
+
+
+
+ + Einlass... + +
+
+ + + + +
+
+
+ + Toiletten + +
+
+
+
+ + Toiletten + +
+
+ + + + + + + + + + +
+
+
+ + Bühne + +
+
+
+
+ + Bühne + +
+
+ + + + +
+
+
+ B01 +
+
+
+
+ + B01 + +
+
+ + + + +
+
+
+ B11 +
+
+
+
+ + B11 + +
+
+ + + + +
+
+
+ B03 +
+
+
+
+ + B03 + +
+
+ + + + +
+
+
+ B10 +
+
+
+
+ + B10 + +
+
+ + + + +
+
+
+ B12 +
+
+
+
+ + B12 + +
+
+ + + + +
+
+
+ B02 +
+
+
+
+ + B02 + +
+
+ + + + +
+
+
+ C01 +
+
+
+
+ + C01 + +
+
+ + + + +
+
+
+ C11 +
+
+
+
+ + C11 + +
+
+ + + + +
+
+
+ C03 +
+
+
+
+ + C03 + +
+
+ + + + +
+
+
+ C10 +
+
+
+
+ + C10 + +
+
+ + + + +
+
+
+ C12 +
+
+
+
+ + C12 + +
+
+ + + + +
+
+
+ C02 +
+
+
+
+ + C02 + +
+
+ + + + +
+
+
+ E13 +
+
+
+
+ + E13 + +
+
+ + + + +
+
+
+ E10 +
+
+
+
+ + E10 + +
+
+ + + + +
+
+
+ E12 +
+
+
+
+ + E12 + +
+
+ + + + +
+
+
+ E11 +
+
+
+
+ + E11 + +
+
+ + + + +
+
+
+ E01 +
+
+
+
+ + E01 + +
+
+ + + + +
+
+
+ E02 +
+
+
+
+ + E02 + +
+
+ + + + +
+
+
+ E03 +
+
+
+
+ + E03 + +
+
+ + + + +
+
+
+ E04 +
+
+
+
+ + E04 + +
+
+ + + + +
+
+
+ D13 +
+
+
+
+ + D13 + +
+
+ + + + +
+
+
+ D10 +
+
+
+
+ + D10 + +
+
+ + + + +
+
+
+ D12 +
+
+
+
+ + D12 + +
+
+ + + + +
+
+
+ D11 +
+
+
+
+ + D11 + +
+
+ + + + +
+
+
+ D01 +
+
+
+
+ + D01 + +
+
+ + + + +
+
+
+ D02 +
+
+
+
+ + D02 + +
+
+ + + + +
+
+
+ D03 +
+
+
+
+ + D03 + +
+
+ + + + +
+
+
+ D04 +
+
+
+
+ + D04 + +
+
+ + + + +
+
+
+ A01 +
+
+
+
+ + A01 + +
+
+ + + + +
+
+
+ A11 +
+
+
+
+ + A11 + +
+
+ + + + +
+
+
+ A03 +
+
+
+
+ + A03 + +
+
+ + + + +
+
+
+ A10 +
+
+
+
+ + A10 + +
+
+ + + + +
+
+
+ A12 +
+
+
+
+ + A12 + +
+
+ + + + +
+
+
+ A02 +
+
+
+
+ + A02 + +
+
+ + + + +
+
+
+ + Getränke + +
+
+
+
+ + Getränke + +
+
+ + + + +
+
+
+ Neben-Ausgang +
+
+
+
+ + Neben-Ausgang + +
+
+ + + + +
+
+
+ Eingang +
+
+
+
+ + Eingang + +
+
+
+ + + + Text is not SVG - cannot display + + +
\ No newline at end of file diff --git a/src/EzLanManager.py b/src/EzLanManager.py index e716252..5d0d235 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -1,28 +1,22 @@ import logging -from datetime import datetime, date from from_root import from_root from src.ez_lan_manager.services.AccountingService import AccountingService from src.ez_lan_manager.services.ConfigurationService import ConfigurationService from src.ez_lan_manager.services.DatabaseService import DatabaseService - -from random import randint - from src.ez_lan_manager.services.MailingService import MailingService from src.ez_lan_manager.services.NewsService import NewsService +from src.ez_lan_manager.services.SeatingService import SeatingService from src.ez_lan_manager.services.TicketingService import TicketingService from src.ez_lan_manager.services.UserService import UserService -from src.ez_lan_manager.types.News import News -from src.ez_lan_manager.types.Transaction import Transaction -from src.ez_lan_manager.types.User import User - logger = logging.getLogger(__name__.split(".")[-1]) if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) configuration_service = ConfigurationService(from_root("config.toml")) lan_info = configuration_service.get_lan_info() + seating_config = configuration_service.get_seating_configuration() db_config = configuration_service.get_database_configuration() db_service = DatabaseService(db_config) user_service = UserService(db_service) @@ -30,21 +24,4 @@ if __name__ == "__main__": news_service = NewsService(db_service) mailing_service = MailingService(configuration_service.get_mailing_service_configuration()) ticketing_service = TicketingService(lan_info, db_service, accounting_service) - - print(ticketing_service.refund_ticket(19)) - #print(ticketing_service.get_available_tickets()) - - - #user_service.create_user("Alex", "alex@gmail.com", "MeinPasswort") - #print(user_service.is_login_valid("Alex@gmail.com", "MeinPasswort")) - - # news_service.add_news(News( - # news_id=None, - # title=f"TITLE{randint(0, 9999)}", - # subtitle="", - # content="", - # author=user_service.get_user(19), - # news_date=date(2024, 8, 30) - # )) - - + seating_service = SeatingService(seating_config, lan_info, db_service, ticketing_service) diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py index f2e5f84..e740fd5 100644 --- a/src/ez_lan_manager/services/ConfigurationService.py +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -4,7 +4,9 @@ from pathlib import Path import logging import tomllib -from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration, MailingServiceConfiguration, LanInfo, TicketInfo +from from_root import from_root + +from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration, MailingServiceConfiguration, LanInfo, TicketInfo, SeatingConfiguration logger = logging.getLogger(__name__.split(".")[-1]) @@ -50,6 +52,7 @@ class ConfigurationService: try: lan_info = self._config["lan"] ticket_info = TicketInfo( + default_category=lan_info["default_category"], categories=list(lan_info["tickets"].keys()), _prices=lan_info["prices"], _available_tickets=lan_info["tickets"] @@ -64,3 +67,17 @@ class ConfigurationService: except KeyError: logger.fatal("Error loading LAN Info, exiting...") sys.exit(1) + + def get_seating_configuration(self) -> SeatingConfiguration: + try: + seating_config = self._config["seating"] + base_svg_file_path = from_root(seating_config["base_svg_path"]) + if not base_svg_file_path.exists(): + logger.fatal(f"Specified seating plan SVG file was not found at {base_svg_file_path}! Exiting...") + sys.exit(1) + return SeatingConfiguration( + base_svg_path=base_svg_file_path + ) + except KeyError: + logger.fatal("Error loading seating configuration, exiting...") + sys.exit(1) diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 26ab4c8..67431d8 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -8,6 +8,7 @@ from mariadb import Cursor from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration from src.ez_lan_manager.types.News import News +from src.ez_lan_manager.types.Seat import Seat from src.ez_lan_manager.types.Ticket import Ticket from src.ez_lan_manager.types.Transaction import Transaction from src.ez_lan_manager.types.User import User @@ -251,3 +252,45 @@ class DatabaseService: logger.warning(f"Error deleting ticket: {e}") return False return True + + def generate_fresh_seats_table(self, seats: list[tuple[str, str]]) -> None: + """ WARNING: THIS WILL DELETE ALL EXISTING DATA! DO NOT USE ON PRODUCTION DATABASE! """ + cursor = self._get_cursor() + try: + cursor.execute("TRUNCATE seats;") + for seat in seats: + cursor.execute("INSERT INTO seats (seat_id, seat_category) VALUES (?, ?);", (seat[0], seat[1])) + self._connection.commit() + except Exception as e: + logger.warning(f"Error generating fresh seats table: {e}") + return + + def get_seating_info(self) -> list[Seat]: + results = [] + cursor = self._get_cursor() + try: + cursor.execute("SELECT seats.*, users.* FROM seats LEFT JOIN users ON seats.user = users.user_id;") + except Exception as e: + logger.warning(f"Error getting seats table: {e}") + return results + + + for seat_raw in cursor.fetchall(): + if seat_raw[3] is None: # Empty seat + results.append(Seat(seat_raw[0], bool(seat_raw[1]), seat_raw[2], None)) + else: + user = self._map_db_result_to_user(seat_raw[4:]) + results.append(Seat(seat_raw[0], bool(seat_raw[1]), seat_raw[2], user)) + + return results + + def seat_user(self, seat_id: str, user_id: int) -> bool: + cursor = self._get_cursor() + try: + cursor.execute("UPDATE seats SET user = ? WHERE seat_id = ?;", (user_id, seat_id)) + affected_rows = cursor.rowcount + self._connection.commit() + except Exception as e: + logger.warning(f"Error seating user: {e}") + return False + return bool(affected_rows) diff --git a/src/ez_lan_manager/services/SeatingService.py b/src/ez_lan_manager/services/SeatingService.py new file mode 100644 index 0000000..8423e7c --- /dev/null +++ b/src/ez_lan_manager/services/SeatingService.py @@ -0,0 +1,152 @@ +import logging +import re +from io import StringIO +from pathlib import Path +from typing import Optional +from xml.etree import ElementTree + +from from_root import from_root + +from src.ez_lan_manager.services.DatabaseService import DatabaseService +from src.ez_lan_manager.services.TicketingService import TicketingService +from src.ez_lan_manager.types.ConfigurationTypes import LanInfo, SeatingConfiguration +from src.ez_lan_manager.types.Seat import Seat + +logger = logging.getLogger(__name__.split(".")[-1]) + +class NoTicketError(Exception): + pass + +class SeatNotFoundError(Exception): + pass + +class WrongCategoryError(Exception): + pass + +class SeatAlreadyTakenError(Exception): + pass + +class SeatingService: + def __init__(self, seating_configuration: SeatingConfiguration, lan_info: LanInfo, db_service: DatabaseService, ticketing_service: TicketingService) -> None: + self._seating_configuration = seating_configuration + self._lan_info = lan_info + self._db_service = db_service + self._ticketing_service = ticketing_service + self._seating_plan = StringIO() + ElementTree.parse(self._seating_configuration.base_svg_path).write(self._seating_plan, encoding="unicode") + + + def get_seating(self) -> list[Seat]: + return self._db_service.get_seating_info() + + def get_seat(self, seat_id: str, cached_data: Optional[list[Seat]] = None) -> Optional[Seat]: + all_seats = self.get_seating() if not cached_data else cached_data + for seat in all_seats: + if seat.seat_id == seat_id: + return seat + + def seat_user(self, user_id: int, seat_id: str) -> None: + user_ticket = self._ticketing_service.get_user_ticket(user_id) + if not user_ticket: + raise NoTicketError + + seat = self.get_seat(seat_id) + if not seat: + raise SeatNotFoundError + + if seat.category != user_ticket.category: + raise WrongCategoryError + + if seat.user is not None: + raise SeatAlreadyTakenError + + self._db_service.seat_user(seat_id, user_id) + self.update_svg_with_seating_status() + + def generate_new_seating_table(self, seating_plan_fp: Path, no_confirm: bool = False) -> None: + if not no_confirm: + confirm = input("WARNING: THIS ACTION WILL DELETE ALL SEATING DATA! TYPE 'AGREE' TO CONTINUE: ") + if confirm != "AGREE": + logging.info("Seating table generation aborted...") + return + + et = ElementTree.parse(seating_plan_fp) + seat_ids = [] + for child in et.getroot().findall(".//mxCell"): + possible_seat_identifier = child.get("value") + try: + if re.match(r"^\w\d{1,3}$", possible_seat_identifier): + seat_ids.append((possible_seat_identifier, self._lan_info.ticket_info.default_category)) + except TypeError: + continue + + for child in et.getroot().findall(".//object"): + possible_seat_identifier = child.get("label") + try: + if re.match(r"^\w\d{1,3}$", possible_seat_identifier): + category = child.get("category") + seat_ids.append((possible_seat_identifier, category)) + except TypeError: + continue + + self._db_service.generate_fresh_seats_table(sorted(seat_ids, key=lambda sd: sd[0])) + self.update_svg_with_seating_status() + + def update_svg_with_seating_status(self) -> None: + et = ElementTree.parse(self._seating_configuration.base_svg_path) + root = et.getroot() + namespace = {'svg': root.tag.split('}')[0].strip('{')} if '}' in root.tag else {} + rect_g_pairs = [] + last_rect = None + + for elem in root.iter(): + if elem.tag == f"{{{namespace.get('svg')}}}rect": + last_rect = elem + elif elem.tag == f"{{{namespace.get('svg')}}}g": + if last_rect is not None: + rect_g_pairs.append((last_rect, elem)) + last_rect = None + + all_seats = self.get_seating() + + for rect, g in rect_g_pairs: + seat_id = self.get_seat_id_from_element(g, namespace) + if not seat_id: + continue + seat = self.get_seat(seat_id, cached_data=all_seats) + if not seat.is_blocked and seat.user is None: + rect.set("fill", "rgb(102, 255, 51)") + elif not seat.is_blocked and seat.user is not None: + rect.set("fill", "rgb(204, 0, 0)") + else: + rect.set("fill", "rgb(190,190,190)") + # @ToDo: Set URL's properly + rect.set('onclick', f"window.open('https://httpbin.org/get?seat_id={seat_id}', '_blank')") + g.set('onclick', f"window.open('https://httpbin.org/get?seat_id={seat_id}', '_blank')") + + # Debug output + et.write(from_root("debug_seating_plan.svg")) + + self._seating_plan = StringIO() + et.write(self._seating_plan, encoding='unicode') + + + @staticmethod + def get_seat_id_from_element(element: ElementTree.Element, namespace: dict) -> Optional[str]: + seat_id = None + for child in element.iter(): + if child.tag == f"{{{namespace.get('svg')}}}text": + # Extract identifier from element + seat_id = child.text.strip() if child.text else None + elif child.tag.endswith('div') and child.text: + # Extract identifier from /
+ seat_id = child.text.strip() + + if seat_id: # Break if we've already found the identifier + break + try: + if re.match(r"^\w\d{1,3}$", seat_id): + return seat_id + except TypeError: + pass + return diff --git a/src/ez_lan_manager/types/ConfigurationTypes.py b/src/ez_lan_manager/types/ConfigurationTypes.py index edb32fa..da5e6cf 100644 --- a/src/ez_lan_manager/types/ConfigurationTypes.py +++ b/src/ez_lan_manager/types/ConfigurationTypes.py @@ -1,6 +1,8 @@ from copy import copy from dataclasses import dataclass from datetime import datetime +from pathlib import Path + class NoSuchCategoryError(Exception): pass @@ -15,6 +17,7 @@ class DatabaseConfiguration: @dataclass(frozen=True) class TicketInfo: + default_category: str categories: list[str] _prices: dict[str, int] _available_tickets: dict[str, int] @@ -50,3 +53,7 @@ class LanInfo: ticket_info: TicketInfo date_from: datetime date_till: datetime + +@dataclass(frozen=True) +class SeatingConfiguration: + base_svg_path: Path diff --git a/src/ez_lan_manager/types/Seat.py b/src/ez_lan_manager/types/Seat.py new file mode 100644 index 0000000..2cabb50 --- /dev/null +++ b/src/ez_lan_manager/types/Seat.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +from typing import Optional + +from src.ez_lan_manager.types.User import User + + +@dataclass(frozen=True) +class Seat: + seat_id: str + is_blocked: bool + category: str + user: Optional[User] -- 2.45.2 From 21b2d59b8249eb29d7b0fdac5d9bb43bc091a5c7 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 21 Aug 2024 14:45:48 +0200 Subject: [PATCH 10/85] add CateringService:MenuItems --- src/EzLanManager.py | 3 + .../services/CateringService.py | 82 +++++++++++++++++ .../services/DatabaseService.py | 87 +++++++++++++++++++ src/ez_lan_manager/types/CateringMenuItem.py | 23 +++++ src/ez_lan_manager/types/CateringOrder.py | 26 ++++++ 5 files changed, 221 insertions(+) create mode 100644 src/ez_lan_manager/services/CateringService.py create mode 100644 src/ez_lan_manager/types/CateringMenuItem.py create mode 100644 src/ez_lan_manager/types/CateringOrder.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 5d0d235..e1a00c3 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -3,6 +3,7 @@ import logging from from_root import from_root from src.ez_lan_manager.services.AccountingService import AccountingService +from src.ez_lan_manager.services.CateringService import CateringService from src.ez_lan_manager.services.ConfigurationService import ConfigurationService from src.ez_lan_manager.services.DatabaseService import DatabaseService from src.ez_lan_manager.services.MailingService import MailingService @@ -10,6 +11,7 @@ from src.ez_lan_manager.services.NewsService import NewsService from src.ez_lan_manager.services.SeatingService import SeatingService from src.ez_lan_manager.services.TicketingService import TicketingService from src.ez_lan_manager.services.UserService import UserService + logger = logging.getLogger(__name__.split(".")[-1]) if __name__ == "__main__": @@ -25,3 +27,4 @@ if __name__ == "__main__": mailing_service = MailingService(configuration_service.get_mailing_service_configuration()) ticketing_service = TicketingService(lan_info, db_service, accounting_service) seating_service = SeatingService(seating_config, lan_info, db_service, ticketing_service) + catering_service = CateringService(db_service, accounting_service) diff --git a/src/ez_lan_manager/services/CateringService.py b/src/ez_lan_manager/services/CateringService.py new file mode 100644 index 0000000..d9a1bdc --- /dev/null +++ b/src/ez_lan_manager/services/CateringService.py @@ -0,0 +1,82 @@ +from typing import Optional + +from src.ez_lan_manager.services.AccountingService import AccountingService +from src.ez_lan_manager.services.DatabaseService import DatabaseService +from src.ez_lan_manager.types.CateringOrder import CateringOrder, CateringOrderStatus +from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory + +class CateringError(Exception): + def __init__(self, message: str) -> None: + self.message = message + + +class CateringService: + def __init__(self, db_service: DatabaseService, accounting_service: AccountingService): + self._db_service = db_service + self._accounting_service = accounting_service + + # ORDERS + + def place_order(self, menu_items: list[CateringMenuItem], user_id: int, is_delivery: bool = True) -> CateringOrder: + pass + + def get_orders(self) -> list[CateringOrder]: + pass + + def get_orders_for_user(self, user_id: int) -> list[CateringOrder]: + pass + + def get_orders_by_status(self, status: CateringOrderStatus) -> list[CateringOrder]: + pass + + def cancel_order(self, order: CateringOrder) -> None: + pass + + # MENU ITEMS + + def get_menu(self, category: Optional[CateringMenuItemCategory] = None) -> list[CateringMenuItem]: + items = self._db_service.get_menu_items() + if not category: + return items + return list(filter(lambda item: item.category == category, items)) + + def get_menu_item_by_id(self, menu_item_id: int) -> CateringMenuItem: + item = self._db_service.get_menu_item(menu_item_id) + if not item: + raise CateringError("Menu item not found") + return item + + def add_menu_item(self, name: str, info: str, price: int, category: CateringMenuItemCategory, is_disabled: bool = False) -> CateringMenuItem: + if new_item := 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.") + + def remove_menu_item(self, menu_item_id: int) -> bool: + return self._db_service.delete_menu_item(menu_item_id) + + def change_menu_item(self, updated_item: CateringMenuItem) -> bool: + return self._db_service.update_menu_item(updated_item) + + def disable_menu_item(self, menu_item_id: int) -> bool: + try: + item = self.get_menu_item_by_id(menu_item_id) + except CateringError: + return False + item.is_disabled = True + return self._db_service.update_menu_item(item) + + def enable_menu_item(self, menu_item_id: int) -> bool: + try: + item = self.get_menu_item_by_id(menu_item_id) + except CateringError: + return False + item.is_disabled = False + return self._db_service.update_menu_item(item) + + def disable_menu_items_by_category(self, category: CateringMenuItemCategory) -> bool: + items = self.get_menu(category=category) + return all([self.disable_menu_item(item.item_id) for item in items]) + + def enable_menu_items_by_category(self, category: CateringMenuItemCategory) -> bool: + items = self.get_menu(category=category) + return all([self.enable_menu_item(item.item_id) for item in items]) diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 67431d8..3428174 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -6,6 +6,7 @@ from typing import Optional import mariadb from mariadb import Cursor +from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration from src.ez_lan_manager.types.News import News from src.ez_lan_manager.types.Seat import Seat @@ -294,3 +295,89 @@ class DatabaseService: logger.warning(f"Error seating user: {e}") return False return bool(affected_rows) + + def get_menu_items(self) -> list[CateringMenuItem]: + results = [] + cursor = self._get_cursor() + try: + cursor.execute("SELECT * FROM catering_menu_items;") + except Exception as e: + logger.warning(f"Error fetching menu items: {e}") + return results + + for menu_item_raw in cursor.fetchall(): + results.append(CateringMenuItem( + item_id=menu_item_raw[0], + name=menu_item_raw[1], + additional_info=menu_item_raw[2], + price=menu_item_raw[3], + category=CateringMenuItemCategory(menu_item_raw[4]), + is_disabled=bool(menu_item_raw[5]) + )) + + return results + + def get_menu_item(self, menu_item_id) -> Optional[CateringMenuItem]: + cursor = self._get_cursor() + try: + cursor.execute("SELECT * FROM catering_menu_items WHERE catering_menu_item_id = ?;", (menu_item_id, )) + except Exception as e: + logger.warning(f"Error fetching menu items: {e}") + return + + raw_data = cursor.fetchone() + if raw_data is None: + return + return CateringMenuItem( + item_id=raw_data[0], + name=raw_data[1], + additional_info=raw_data[2], + price=raw_data[3], + category=CateringMenuItemCategory(raw_data[4]), + is_disabled=bool(raw_data[5]) + ) + + def add_menu_item(self, name: str, info: str, price: int, category: CateringMenuItemCategory, is_disabled: bool = False) -> Optional[CateringMenuItem]: + cursor = self._get_cursor() + try: + cursor.execute( + "INSERT INTO catering_menu_items (name, additional_info, price, category, is_disabled) VALUES (?, ?, ?, ?, ?);", + (name, info, price, category.value, is_disabled) + ) + self._connection.commit() + except Exception as e: + logger.warning(f"Error adding menu item: {e}") + return + + return CateringMenuItem( + item_id=cursor.lastrowid, + name=name, + additional_info=info, + price=price, + category=category, + is_disabled=is_disabled + ) + + def delete_menu_item(self, menu_item_id: int) -> bool: + cursor = self._get_cursor() + try: + cursor.execute("DELETE FROM catering_menu_items WHERE catering_menu_item_id = ?;", (menu_item_id,)) + self._connection.commit() + except Exception as e: + logger.warning(f"Error deleting menu item: {e}") + return False + return bool(cursor.affected_rows) + + def update_menu_item(self, updated_item: CateringMenuItem) -> bool: + cursor = self._get_cursor() + try: + cursor.execute( + "UPDATE catering_menu_items SET name = ?, additional_info = ?, price = ?, category = ?, is_disabled = ? WHERE catering_menu_item_id = ?;", + (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 + self._connection.commit() + except Exception as e: + logger.warning(f"Error updating menu item: {e}") + return False + return bool(affected_rows) diff --git a/src/ez_lan_manager/types/CateringMenuItem.py b/src/ez_lan_manager/types/CateringMenuItem.py new file mode 100644 index 0000000..e5353ff --- /dev/null +++ b/src/ez_lan_manager/types/CateringMenuItem.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from enum import StrEnum + +class CateringMenuItemCategory(StrEnum): + MAIN_COURSE = "MAIN_COURSE" + DESSERT = "DESSERT" + BEVERAGE_NON_ALCOHOLIC = "BEVERAGE_NON_ALCOHOLIC" + BEVERAGE_ALCOHOLIC = "BEVERAGE_ALCOHOLIC" + BEVERAGE_COCKTAIL = "BEVERAGE_COCKTAIL" + BEVERAGE_SHOT = "BEVERAGE_SHOT" + BREAKFAST = "BREAKFAST" + SNACK = "SNACK" + NON_FOOD = "NON_FOOD" + + +@dataclass(frozen=False) +class CateringMenuItem: + item_id: int + name: str + price: int + category: CateringMenuItemCategory + additional_info: str = str() + is_disabled: bool = False diff --git a/src/ez_lan_manager/types/CateringOrder.py b/src/ez_lan_manager/types/CateringOrder.py new file mode 100644 index 0000000..51ef04b --- /dev/null +++ b/src/ez_lan_manager/types/CateringOrder.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from enum import StrEnum + +from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem +from src.ez_lan_manager.types.User import User + + +class CateringOrderStatus(StrEnum): + RECEIVED = "RECEIVED" + DELAYED = "DELAYED" + READY_FOR_PICKUP = "READY_FOR_PICKUP" + EN_ROUTE = "EN_ROUTE" + COMPLETED = "COMPLETED" + CANCELED = "CANCELED" + +@dataclass(frozen=True) +class CateringOrder: + order_id: int + status: CateringOrderStatus + items: list[CateringMenuItem] + customer: User + is_delivery: bool = True + + @property + def price(self) -> int: + return sum([item.price for item in self.items]) -- 2.45.2 From cb0dad191626ad47e1764737aa605a26c5b1b85f Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 21 Aug 2024 23:44:39 +0200 Subject: [PATCH 11/85] WIP: Catering Orders --- src/EzLanManager.py | 12 ++++++- .../services/CateringService.py | 30 ++++++++++++++++-- .../services/DatabaseService.py | 31 ++++++++++++++++++- src/ez_lan_manager/services/UserService.py | 2 +- src/ez_lan_manager/types/CateringMenuItem.py | 3 ++ src/ez_lan_manager/types/CateringOrder.py | 10 ++++-- 6 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/EzLanManager.py b/src/EzLanManager.py index e1a00c3..ad5f8bb 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -11,6 +11,7 @@ from src.ez_lan_manager.services.NewsService import NewsService from src.ez_lan_manager.services.SeatingService import SeatingService from src.ez_lan_manager.services.TicketingService import TicketingService from src.ez_lan_manager.services.UserService import UserService +from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory, CateringMenuItem logger = logging.getLogger(__name__.split(".")[-1]) @@ -27,4 +28,13 @@ if __name__ == "__main__": mailing_service = MailingService(configuration_service.get_mailing_service_configuration()) ticketing_service = TicketingService(lan_info, db_service, accounting_service) seating_service = SeatingService(seating_config, lan_info, db_service, ticketing_service) - catering_service = CateringService(db_service, accounting_service) + catering_service = CateringService(db_service, accounting_service, user_service) + #print(catering_service.get_menu()) + + # catering_service.place_order( + # { + # CateringMenuItem(item_id=5, name='Bier', price=250, category=CateringMenuItemCategory.BEVERAGE_ALCOHOLIC, additional_info="Pils", is_disabled=False): 12, + # CateringMenuItem(item_id=6, name='Pizza Hawaii', price=900, category=CateringMenuItemCategory.MAIN_COURSE, additional_info = '', is_disabled = False): 2 + # }, + # 19 + # ) diff --git a/src/ez_lan_manager/services/CateringService.py b/src/ez_lan_manager/services/CateringService.py index d9a1bdc..837fd24 100644 --- a/src/ez_lan_manager/services/CateringService.py +++ b/src/ez_lan_manager/services/CateringService.py @@ -1,23 +1,47 @@ +import logging from typing import Optional from src.ez_lan_manager.services.AccountingService import AccountingService from src.ez_lan_manager.services.DatabaseService import DatabaseService -from src.ez_lan_manager.types.CateringOrder import CateringOrder, CateringOrderStatus +from src.ez_lan_manager.services.UserService import UserService +from src.ez_lan_manager.types.CateringOrder import CateringOrder, CateringOrderStatus, CateringMenuItemsWithAmount from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory +logger = logging.getLogger(__name__.split(".")[-1]) + class CateringError(Exception): def __init__(self, message: str) -> None: self.message = message class CateringService: - def __init__(self, db_service: DatabaseService, accounting_service: AccountingService): + def __init__(self, db_service: DatabaseService, accounting_service: AccountingService, user_service: UserService): self._db_service = db_service self._accounting_service = accounting_service + self._user_service = user_service # ORDERS - def place_order(self, menu_items: list[CateringMenuItem], user_id: int, is_delivery: bool = True) -> CateringOrder: + 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") + + user = self._user_service.get_user(user_id) + if not user: + raise CateringError("User does not exist") + + total_price = sum([item.price * quantity for item, quantity in menu_items.items()]) + if self._accounting_service.get_balance(user_id) < total_price: + raise CateringError("Insufficient funds") + + order = self._db_service.add_new_order(menu_items, user_id, is_delivery) + if order: + 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)}") + return order + + def update_order_status(self, order_id: int, new_status: CateringOrderStatus) -> None: pass def get_orders(self) -> list[CateringOrder]: diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 3428174..151e04d 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -1,12 +1,14 @@ import logging import sys -from datetime import date +from datetime import date, datetime from typing import Optional import mariadb from mariadb import Cursor +from src.ez_lan_manager.types.CateringOrder import CateringOrder from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory +from src.ez_lan_manager.types.CateringOrder import CateringMenuItemsWithAmount, CateringOrderStatus from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration from src.ez_lan_manager.types.News import News from src.ez_lan_manager.types.Seat import Seat @@ -381,3 +383,30 @@ class DatabaseService: logger.warning(f"Error updating menu item: {e}") return False return bool(affected_rows) + + def add_new_order(self, menu_items: CateringMenuItemsWithAmount, user_id: int, is_delivery: bool) -> Optional[CateringOrder]: + now = datetime.now() + cursor = self._get_cursor() + try: + cursor.execute( + "INSERT INTO orders (status, user, is_delivery, order_date) VALUES (?, ?, ?, ?);", + (CateringOrderStatus.RECEIVED.value, user_id, is_delivery, now) + ) + order_id = cursor.lastrowid + for menu_item, quantity in menu_items.items(): + cursor.execute( + "INSERT INTO order_catering_menu_item (order_id, catering_menu_item_id, quantity) VALUES (?, ?, ?);", + (order_id, menu_item.item_id, quantity) + ) + self._connection.commit() + return CateringOrder( + order_id=order_id, + order_date=now, + status=CateringOrderStatus.RECEIVED, + items=menu_items, + customer=self.get_user_by_id(user_id), + is_delivery=is_delivery + ) + except Exception as e: + logger.warning(f"Error placing order: {e}") + return diff --git a/src/ez_lan_manager/services/UserService.py b/src/ez_lan_manager/services/UserService.py index a671aa1..d971ca5 100644 --- a/src/ez_lan_manager/services/UserService.py +++ b/src/ez_lan_manager/services/UserService.py @@ -16,7 +16,7 @@ class UserService: def __init__(self, db_service: DatabaseService) -> None: self._db_service = db_service - def get_user(self, accessor: Union[str, int]) -> User: + def get_user(self, accessor: Union[str, int]) -> Optional[User]: if isinstance(accessor, int): return self._db_service.get_user_by_id(accessor) if "@" in accessor: diff --git a/src/ez_lan_manager/types/CateringMenuItem.py b/src/ez_lan_manager/types/CateringMenuItem.py index e5353ff..5de46b6 100644 --- a/src/ez_lan_manager/types/CateringMenuItem.py +++ b/src/ez_lan_manager/types/CateringMenuItem.py @@ -21,3 +21,6 @@ class CateringMenuItem: category: CateringMenuItemCategory additional_info: str = str() is_disabled: bool = False + + def __hash__(self) -> int: + return hash(str(self.item_id) + self.name) diff --git a/src/ez_lan_manager/types/CateringOrder.py b/src/ez_lan_manager/types/CateringOrder.py index 51ef04b..54aad54 100644 --- a/src/ez_lan_manager/types/CateringOrder.py +++ b/src/ez_lan_manager/types/CateringOrder.py @@ -1,9 +1,12 @@ from dataclasses import dataclass +from datetime import datetime from enum import StrEnum -from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem +from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory from src.ez_lan_manager.types.User import User +CateringMenuItemsWithAmount = dict[CateringMenuItem, int] + class CateringOrderStatus(StrEnum): RECEIVED = "RECEIVED" @@ -16,11 +19,12 @@ class CateringOrderStatus(StrEnum): @dataclass(frozen=True) class CateringOrder: order_id: int + order_date: datetime status: CateringOrderStatus - items: list[CateringMenuItem] + items: CateringMenuItemsWithAmount customer: User is_delivery: bool = True @property def price(self) -> int: - return sum([item.price for item in self.items]) + return sum([item.price for item in self.items.keys()]) -- 2.45.2 From 9bfd910ae2781958c5807459f5c3112bc10c5d5e Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 22 Aug 2024 13:37:33 +0200 Subject: [PATCH 12/85] finalize Catering Service --- src/EzLanManager.py | 2 +- .../services/CateringService.py | 20 ++++-- .../services/DatabaseService.py | 72 +++++++++++++++++++ 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/src/EzLanManager.py b/src/EzLanManager.py index ad5f8bb..a2acef8 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -12,6 +12,7 @@ from src.ez_lan_manager.services.SeatingService import SeatingService from src.ez_lan_manager.services.TicketingService import TicketingService from src.ez_lan_manager.services.UserService import UserService from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory, CateringMenuItem +from src.ez_lan_manager.types.CateringOrder import CateringOrderStatus logger = logging.getLogger(__name__.split(".")[-1]) @@ -29,7 +30,6 @@ if __name__ == "__main__": ticketing_service = TicketingService(lan_info, db_service, accounting_service) seating_service = SeatingService(seating_config, lan_info, db_service, ticketing_service) catering_service = CateringService(db_service, accounting_service, user_service) - #print(catering_service.get_menu()) # catering_service.place_order( # { diff --git a/src/ez_lan_manager/services/CateringService.py b/src/ez_lan_manager/services/CateringService.py index 837fd24..df8f771 100644 --- a/src/ez_lan_manager/services/CateringService.py +++ b/src/ez_lan_manager/services/CateringService.py @@ -41,20 +41,26 @@ class CateringService: 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)}") return order - def update_order_status(self, order_id: int, new_status: CateringOrderStatus) -> None: - pass + def update_order_status(self, order_id: int, new_status: CateringOrderStatus) -> bool: + if new_status == CateringOrderStatus.CANCELED: + # Cancelled orders need to be refunded + raise CateringError("Orders cannot be canceled this way, use CateringService.cancel_order") + return self._db_service.change_order_status(order_id, new_status) def get_orders(self) -> list[CateringOrder]: - pass + return self._db_service.get_orders() def get_orders_for_user(self, user_id: int) -> list[CateringOrder]: - pass + return self._db_service.get_orders(user_id=user_id) def get_orders_by_status(self, status: CateringOrderStatus) -> list[CateringOrder]: - pass + return self._db_service.get_orders(status=status) - def cancel_order(self, order: CateringOrder) -> None: - pass + def cancel_order(self, order: CateringOrder) -> bool: + if self._db_service.change_order_status(order.order_id, CateringOrderStatus.CANCELED): + self._accounting_service.add_balance(order.customer.user_id, order.price, f"CATERING REFUND - {order.order_id}") + return True + return False # MENU ITEMS diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 151e04d..19eac20 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -410,3 +410,75 @@ class DatabaseService: except Exception as e: logger.warning(f"Error placing order: {e}") return + + def change_order_status(self, order_id: int, status: CateringOrderStatus) -> bool: + cursor = self._get_cursor() + try: + cursor.execute( + "UPDATE orders SET status = ? WHERE order_id = ?;", + (status.value, order_id) + ) + affected_rows = cursor.rowcount + self._connection.commit() + except Exception as e: + logger.warning(f"Error updating menu item: {e}") + return False + return bool(affected_rows) + + def get_orders(self, user_id: Optional[int] = None, status: Optional[CateringOrderStatus] = None) -> list[CateringOrder]: + fetched_orders = [] + query = "SELECT * FROM orders LEFT JOIN users ON orders.user = users.user_id" + if user_id is not None and status is None: + query += f" WHERE user = {user_id};" + elif status is not None and user_id is None: + query += f" WHERE status = '{status.value}';" + elif status is not None and user_id is not None: + query += f" WHERE user = {user_id} AND status = '{status.value}';" + else: + query += ";" + cursor = self._get_cursor() + try: + cursor.execute(query) + except Exception as e: + logger.warning(f"Error getting orders: {e}") + return fetched_orders + + for raw_order in cursor.fetchall(): + fetched_orders.append( + CateringOrder( + order_id=raw_order[0], + status=CateringOrderStatus(raw_order[1]), + customer=self._map_db_result_to_user(raw_order[5:]), + items=self.get_menu_items_for_order(raw_order[0]), + is_delivery=bool(raw_order[4]), + order_date=raw_order[3], + ) + ) + + return fetched_orders + + def get_menu_items_for_order(self, order_id: int) -> CateringMenuItemsWithAmount: + cursor = self._get_cursor() + result = {} + try: + cursor.execute( + "SELECT * FROM order_catering_menu_item " + "LEFT JOIN catering_menu_items ON order_catering_menu_item.catering_menu_item_id = catering_menu_items.catering_menu_item_id " + "WHERE order_id = ?;", + (order_id, ) + ) + except Exception as e: + logger.warning(f"Error getting order items: {e}") + return result + + for order_catering_menu_item_raw in cursor.fetchall(): + result[CateringMenuItem( + item_id=order_catering_menu_item_raw[1], + name=order_catering_menu_item_raw[4], + additional_info=order_catering_menu_item_raw[5], + price=order_catering_menu_item_raw[6], + category=CateringMenuItemCategory(order_catering_menu_item_raw[7]), + is_disabled=bool(order_catering_menu_item_raw[8]) + )] = order_catering_menu_item_raw[2] + + return result -- 2.45.2 From 1b9fa0d8a6b58f7ec755f28be859740908d1cd87 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 22 Aug 2024 13:40:50 +0200 Subject: [PATCH 13/85] Add database schema --- sql/create_database.sql | 185 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 sql/create_database.sql diff --git a/sql/create_database.sql b/sql/create_database.sql new file mode 100644 index 0000000..d5aa6b2 --- /dev/null +++ b/sql/create_database.sql @@ -0,0 +1,185 @@ +CREATE DATABASE IF NOT EXISTS `ez_lan_manager` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci */; +USE `ez_lan_manager`; +-- MySQL dump 10.13 Distrib 5.7.24, for Linux (x86_64) +-- +-- Host: 127.0.0.1 Database: ez_lan_manager +-- ------------------------------------------------------ +-- Server version 5.5.5-10.11.8-MariaDB-0ubuntu0.24.04.1 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!40101 SET NAMES utf8 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Table structure for table `catering_menu_items` +-- + +DROP TABLE IF EXISTS `catering_menu_items`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `catering_menu_items` ( + `catering_menu_item_id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(45) NOT NULL, + `additional_info` varchar(300) DEFAULT '', + `price` int(11) NOT NULL DEFAULT 0, + `category` varchar(80) NOT NULL, + `is_disabled` tinyint(4) DEFAULT 0, + PRIMARY KEY (`catering_menu_item_id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `news` +-- + +DROP TABLE IF EXISTS `news`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `news` ( + `news_id` int(11) NOT NULL AUTO_INCREMENT, + `news_content` text DEFAULT NULL, + `news_title` varchar(100) DEFAULT NULL, + `news_subtitle` varchar(100) DEFAULT NULL, + `news_author` int(11) NOT NULL, + `news_date` date DEFAULT current_timestamp(), + PRIMARY KEY (`news_id`), + KEY `user_is_idx` (`news_author`), + CONSTRAINT `user_is` FOREIGN KEY (`news_author`) REFERENCES `users` (`user_id`) ON DELETE NO ACTION ON UPDATE NO ACTION +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `order_catering_menu_item` +-- + +DROP TABLE IF EXISTS `order_catering_menu_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `order_catering_menu_item` ( + `order_id` int(11) NOT NULL, + `catering_menu_item_id` int(11) NOT NULL, + `quantity` int(11) NOT NULL DEFAULT 1, + PRIMARY KEY (`order_id`,`catering_menu_item_id`), + KEY `catering_menu_item_id_idx` (`catering_menu_item_id`), + CONSTRAINT `catering_menu_item_id` FOREIGN KEY (`catering_menu_item_id`) REFERENCES `catering_menu_items` (`catering_menu_item_id`) ON DELETE NO ACTION ON UPDATE NO ACTION, + CONSTRAINT `order_id` FOREIGN KEY (`order_id`) REFERENCES `orders` (`order_id`) ON DELETE NO ACTION ON UPDATE NO ACTION +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `orders` +-- + +DROP TABLE IF EXISTS `orders`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `orders` ( + `order_id` int(11) NOT NULL AUTO_INCREMENT, + `status` varchar(45) NOT NULL, + `user` int(11) NOT NULL, + `order_date` datetime NOT NULL DEFAULT current_timestamp(), + `is_delivery` tinyint(4) NOT NULL DEFAULT 1, + PRIMARY KEY (`order_id`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `seats` +-- + +DROP TABLE IF EXISTS `seats`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `seats` ( + `seat_id` varchar(5) NOT NULL, + `is_blocked` tinyint(4) NOT NULL DEFAULT 0, + `seat_category` varchar(45) NOT NULL DEFAULT '', + `user` int(11) DEFAULT NULL, + PRIMARY KEY (`seat_id`), + UNIQUE KEY `user_UNIQUE` (`user`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `tickets` +-- + +DROP TABLE IF EXISTS `tickets`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `tickets` ( + `ticket_id` int(11) NOT NULL AUTO_INCREMENT, + `ticket_category` varchar(45) NOT NULL, + `user` int(11) NOT NULL, + `purchase_date` datetime NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`ticket_id`), + KEY `user_id_idx` (`user`), + CONSTRAINT `user` FOREIGN KEY (`user`) REFERENCES `users` (`user_id`) ON DELETE NO ACTION ON UPDATE NO ACTION +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `transactions` +-- + +DROP TABLE IF EXISTS `transactions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `transactions` ( + `transaction_id` int(11) NOT NULL AUTO_INCREMENT, + `user_id` int(11) NOT NULL, + `value` varchar(45) NOT NULL DEFAULT '0', + `is_debit` tinyint(4) NOT NULL, + `transaction_date` datetime NOT NULL DEFAULT current_timestamp(), + `transaction_reference` varchar(45) NOT NULL, + PRIMARY KEY (`transaction_id`), + UNIQUE KEY `transaction_id_UNIQUE` (`transaction_id`), + KEY `user_id_idx` (`user_id`), + CONSTRAINT `user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE NO ACTION ON UPDATE NO ACTION +) ENGINE=InnoDB AUTO_INCREMENT=19 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Table structure for table `users` +-- + +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `users` ( + `user_id` int(11) NOT NULL AUTO_INCREMENT, + `user_name` varchar(50) NOT NULL, + `user_mail` varchar(100) NOT NULL, + `user_password` varchar(255) NOT NULL, + `user_first_name` varchar(50) DEFAULT NULL, + `user_last_name` varchar(50) DEFAULT NULL, + `user_birth_date` date DEFAULT NULL, + `is_active` tinyint(4) DEFAULT NULL, + `is_team_member` tinyint(4) DEFAULT NULL, + `is_admin` tinyint(4) DEFAULT NULL, + `created_at` datetime DEFAULT current_timestamp(), + `last_updated_at` datetime DEFAULT current_timestamp(), + PRIMARY KEY (`user_id`), + UNIQUE KEY `user_id_UNIQUE` (`user_id`), + UNIQUE KEY `user_mail_UNIQUE` (`user_mail`), + UNIQUE KEY `user_name_UNIQUE` (`user_name`) +) ENGINE=InnoDB AUTO_INCREMENT=27 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2024-08-22 13:39:21 -- 2.45.2 From 69c3ea9b68fc3df1c7434af2b93e505dbdbb4abf Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 22 Aug 2024 14:00:19 +0200 Subject: [PATCH 14/85] add support for profile pictures --- src/EzLanManager.py | 7 ------ .../services/DatabaseService.py | 23 +++++++++++++++++++ src/ez_lan_manager/services/UserService.py | 6 +++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/EzLanManager.py b/src/EzLanManager.py index a2acef8..e2ab361 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -31,10 +31,3 @@ if __name__ == "__main__": seating_service = SeatingService(seating_config, lan_info, db_service, ticketing_service) catering_service = CateringService(db_service, accounting_service, user_service) - # catering_service.place_order( - # { - # CateringMenuItem(item_id=5, name='Bier', price=250, category=CateringMenuItemCategory.BEVERAGE_ALCOHOLIC, additional_info="Pils", is_disabled=False): 12, - # CateringMenuItem(item_id=6, name='Pizza Hawaii', price=900, category=CateringMenuItemCategory.MAIN_COURSE, additional_info = '', is_disabled = False): 2 - # }, - # 19 - # ) diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 19eac20..5ca0ca6 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -482,3 +482,26 @@ class DatabaseService: )] = order_catering_menu_item_raw[2] return result + + def set_user_profile_picture(self, user_id: int, picture_data: bytes) -> None: + cursor = self._get_cursor() + try: + cursor.execute( + "INSERT INTO user_profile_picture (user_id, picture) VALUES (?, ?) ON DUPLICATE KEY UPDATE picture = VALUES(picture)", + (user_id, picture_data) + ) + self._connection.commit() + except Exception as e: + logger.warning(f"Error setting user profile picture: {e}") + + def get_user_profile_picture(self, user_id: int) -> Optional[bytes]: + cursor = self._get_cursor() + try: + cursor.execute("SELECT (picture) FROM user_profile_picture WHERE user_id = ?", (user_id, )) + r = cursor.fetchone() + if r is None: + return + return r[0] + except Exception as e: + logger.warning(f"Error setting user profile picture: {e}") + return None diff --git a/src/ez_lan_manager/services/UserService.py b/src/ez_lan_manager/services/UserService.py index d971ca5..59a66e5 100644 --- a/src/ez_lan_manager/services/UserService.py +++ b/src/ez_lan_manager/services/UserService.py @@ -23,6 +23,12 @@ class UserService: return self._db_service.get_user_by_mail(accessor) return self._db_service.get_user_by_name(accessor) + def set_profile_picture(self, user_id: int, picture: bytes) -> None: + self._db_service.set_user_profile_picture(user_id, picture) + + def get_profile_picture(self, user_id: int) -> bytes: + return self._db_service.get_user_profile_picture(user_id) + def create_user(self, user_name: str, user_mail: str, password_clear_text: str) -> User: disallowed_char = self._check_for_disallowed_char(user_name) if disallowed_char: -- 2.45.2 From 446956f72169c984a6b532633ac746e1d1236ba6 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sat, 24 Aug 2024 01:57:47 +0200 Subject: [PATCH 15/85] add WIP frontend --- requirements.txt | Bin 68 -> 88 bytes src/EzLanManager.py | 58 +++++++++------- src/ez_lan_manager/__init__.py | 28 ++++++++ src/ez_lan_manager/assets/fonts/joystix.otf | Bin 0 -> 37268 bytes .../components/DesktopNavigation.py | 60 ++++++++++++++++ src/ez_lan_manager/components/LoginBox.py | 56 +++++++++++++++ src/ez_lan_manager/components/NewsPost.py | 54 +++++++++++++++ src/ez_lan_manager/components/__init__.py | 0 src/ez_lan_manager/pages/BasePage.py | 65 ++++++++++++++++++ src/ez_lan_manager/pages/NewsPage.py | 29 ++++++++ src/ez_lan_manager/pages/__init__.py | 2 + 11 files changed, 328 insertions(+), 24 deletions(-) create mode 100644 src/ez_lan_manager/assets/fonts/joystix.otf create mode 100644 src/ez_lan_manager/components/DesktopNavigation.py create mode 100644 src/ez_lan_manager/components/LoginBox.py create mode 100644 src/ez_lan_manager/components/NewsPost.py create mode 100644 src/ez_lan_manager/components/__init__.py create mode 100644 src/ez_lan_manager/pages/BasePage.py create mode 100644 src/ez_lan_manager/pages/NewsPage.py create mode 100644 src/ez_lan_manager/pages/__init__.py diff --git a/requirements.txt b/requirements.txt index c5cde3760f3f37ebd29b72e2a460e87a6566d180..8199663ecf874f1ebeb56b12743fbdcd11037474 100644 GIT binary patch delta 25 ccmZ>XnBXG9%fQ7@#E{95&!Edt3Zy}N06$v<2mk;8 delta 4 Lcma!WncxBd1C#-A diff --git a/src/EzLanManager.py b/src/EzLanManager.py index e2ab361..f24e660 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -1,33 +1,43 @@ +from __future__ import annotations import logging +from pathlib import Path +from typing import * # type: ignore + +import rio from from_root import from_root -from src.ez_lan_manager.services.AccountingService import AccountingService -from src.ez_lan_manager.services.CateringService import CateringService -from src.ez_lan_manager.services.ConfigurationService import ConfigurationService -from src.ez_lan_manager.services.DatabaseService import DatabaseService -from src.ez_lan_manager.services.MailingService import MailingService -from src.ez_lan_manager.services.NewsService import NewsService -from src.ez_lan_manager.services.SeatingService import SeatingService -from src.ez_lan_manager.services.TicketingService import TicketingService -from src.ez_lan_manager.services.UserService import UserService -from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory, CateringMenuItem -from src.ez_lan_manager.types.CateringOrder import CateringOrderStatus +from src.ez_lan_manager import pages, init_services logger = logging.getLogger(__name__.split(".")[-1]) if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) - configuration_service = ConfigurationService(from_root("config.toml")) - lan_info = configuration_service.get_lan_info() - seating_config = configuration_service.get_seating_configuration() - db_config = configuration_service.get_database_configuration() - db_service = DatabaseService(db_config) - user_service = UserService(db_service) - accounting_service = AccountingService(db_service) - news_service = NewsService(db_service) - mailing_service = MailingService(configuration_service.get_mailing_service_configuration()) - ticketing_service = TicketingService(lan_info, db_service, accounting_service) - seating_service = SeatingService(seating_config, lan_info, db_service, ticketing_service) - catering_service = CateringService(db_service, accounting_service, user_service) + theme = rio.Theme.from_colors( + primary_color=rio.Color.from_hex("ffffff"), + secondary_color=rio.Color.from_hex("018786"), + neutral_color=rio.Color.from_hex("1e1e1e"), + background_color=rio.Color.from_hex("121212"), + hud_color=rio.Color.from_hex("02dac5"), + text_color=rio.Color.from_hex("018786"), + mode="dark", + corner_radius_small=0, + corner_radius_medium=0, + corner_radius_large=0, + font=rio.Font(from_root("src/ez_lan_manager/assets/fonts/joystix.otf")) + ) + app = rio.App( + name='', + pages=[ + rio.Page( + name="News", + page_url='', + build=pages.NewsPage, + ), + ], + theme=theme, + assets_dir=Path(__file__).parent / "assets", + default_attachments=init_services() + ) + + app.run_as_web_server() diff --git a/src/ez_lan_manager/__init__.py b/src/ez_lan_manager/__init__.py index 8ba3caa..28b5bb0 100644 --- a/src/ez_lan_manager/__init__.py +++ b/src/ez_lan_manager/__init__.py @@ -1,2 +1,30 @@ +import logging + +from from_root import from_root + from src.ez_lan_manager.services import * +from src.ez_lan_manager.services.AccountingService import AccountingService +from src.ez_lan_manager.services.CateringService import CateringService +from src.ez_lan_manager.services.ConfigurationService import ConfigurationService +from src.ez_lan_manager.services.DatabaseService import DatabaseService +from src.ez_lan_manager.services.MailingService import MailingService +from src.ez_lan_manager.services.NewsService import NewsService +from src.ez_lan_manager.services.SeatingService import SeatingService +from src.ez_lan_manager.services.TicketingService import TicketingService +from src.ez_lan_manager.services.UserService import UserService from src.ez_lan_manager.types import * + +# Inits services in the correct order +def init_services() -> tuple[AccountingService, CateringService, ConfigurationService, DatabaseService, MailingService, NewsService, SeatingService, TicketingService, UserService]: + logging.basicConfig(level=logging.DEBUG) + configuration_service = ConfigurationService(from_root("config.toml")) + db_service = DatabaseService(configuration_service.get_database_configuration()) + user_service = UserService(db_service) + accounting_service = AccountingService(db_service) + news_service = NewsService(db_service) + mailing_service = MailingService(configuration_service.get_mailing_service_configuration()) + ticketing_service = TicketingService(configuration_service.get_lan_info(), db_service, accounting_service) + seating_service = SeatingService(configuration_service.get_seating_configuration(), configuration_service.get_lan_info(), db_service, ticketing_service) + catering_service = CateringService(db_service, accounting_service, user_service) + + return accounting_service, catering_service, configuration_service, db_service, mailing_service, news_service, seating_service, ticketing_service, user_service diff --git a/src/ez_lan_manager/assets/fonts/joystix.otf b/src/ez_lan_manager/assets/fonts/joystix.otf new file mode 100644 index 0000000000000000000000000000000000000000..20a3100fb5a778e03bf130027ce1b1c01444e24b GIT binary patch literal 37268 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 KWdi<#z5fSo7^(aK literal 0 HcmV?d00001 diff --git a/src/ez_lan_manager/components/DesktopNavigation.py b/src/ez_lan_manager/components/DesktopNavigation.py new file mode 100644 index 0000000..6067368 --- /dev/null +++ b/src/ez_lan_manager/components/DesktopNavigation.py @@ -0,0 +1,60 @@ +from rio import * + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.LoginBox import LoginBox + + +class DesktopNavigationButton(Component): + STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + label: str + target_url: str + open_new_tab: bool = False + + def build(self) -> Component: + return Link( + content=Button( + content=Text(self.label, style=self.STYLE), + shape="rectangle", + style="minor", + color="secondary", + grow_x=True, + margin_left=0.6, + margin_right=0.6, + margin_top=0.6 + ), + target_url=self.target_url, + open_in_new_tab=self.open_new_tab + ) + + +class DesktopNavigation(Component): + def build(self) -> Component: + lan_info = self.session[ConfigurationService].get_lan_info() + return Card( + Column( + Text(lan_info.name, align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.hud_color, font_size=2.5)), + Text(f"Edition {lan_info.iteration}", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=1.2), margin_top=0.3, margin_bottom=2), + LoginBox(), + DesktopNavigationButton("News", "./news"), + Spacer(min_height=1), + DesktopNavigationButton(f"Über {lan_info.name} {lan_info.iteration}", "./overview"), + DesktopNavigationButton("Ticket kaufen", "./buy_ticket"), + DesktopNavigationButton("Sitzplan", "./seating"), + DesktopNavigationButton("Catering", "./catering"), + DesktopNavigationButton("Teilnehmer", "./guests"), + DesktopNavigationButton("Turniere", "./tournaments"), + DesktopNavigationButton("FAQ", "./faq"), + DesktopNavigationButton("Regeln & AGB", "./rules-and-agb"), + Spacer(min_height=1), + DesktopNavigationButton("Die EZ GG e.V.", "https://ezgg-ev.de/about", open_new_tab=True), + DesktopNavigationButton("Kontakt", "./contact"), + DesktopNavigationButton("Impressum & DSGVO", "./imprint"), + Spacer(min_height=1), + align_y=0 + ), + color=self.session.theme.neutral_color, + min_width=15, + grow_y=True, + corner_radius=(0.5, 0, 0, 0), + margin_right=0.1 + ) diff --git a/src/ez_lan_manager/components/LoginBox.py b/src/ez_lan_manager/components/LoginBox.py new file mode 100644 index 0000000..01b97cf --- /dev/null +++ b/src/ez_lan_manager/components/LoginBox.py @@ -0,0 +1,56 @@ +from rio import Component, Card, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput + + +class LoginBox(Component): + TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + def build(self) -> Component: + return Rectangle( + content=Column( + TextInput( + text="", + label="Benutzername", + accessibility_label = "Benutzername", + min_height=0.5 + ), + TextInput( + text="", + label="Passwort", + accessibility_label="Passwort", + is_secret=True + ), + Column( + Row( + Button( + Text("LOGIN", style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary", + margin_bottom=0.4 + ) + ), + Row( + Button( + Text("REG", style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary" + ), + Spacer(), + Button( + Text("LST PWD",style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary" + ), + proportions=(49, 2, 49) + ) + ), + spacing=0.4 + ), + fill=Color.TRANSPARENT, + min_height=8, + min_width=12, + align_x=0.5, + margin_top=0.3, + margin_bottom=2 + ) diff --git a/src/ez_lan_manager/components/NewsPost.py b/src/ez_lan_manager/components/NewsPost.py new file mode 100644 index 0000000..edf6f8e --- /dev/null +++ b/src/ez_lan_manager/components/NewsPost.py @@ -0,0 +1,54 @@ +from rio import Component, Rectangle, Text, TextStyle, Column, Row + + +class NewsPost(Component): + title: str = "" + text: str = "" + date: str = "" + + def build(self) -> Component: + return Rectangle( + content=Column( + Row( + Text( + self.title, + align_x=0, + grow_x=True, + margin=2, + margin_bottom=0, + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.3 + ), + wrap=False + ), + Text( + self.date, + margin=2, + align_x=1, + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.6 + ), + wrap=True + ) + ), + Text( + self.text, + margin=2, + style=TextStyle( + fill=self.session.theme.background_color + ), + wrap=True + ) + ), + fill=self.session.theme.primary_color, + margin_left=1, + margin_right=1, + margin_top=2, + margin_bottom=1, + shadow_radius=0.5, + shadow_color=self.session.theme.hud_color, + shadow_offset_y=0, + corner_radius=0.2 + ) diff --git a/src/ez_lan_manager/components/__init__.py b/src/ez_lan_manager/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ez_lan_manager/pages/BasePage.py b/src/ez_lan_manager/pages/BasePage.py new file mode 100644 index 0000000..48332b1 --- /dev/null +++ b/src/ez_lan_manager/pages/BasePage.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import * # type: ignore + +from rio import Component, event, Spacer, Card, Container, Column, Row, Rectangle, TextStyle, Color, Text + +from src.ez_lan_manager.components.DesktopNavigation import DesktopNavigation + +class BasePage(Component): + content: Component + + @event.on_window_size_change + async def on_window_size_change(self): + await self.force_refresh() + + def build(self) -> Component: + if self.content is None: + content = Spacer() + else: + content = Card( + self.content, + color="secondary", + min_width=38, + corner_radius=(0, 0.5, 0, 0) + ) + if self.session.window_width > 28: + return Container( + content=Column( + Row(), + Column( + Row( + Spacer(grow_x=True, grow_y=True), + DesktopNavigation(), + content, + Spacer(grow_x=True, grow_y=True), + grow_y=True + ), + Row( + Spacer(grow_x=True, grow_y=False), + Card( + content=Text("EZ LAN Manager Version 0.0.1 © EZ GG e.V.", align_x=0.5, align_y=0.5, style=TextStyle(fill=self.session.theme.primary_color, font_size=0.5)), + color=self.session.theme.neutral_color, + corner_radius=(0, 0, 0.5, 0.5), + grow_x=False, + grow_y=False, + min_height=1.2, + min_width=53.1 + ), + Spacer(grow_x=True, grow_y=False), + grow_y=False + ) + ), + Row(), + proportions=[4, 92, 4] + ), + grow_x=True, + grow_y=True + ) + else: + return Text( + "Der EZ LAN Manager wird\nauf mobilen Endgeräten nur\nim Querformat unterstützt.\nBitte drehe dein Gerät.", + align_x=0.5, + align_y=0.5, + style=TextStyle(fill=Color.from_hex("FFFFFF"), font_size=0.8) + ) diff --git a/src/ez_lan_manager/pages/NewsPage.py b/src/ez_lan_manager/pages/NewsPage.py new file mode 100644 index 0000000..ab000dd --- /dev/null +++ b/src/ez_lan_manager/pages/NewsPage.py @@ -0,0 +1,29 @@ +from rio import Text, Column, Rectangle, TextStyle, Component + +from src.ez_lan_manager.components.NewsPost import NewsPost +from src.ez_lan_manager.pages import BasePage + +class NewsPage(Component): + def build(self) -> Component: + return BasePage( + content=Column( + NewsPost( + title="EZ LAN Manager", + text="Der EZ LAN Manager ist die offizielle Software der EZ GG e.V. um LAn-Parties zu verwalten." + "Ist schon echt cool wie der funktioniert! So kann LAN Party richtig geschmeidig ablaufen.", + date="23.08.2024" + ), + NewsPost( + title="Alkohöl", + text="Der Verein 'EZ GG e.V.' ist bekannt für seinen unstillbaren Durst. " + "Bei jedem Treffen fließt der Alkohol in Strömen – egal ob Bier, Wein oder Hochprozentiges. " + "Kein Glas bleibt lange leer, und bevor der Pegel auch nur ansatzweise sinkt, " + "wird schon nachgefüllt. Die Mitglieder feiern ausgiebig und trinken dabei so viel, " + "dass die Vorräte nie lange halten. Bei jeder Gelegenheit wird angestoßen, " + "die Stimmung steigt und der Alkohol fließt ohne Ende. " + "Ihr Motto: 'Kein Abend ohne reichlich Alkohol!'", + date="23.08.2024" + ), + align_y=0, + ) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py new file mode 100644 index 0000000..8ba84d0 --- /dev/null +++ b/src/ez_lan_manager/pages/__init__.py @@ -0,0 +1,2 @@ +from .BasePage import BasePage +from .NewsPage import NewsPage -- 2.45.2 From f2dfaa78f304a505596641039bfc1fceaaed4e85 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sat, 24 Aug 2024 09:13:32 +0200 Subject: [PATCH 16/85] add placeholder pages, cleanup imports and set window titles dynamically --- src/EzLanManager.py | 88 +++++++++++++++---- .../components/DesktopNavigation.py | 2 +- src/ez_lan_manager/pages/NewsPage.py | 7 +- src/ez_lan_manager/pages/PlaceholderPage.py | 24 +++++ src/ez_lan_manager/pages/__init__.py | 1 + 5 files changed, 104 insertions(+), 18 deletions(-) create mode 100644 src/ez_lan_manager/pages/PlaceholderPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index f24e660..ad6bc88 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -1,10 +1,8 @@ -from __future__ import annotations import logging from pathlib import Path -from typing import * # type: ignore -import rio +from rio import App, Theme, Color, Font, Page from from_root import from_root from src.ez_lan_manager import pages, init_services @@ -12,32 +10,90 @@ from src.ez_lan_manager import pages, init_services logger = logging.getLogger(__name__.split(".")[-1]) if __name__ == "__main__": - theme = rio.Theme.from_colors( - primary_color=rio.Color.from_hex("ffffff"), - secondary_color=rio.Color.from_hex("018786"), - neutral_color=rio.Color.from_hex("1e1e1e"), - background_color=rio.Color.from_hex("121212"), - hud_color=rio.Color.from_hex("02dac5"), - text_color=rio.Color.from_hex("018786"), + theme = Theme.from_colors( + primary_color=Color.from_hex("ffffff"), + secondary_color=Color.from_hex("018786"), + neutral_color=Color.from_hex("1e1e1e"), + background_color=Color.from_hex("121212"), + hud_color=Color.from_hex("02dac5"), + text_color=Color.from_hex("018786"), mode="dark", corner_radius_small=0, corner_radius_medium=0, corner_radius_large=0, - font=rio.Font(from_root("src/ez_lan_manager/assets/fonts/joystix.otf")) + font=Font(from_root("src/ez_lan_manager/assets/fonts/joystix.otf")) ) - app = rio.App( - name='', + services = init_services() + + app = App( + name="EZ LAN Manager", pages=[ - rio.Page( + Page( name="News", - page_url='', + page_url="", build=pages.NewsPage, ), + Page( + name="News", + page_url="news", + build=pages.NewsPage, + ), + Page( + name="Overview", + page_url="overview", + build=lambda: pages.PlaceholderPage(placeholder_name="LAN Übersicht"), + ), + Page( + name="BuyTicket", + page_url="buy_ticket", + build=lambda: pages.PlaceholderPage(placeholder_name="Tickets kaufen"), + ), + Page( + name="SeatingPlan", + page_url="seating", + build=lambda: pages.PlaceholderPage(placeholder_name="Sitzplan"), + ), + Page( + name="Catering", + page_url="catering", + build=lambda: pages.PlaceholderPage(placeholder_name="Catering"), + ), + Page( + name="Guests", + page_url="guests", + build=lambda: pages.PlaceholderPage(placeholder_name="Teilnehmer"), + ), + Page( + name="Tournaments", + page_url="tournaments", + build=lambda: pages.PlaceholderPage(placeholder_name="Turniere"), + ), + Page( + name="FAQ", + page_url="faq", + build=lambda: pages.PlaceholderPage(placeholder_name="FAQ"), + ), + Page( + name="RulesGTC", + page_url="rules-gtc", + build=lambda: pages.PlaceholderPage(placeholder_name="Regeln & AGB"), + ), + Page( + name="Contact", + page_url="contact", + build=lambda: pages.PlaceholderPage(placeholder_name="Kontakt"), + ), + Page( + name="Imprint", + page_url="imprint", + build=lambda: pages.PlaceholderPage(placeholder_name="Impressum & DSGVO"), + ) ], theme=theme, assets_dir=Path(__file__).parent / "assets", - default_attachments=init_services() + default_attachments=services, + on_session_start= lambda s: s.set_title(services[2].get_lan_info().name) ) app.run_as_web_server() diff --git a/src/ez_lan_manager/components/DesktopNavigation.py b/src/ez_lan_manager/components/DesktopNavigation.py index 6067368..58842e5 100644 --- a/src/ez_lan_manager/components/DesktopNavigation.py +++ b/src/ez_lan_manager/components/DesktopNavigation.py @@ -44,7 +44,7 @@ class DesktopNavigation(Component): DesktopNavigationButton("Teilnehmer", "./guests"), DesktopNavigationButton("Turniere", "./tournaments"), DesktopNavigationButton("FAQ", "./faq"), - DesktopNavigationButton("Regeln & AGB", "./rules-and-agb"), + DesktopNavigationButton("Regeln & AGB", "./rules-gtc"), Spacer(min_height=1), DesktopNavigationButton("Die EZ GG e.V.", "https://ezgg-ev.de/about", open_new_tab=True), DesktopNavigationButton("Kontakt", "./contact"), diff --git a/src/ez_lan_manager/pages/NewsPage.py b/src/ez_lan_manager/pages/NewsPage.py index ab000dd..a187c15 100644 --- a/src/ez_lan_manager/pages/NewsPage.py +++ b/src/ez_lan_manager/pages/NewsPage.py @@ -1,9 +1,14 @@ -from rio import Text, Column, Rectangle, TextStyle, Component +from rio import Text, Column, Rectangle, TextStyle, Component, event +from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.NewsPost import NewsPost from src.ez_lan_manager.pages import BasePage class NewsPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Neuigkeiten") + def build(self) -> Component: return BasePage( content=Column( diff --git a/src/ez_lan_manager/pages/PlaceholderPage.py b/src/ez_lan_manager/pages/PlaceholderPage.py new file mode 100644 index 0000000..ee54c88 --- /dev/null +++ b/src/ez_lan_manager/pages/PlaceholderPage.py @@ -0,0 +1,24 @@ +from rio import Column, Component, event + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.NewsPost import NewsPost +from src.ez_lan_manager.pages import BasePage + +class PlaceholderPage(Component): + placeholder_name: str + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.placeholder_name}") + + def build(self) -> Component: + return BasePage( + content=Column( + NewsPost( + title="Platzhalter", + text=f"Dies ist die Platzhalterseite für {self.placeholder_name}.", + date="99.99.9999" + ), + align_y=0, + ) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 8ba84d0..34f617e 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -1,2 +1,3 @@ from .BasePage import BasePage from .NewsPage import NewsPage +from .PlaceholderPage import PlaceholderPage -- 2.45.2 From 1355e8387e14ddca1fabf67ccc8db4d2319c3290 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sat, 24 Aug 2024 09:20:41 +0200 Subject: [PATCH 17/85] add meta tags, prepare icon --- src/EzLanManager.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/EzLanManager.py b/src/EzLanManager.py index ad6bc88..71413a9 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -25,6 +25,7 @@ if __name__ == "__main__": ) services = init_services() + lan_info = services[2].get_lan_info() app = App( name="EZ LAN Manager", @@ -93,7 +94,23 @@ if __name__ == "__main__": theme=theme, assets_dir=Path(__file__).parent / "assets", default_attachments=services, - on_session_start= lambda s: s.set_title(services[2].get_lan_info().name) + on_session_start= lambda s: s.set_title(lan_info.name), + #icon=from_root(""), ToDo + meta_tags={ + "robots": "INDEX,FOLLOW", + "description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.", + "og:description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.", + "keywords": "Gaming, Clan, Guild, Verein, Club, Einfach, Zocken, Genuss, Gesellschaft, Videospiele, " + "Videogames, LAN, Party, EZ, LAN, Manager", + "author": "David Rodenkirchen", + "publisher": "EZ GG e.V.", + "copyright": "EZ GG e.V.", + "audience": "Alle", + "page-type": "Management Application", + "page-topic": "LAN Party", + "expires": "", + "revisit-after": "2 days" + } ) app.run_as_web_server() -- 2.45.2 From 8a47e95c2a8b91ae321450fe8696024b7f51d138 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sat, 24 Aug 2024 09:25:39 +0200 Subject: [PATCH 18/85] add favicoN --- src/EzLanManager.py | 2 +- src/ez_lan_manager/assets/img/favicon.png | Bin 0 -> 1247 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 src/ez_lan_manager/assets/img/favicon.png diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 71413a9..5840aa0 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -95,7 +95,7 @@ if __name__ == "__main__": assets_dir=Path(__file__).parent / "assets", default_attachments=services, on_session_start= lambda s: s.set_title(lan_info.name), - #icon=from_root(""), ToDo + icon=from_root("src/ez_lan_manager/assets/img/favicon.png"), meta_tags={ "robots": "INDEX,FOLLOW", "description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.", diff --git a/src/ez_lan_manager/assets/img/favicon.png b/src/ez_lan_manager/assets/img/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..b6a50039b8db55d3c556db19ad49bbfada3e6102 GIT binary patch literal 1247 zcmV<51R(o~P)EX>4Tx04R}tkv&MmKpe$iQ>8^J9Sl^&AwzYti;6hbDionYs1;guFdzMbCJjl7 zi=*ILaPVWX>fqw6tAnc`2!4P#J2)x2NQwVT3N2zhIPS;0dyl(!0N1K1n$?#CG~G6{ z>6DN!tO}u5gb@ReC}NUg8FQkTf$#XbM}Vz&36|x5?$0r(7A*w`2*gRnFm2)u;+aj` zV7yPvD-}s4J|~_q>4LZ+&~7@;_p#%&Pk`VvaHV(s^%gMuNqV!Z z#gBmKHgIv>)#N?kat9cGG9*)Sr65hNR07`5=$i__&@C{q=JnRu$LRx*qpmVHz`-Ff zUZ(7IpLd6Qd;9lHr@tS3SaPcC(mQDY000JJOGiWi{{a60|De66lK=n!32;bRa{vG? zBLDy{BLR4&KXw2B00(qQO+^Rj2p9(#5DER6MgRZ+8FWQhbVF}#ZDnqB07G(RVRU6= zAa`kWXdp*PO;A^X4i^9b0=P*;K~y-)Ws}QmRaX?oe|zt9&OM%7V+_}TR&0F5H}R2y zRZ|+Yf&;;dg8zdP<0xt-qT~vSSI2@nbEKelQmLe=L~P@uZG40(7!hm@5%0}S&U>Hj zAUDy(Z?ssfz1FwZ{yxDhsDFA_V5y@RvkgnN0+8yz$xJ_(lHSnq>dSgL9i2I2tHIt9 zuJe*4{my)>K0%0+z$r5}@d#tKk#kpx z&!Cp1eZ*-mj&&G6e5>z1X``#l2mtl0uLSJFwk`st84Tm35yu{#&KQo8rk!Njb-kgZ zQ>ce`R#RX1g7SEghWxQvCy_OldPdd{yYbx9z*3=}{&of1QY5+^$7;q88!#rLZq69+ z4KNoNqUtvyu*rMx38GrdizM3QCg_|Z6y)l8BO>y0}rC^a=}6nF!GZJ{^+ID*cG znUiZKjNW0Zc8Qx?Zpzh_lZ53aQS7m?RHa6B9e%i`)?h>wi;`4t0x%z|i&3(5@;mh8 zisrx3idfPlu#^D6b02>eh_uMN4oMo2vms6kNQop)o{5EpzW~I7hNaot{{bp|jk5Vr ziLg}$AeJrqoj4tozyN+&04n61yL5GDwd2jkIvg8vPAgC*h!!LGLmEFk%Uo|CslEa< ztASrSbj$V$lC&N_TtyJoF&@~uW&1m|XKN!cKonoabN3TO=cj~p0b{-;P736`9b{RX z87)80cI*uRWZz9C7R*0_rOpvVcTh3_UhDNgbXow}t^RJgYs)?I-Vjy)DxUjLG&Q3B z?f<+$s=*I0<2o)?e{J^bqeIhPsQIB@{?vXFAr3G$aRX4y%TG$UGG4$KLw(&rQhhDE zegA|xcHE!QU|PHVKevi``6D&=wEz&sLnw6*KP>6C-7mhZ{{$w88sinmuTcO1002ov JPDHLkV1oUYIXD0S literal 0 HcmV?d00001 -- 2.45.2 From e6b7f4ca85b6883cb495e623e49c7529fccdd774 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 25 Aug 2024 22:31:29 +0200 Subject: [PATCH 19/85] add login --- src/EzLanManager.py | 19 +++- .../components/DesktopNavigation.py | 36 +++---- .../components/DesktopNavigationButton.py | 24 +++++ src/ez_lan_manager/components/LoginBox.py | 95 ++++++++++++------- src/ez_lan_manager/components/UserInfoBox.py | 63 ++++++++++++ .../components/UserInfoBoxButton.py | 24 +++++ src/ez_lan_manager/services/UserService.py | 8 +- src/ez_lan_manager/types/SessionStorage.py | 7 ++ 8 files changed, 216 insertions(+), 60 deletions(-) create mode 100644 src/ez_lan_manager/components/DesktopNavigationButton.py create mode 100644 src/ez_lan_manager/components/UserInfoBox.py create mode 100644 src/ez_lan_manager/components/UserInfoBoxButton.py create mode 100644 src/ez_lan_manager/types/SessionStorage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 5840aa0..612ed00 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -2,10 +2,11 @@ import logging from pathlib import Path -from rio import App, Theme, Color, Font, Page +from rio import App, Theme, Color, Font, Page, Session from from_root import from_root from src.ez_lan_manager import pages, init_services +from src.ez_lan_manager.types.SessionStorage import SessionStorage logger = logging.getLogger(__name__.split(".")[-1]) @@ -27,6 +28,10 @@ if __name__ == "__main__": services = init_services() lan_info = services[2].get_lan_info() + async def on_session_start(session: Session) -> None: + await session.set_title(lan_info.name) + session.attach(SessionStorage()) + app = App( name="EZ LAN Manager", pages=[ @@ -89,12 +94,22 @@ if __name__ == "__main__": name="Imprint", page_url="imprint", build=lambda: pages.PlaceholderPage(placeholder_name="Impressum & DSGVO"), + ), + Page( + name="Register", + page_url="register", + build=lambda: pages.PlaceholderPage(placeholder_name="Registrierung"), + ), + Page( + name="ForgotPassword", + page_url="forgot-password", + build=lambda: pages.PlaceholderPage(placeholder_name="Passwort vergessen"), ) ], theme=theme, assets_dir=Path(__file__).parent / "assets", default_attachments=services, - on_session_start= lambda s: s.set_title(lan_info.name), + on_session_start=on_session_start, icon=from_root("src/ez_lan_manager/assets/img/favicon.png"), meta_tags={ "robots": "INDEX,FOLLOW", diff --git a/src/ez_lan_manager/components/DesktopNavigation.py b/src/ez_lan_manager/components/DesktopNavigation.py index 58842e5..036f645 100644 --- a/src/ez_lan_manager/components/DesktopNavigation.py +++ b/src/ez_lan_manager/components/DesktopNavigation.py @@ -1,40 +1,26 @@ from rio import * from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.DesktopNavigationButton import DesktopNavigationButton from src.ez_lan_manager.components.LoginBox import LoginBox - - -class DesktopNavigationButton(Component): - STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) - label: str - target_url: str - open_new_tab: bool = False - - def build(self) -> Component: - return Link( - content=Button( - content=Text(self.label, style=self.STYLE), - shape="rectangle", - style="minor", - color="secondary", - grow_x=True, - margin_left=0.6, - margin_right=0.6, - margin_top=0.6 - ), - target_url=self.target_url, - open_in_new_tab=self.open_new_tab - ) - +from src.ez_lan_manager.components.UserInfoBox import UserInfoBox +from src.ez_lan_manager.types.SessionStorage import SessionStorage class DesktopNavigation(Component): + async def refresh_cb(self) -> None: + self.box = self.login_box if self.session[SessionStorage].user_id is None else self.user_info_box + await self.force_refresh() + def build(self) -> Component: + self.user_info_box = UserInfoBox() + self.login_box = LoginBox(self.refresh_cb) + self.box = self.login_box if self.session[SessionStorage].user_id is None else self.user_info_box lan_info = self.session[ConfigurationService].get_lan_info() return Card( Column( Text(lan_info.name, align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.hud_color, font_size=2.5)), Text(f"Edition {lan_info.iteration}", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=1.2), margin_top=0.3, margin_bottom=2), - LoginBox(), + self.box, DesktopNavigationButton("News", "./news"), Spacer(min_height=1), DesktopNavigationButton(f"Über {lan_info.name} {lan_info.iteration}", "./overview"), diff --git a/src/ez_lan_manager/components/DesktopNavigationButton.py b/src/ez_lan_manager/components/DesktopNavigationButton.py new file mode 100644 index 0000000..b59ea3b --- /dev/null +++ b/src/ez_lan_manager/components/DesktopNavigationButton.py @@ -0,0 +1,24 @@ +from rio import Component, TextStyle, Color, Link, Button, Text + + +class DesktopNavigationButton(Component): + STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + label: str + target_url: str + open_new_tab: bool = False + + def build(self) -> Component: + return Link( + content=Button( + content=Text(self.label, style=self.STYLE), + shape="rectangle", + style="minor", + color="secondary", + grow_x=True, + margin_left=0.6, + margin_right=0.6, + margin_top=0.6 + ), + target_url=self.target_url, + open_in_new_tab=self.open_new_tab + ) diff --git a/src/ez_lan_manager/components/LoginBox.py b/src/ez_lan_manager/components/LoginBox.py index 01b97cf..2060932 100644 --- a/src/ez_lan_manager/components/LoginBox.py +++ b/src/ez_lan_manager/components/LoginBox.py @@ -1,47 +1,78 @@ -from rio import Component, Card, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput +from typing import Callable + +from rio import Component, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput + +from src.ez_lan_manager import UserService +from src.ez_lan_manager.types.SessionStorage import SessionStorage class LoginBox(Component): TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + refresh_cb: Callable + + async def _on_login_pressed(self) -> None: + self.login_button.is_loading = True + user_name = self.user_name_input.text.lower() + if self.session[UserService].is_login_valid(user_name, self.password_input.text): + self.session[SessionStorage].user_id = self.session[UserService].get_user(user_name).user_id + self.user_name_input.is_valid = True + self.password_input.is_valid = True + self.login_button.is_loading = False + await self.refresh_cb() + else: + self.user_name_input.is_valid = False + self.password_input.is_valid = False + self.login_button.is_loading = False + def build(self) -> Component: + self.user_name_input = TextInput( + text="", + label="Benutzername", + accessibility_label = "Benutzername", + min_height=0.5, + on_confirm=lambda _: self._on_login_pressed() + ) + self.password_input = TextInput( + text="", + label="Passwort", + accessibility_label="Passwort", + is_secret=True, + on_confirm=lambda _: self._on_login_pressed() + ) + self.login_button = Button( + Text("LOGIN", style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary", + margin_bottom=0.4, + on_press=self._on_login_pressed + ) + self.register_button = Button( + Text("REG", style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary", + on_press=lambda: self.session.navigate_to("./register") + ) + self.forgot_password_button = Button( + Text("LST PWD",style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary", + on_press=lambda: self.session.navigate_to("./forgot-password") + ) return Rectangle( content=Column( - TextInput( - text="", - label="Benutzername", - accessibility_label = "Benutzername", - min_height=0.5 - ), - TextInput( - text="", - label="Passwort", - accessibility_label="Passwort", - is_secret=True - ), + self.user_name_input, + self.password_input, Column( Row( - Button( - Text("LOGIN", style=self.TEXT_STYLE, justify="center"), - shape="rectangle", - style="minor", - color="secondary", - margin_bottom=0.4 - ) + self.login_button ), Row( - Button( - Text("REG", style=self.TEXT_STYLE, justify="center"), - shape="rectangle", - style="minor", - color="secondary" - ), + self.register_button, Spacer(), - Button( - Text("LST PWD",style=self.TEXT_STYLE, justify="center"), - shape="rectangle", - style="minor", - color="secondary" - ), + self.forgot_password_button, proportions=(49, 2, 49) ) ), diff --git a/src/ez_lan_manager/components/UserInfoBox.py b/src/ez_lan_manager/components/UserInfoBox.py new file mode 100644 index 0000000..d9f7244 --- /dev/null +++ b/src/ez_lan_manager/components/UserInfoBox.py @@ -0,0 +1,63 @@ +from random import choice + +from rio import Component, Card, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput, Link + +from src.ez_lan_manager import UserService, AccountingService +from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton +from src.ez_lan_manager.types.SessionStorage import SessionStorage + +class StatusButton(Component): + STYLE = TextStyle(fill=Color.from_hex("121212"), font_size=0.5) + label: str + target_url: str + enabled: bool + + def build(self) -> Component: + return Link( + content=Button( + content=Text(self.label, style=self.STYLE, justify="center"), + shape="rectangle", + style="major", + color="success" if self.enabled else "danger", + grow_x=True, + margin_left=0.6, + margin_right=0.6, + margin_top=0.6 + ), + target_url=self.target_url, + align_y=0.5, + grow_y=False + ) + + +class UserInfoBox(Component): + @staticmethod + def get_greeting() -> str: + return choice(["Grüße", "Hallo", "Willkommen", "Moin", "Ahoi"]) + + def build(self) -> Component: + user = self.session[UserService].get_user(self.session[SessionStorage].user_id) + if user is None: # Noone logged in + return Text("") + a_s = self.session[AccountingService] + return Rectangle( + content=Column( + Text(f"{self.get_greeting()},", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9), justify="center"), + Text(f"{user.user_name}", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=1.2), justify="center"), + Row( + StatusButton(label="TICKET", target_url="", enabled=True), + StatusButton(label="SITZPLATZ", target_url="", enabled=False), + proportions=(50, 50), + grow_y=False + ), + UserInfoBoxButton("Profil bearbeiten", "./edit-profile"), + UserInfoBoxButton(f"Guthaben: {a_s.make_euro_string_from_int(a_s.get_balance(user.user_id))}", "./account"), + UserInfoBoxButton("Ausloggen", "./logout") + ), + fill=Color.TRANSPARENT, + min_height=8, + min_width=12, + align_x=0.5, + margin_top=0.3, + margin_bottom=2 + ) diff --git a/src/ez_lan_manager/components/UserInfoBoxButton.py b/src/ez_lan_manager/components/UserInfoBoxButton.py new file mode 100644 index 0000000..bcaa0d1 --- /dev/null +++ b/src/ez_lan_manager/components/UserInfoBoxButton.py @@ -0,0 +1,24 @@ +from rio import Component, TextStyle, Color, Link, Button, Text + + +class UserInfoBoxButton(Component): + STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.6) + label: str + target_url: str + open_new_tab: bool = False + + def build(self) -> Component: + return Link( + content=Button( + content=Text(self.label, style=self.STYLE), + shape="rectangle", + style="minor", + color="secondary", + grow_x=True, + margin_left=0.6, + margin_right=0.6, + margin_top=0.6 + ), + target_url=self.target_url, + open_in_new_tab=self.open_new_tab + ) diff --git a/src/ez_lan_manager/services/UserService.py b/src/ez_lan_manager/services/UserService.py index 59a66e5..2e4fc68 100644 --- a/src/ez_lan_manager/services/UserService.py +++ b/src/ez_lan_manager/services/UserService.py @@ -16,9 +16,12 @@ class UserService: def __init__(self, db_service: DatabaseService) -> None: self._db_service = db_service - def get_user(self, accessor: Union[str, int]) -> Optional[User]: + def get_user(self, accessor: Optional[Union[str, int]]) -> Optional[User]: + if accessor is None: + return if isinstance(accessor, int): return self._db_service.get_user_by_id(accessor) + accessor = accessor.lower() if "@" in accessor: return self._db_service.get_user_by_mail(accessor) return self._db_service.get_user_by_name(accessor) @@ -34,6 +37,8 @@ class UserService: if disallowed_char: raise NameNotAllowedError(disallowed_char) + user_name = user_name.lower() + hashed_pw = sha256(password_clear_text.encode(encoding="utf-8")).hexdigest() return self._db_service.create_user(user_name, user_mail, hashed_pw) @@ -41,6 +46,7 @@ class UserService: disallowed_char = self._check_for_disallowed_char(user.user_name) if disallowed_char: raise NameNotAllowedError(disallowed_char) + user.user_name = user.user_name.lower() return self._db_service.update_user(user) def is_login_valid(self, user_name_or_mail: str, password_clear_text: str) -> bool: diff --git a/src/ez_lan_manager/types/SessionStorage.py b/src/ez_lan_manager/types/SessionStorage.py new file mode 100644 index 0000000..2e4a140 --- /dev/null +++ b/src/ez_lan_manager/types/SessionStorage.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=False) +class SessionStorage: + user_id: Optional[int] = None # DEBUG: Put user ID here to skip login -- 2.45.2 From 8c20e46a575f626fb2433ea3c8e75efa5f4c192f Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 25 Aug 2024 22:38:27 +0200 Subject: [PATCH 20/85] add pfp to SQL --- sql/create_database.sql | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/sql/create_database.sql b/sql/create_database.sql index d5aa6b2..f6ef3dd 100644 --- a/sql/create_database.sql +++ b/sql/create_database.sql @@ -146,6 +146,21 @@ CREATE TABLE `transactions` ( ) ENGINE=InnoDB AUTO_INCREMENT=19 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; +-- +-- Table structure for table `user_profile_picture` +-- + +DROP TABLE IF EXISTS `user_profile_picture`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `user_profile_picture` ( + `user_id` int(11) NOT NULL, + `picture` blob DEFAULT NULL, + PRIMARY KEY (`user_id`), + CONSTRAINT `fk_user_profile_picture_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE NO ACTION ON UPDATE NO ACTION +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + -- -- Table structure for table `users` -- @@ -170,7 +185,7 @@ CREATE TABLE `users` ( UNIQUE KEY `user_id_UNIQUE` (`user_id`), UNIQUE KEY `user_mail_UNIQUE` (`user_mail`), UNIQUE KEY `user_name_UNIQUE` (`user_name`) -) ENGINE=InnoDB AUTO_INCREMENT=27 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; @@ -182,4 +197,4 @@ CREATE TABLE `users` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2024-08-22 13:39:21 +-- Dump completed on 2024-08-25 22:37:14 -- 2.45.2 From 6566cd7a68e75193d33fdd068ebe3cd4da40bf92 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 25 Aug 2024 23:03:56 +0200 Subject: [PATCH 21/85] add demo db helper --- .../helpers/create_demo_database_content.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/ez_lan_manager/helpers/create_demo_database_content.py diff --git a/src/ez_lan_manager/helpers/create_demo_database_content.py b/src/ez_lan_manager/helpers/create_demo_database_content.py new file mode 100644 index 0000000..6c109d4 --- /dev/null +++ b/src/ez_lan_manager/helpers/create_demo_database_content.py @@ -0,0 +1,48 @@ +# USE THIS ON AN EMPTY DATABASE TO GENERATE DEMO DATA +from from_root import from_root + +from src.ez_lan_manager import init_services + +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 +] + +if __name__ == "__main__": + services = init_services() + user_service = services[8] + accounting_service = services[0] + ticket_service = services[7] + seating_service = services[6] + seating_service.generate_new_seating_table(from_root("config/seating_plan.example.drawio")) + + # MANFRED + manfred = user_service.create_user(DEMO_USERS[0]["user_name"], DEMO_USERS[0]["user_mail"], DEMO_USERS[0]["password_clear_text"]) + + # GUSTAV + gustav = user_service.create_user(DEMO_USERS[1]["user_name"], DEMO_USERS[1]["user_mail"], DEMO_USERS[1]["password_clear_text"]) + accounting_service.add_balance(gustav.user_id, 100000, "DEMO EINZAHLUNG") + ticket_service.purchase_ticket(gustav.user_id, "NORMAL") + + # JASON + jason = user_service.create_user(DEMO_USERS[2]["user_name"], DEMO_USERS[2]["user_mail"], DEMO_USERS[2]["password_clear_text"]) + accounting_service.add_balance(jason.user_id, 100000, "DEMO EINZAHLUNG") + ticket_service.purchase_ticket(jason.user_id, "NORMAL") + seating_service.seat_user(30, "D10") + + # LISA + lisa = user_service.create_user(DEMO_USERS[3]["user_name"], DEMO_USERS[3]["user_mail"], DEMO_USERS[3]["password_clear_text"]) + accounting_service.add_balance(lisa.user_id, 100000, "DEMO EINZAHLUNG") + lisa.is_team_member = True + user_service.update_user(lisa) + + # THOMAS + thomas = user_service.create_user(DEMO_USERS[4]["user_name"], DEMO_USERS[4]["user_mail"], DEMO_USERS[4]["password_clear_text"]) + accounting_service.add_balance(thomas.user_id, 100000, "DEMO EINZAHLUNG") + thomas.is_team_member = True + thomas.is_admin = True + user_service.update_user(thomas) + -- 2.45.2 From b853c0dd132d8bf0fc165d30ccecfea02d9375e5 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 25 Aug 2024 23:08:39 +0200 Subject: [PATCH 22/85] add todo --- src/ez_lan_manager/types/SessionStorage.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ez_lan_manager/types/SessionStorage.py b/src/ez_lan_manager/types/SessionStorage.py index 2e4a140..26730b4 100644 --- a/src/ez_lan_manager/types/SessionStorage.py +++ b/src/ez_lan_manager/types/SessionStorage.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from typing import Optional +# ToDo: Persist between reloads: https://rio.dev/docs/howto/persistent-settings +# Note for ToDo: rio.UserSettings are saved LOCALLY, do not just read a user_id here! @dataclass(frozen=False) class SessionStorage: user_id: Optional[int] = None # DEBUG: Put user ID here to skip login -- 2.45.2 From d03f6ce7be06913688eb607d72a56da5be73536f Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 25 Aug 2024 23:23:37 +0200 Subject: [PATCH 23/85] add seat getter from user id --- src/ez_lan_manager/services/SeatingService.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ez_lan_manager/services/SeatingService.py b/src/ez_lan_manager/services/SeatingService.py index 8423e7c..6279df0 100644 --- a/src/ez_lan_manager/services/SeatingService.py +++ b/src/ez_lan_manager/services/SeatingService.py @@ -45,6 +45,12 @@ class SeatingService: if seat.seat_id == seat_id: return seat + def get_user_seat(self, user_id: int) -> Optional[Seat]: + all_seats = self.get_seating() + for seat in all_seats: + if seat.user and seat.user.user_id == user_id: + return seat + def seat_user(self, user_id: int, seat_id: str) -> None: user_ticket = self._ticketing_service.get_user_ticket(user_id) if not user_ticket: -- 2.45.2 From da2563c5279d8e701d4eb005152b40dacd3b266b Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sun, 25 Aug 2024 23:47:26 +0200 Subject: [PATCH 24/85] implement logout --- src/EzLanManager.py | 5 ++++ .../components/MainViewContentBox.py | 25 ++++++++++++++++ src/ez_lan_manager/components/UserInfoBox.py | 6 ++-- src/ez_lan_manager/pages/Logout.py | 30 +++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 1 + src/ez_lan_manager/types/SessionStorage.py | 3 ++ 6 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/ez_lan_manager/components/MainViewContentBox.py create mode 100644 src/ez_lan_manager/pages/Logout.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 612ed00..87d0873 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -104,6 +104,11 @@ if __name__ == "__main__": name="ForgotPassword", page_url="forgot-password", build=lambda: pages.PlaceholderPage(placeholder_name="Passwort vergessen"), + ), + Page( + name="Logout", + page_url="logout", + build=pages.LogoutPage, ) ], theme=theme, diff --git a/src/ez_lan_manager/components/MainViewContentBox.py b/src/ez_lan_manager/components/MainViewContentBox.py new file mode 100644 index 0000000..ac635aa --- /dev/null +++ b/src/ez_lan_manager/components/MainViewContentBox.py @@ -0,0 +1,25 @@ +from typing import Optional + +from rio import Component, Rectangle, Text + + +class MainViewContentBox(Component): + content: Optional[Component] = None + + def build(self) -> Component: + if self.content is None: + content = Text("Vielleich sollte hier etwas sein...\n\n\n... Wenn ja, habe ich es nicht gefunden. :(") + else: + content = self.content + return Rectangle( + content=content, + fill=self.session.theme.primary_color, + margin_left=1, + margin_right=1, + margin_top=2, + margin_bottom=1, + shadow_radius=0.5, + shadow_color=self.session.theme.hud_color, + shadow_offset_y=0, + corner_radius=0.2 + ) diff --git a/src/ez_lan_manager/components/UserInfoBox.py b/src/ez_lan_manager/components/UserInfoBox.py index d9f7244..a34448b 100644 --- a/src/ez_lan_manager/components/UserInfoBox.py +++ b/src/ez_lan_manager/components/UserInfoBox.py @@ -2,7 +2,7 @@ from random import choice from rio import Component, Card, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput, Link -from src.ez_lan_manager import UserService, AccountingService +from src.ez_lan_manager import UserService, AccountingService, TicketingService, SeatingService from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton from src.ez_lan_manager.types.SessionStorage import SessionStorage @@ -45,8 +45,8 @@ class UserInfoBox(Component): Text(f"{self.get_greeting()},", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9), justify="center"), Text(f"{user.user_name}", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=1.2), justify="center"), Row( - StatusButton(label="TICKET", target_url="", enabled=True), - StatusButton(label="SITZPLATZ", target_url="", enabled=False), + StatusButton(label="TICKET", target_url="./buy_ticket", enabled=self.session[TicketingService].get_user_ticket(user.user_id) is not None), + StatusButton(label="SITZPLATZ", target_url="./seating", enabled=self.session[SeatingService].get_user_seat(user.user_id) is not None), proportions=(50, 50), grow_y=False ), diff --git a/src/ez_lan_manager/pages/Logout.py b/src/ez_lan_manager/pages/Logout.py new file mode 100644 index 0000000..2d5dde3 --- /dev/null +++ b/src/ez_lan_manager/pages/Logout.py @@ -0,0 +1,30 @@ +from rio import Column, Component, event, Text, TextStyle + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.types.SessionStorage import SessionStorage + + +class LogoutPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Logout") + + def build(self) -> Component: + self.session[SessionStorage].clear() + return BasePage( + content=Column( + MainViewContentBox( + content=Text( + "Auf wiedersehen o/", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.4 + ), + margin=2 + ) + ), + align_y=0, + ) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 34f617e..599b20e 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -1,3 +1,4 @@ from .BasePage import BasePage from .NewsPage import NewsPage from .PlaceholderPage import PlaceholderPage +from .Logout import LogoutPage diff --git a/src/ez_lan_manager/types/SessionStorage.py b/src/ez_lan_manager/types/SessionStorage.py index 26730b4..b8199c6 100644 --- a/src/ez_lan_manager/types/SessionStorage.py +++ b/src/ez_lan_manager/types/SessionStorage.py @@ -7,3 +7,6 @@ from typing import Optional @dataclass(frozen=False) class SessionStorage: user_id: Optional[int] = None # DEBUG: Put user ID here to skip login + + def clear(self) -> None: + self.user_id = None -- 2.45.2 From a66387de2249601b227a24efd2f43a1c39762bac Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 26 Aug 2024 00:01:23 +0200 Subject: [PATCH 25/85] add guard for reg-only pages --- src/EzLanManager.py | 14 ++++++++++++++ src/ez_lan_manager/helpers/LoggedInGuard.py | 10 ++++++++++ 2 files changed, 24 insertions(+) create mode 100644 src/ez_lan_manager/helpers/LoggedInGuard.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 87d0873..f535f99 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -6,6 +6,7 @@ from rio import App, Theme, Color, Font, Page, Session from from_root import from_root from src.ez_lan_manager import pages, init_services +from src.ez_lan_manager.helpers.LoggedInGuard import logged_in_guard from src.ez_lan_manager.types.SessionStorage import SessionStorage logger = logging.getLogger(__name__.split(".")[-1]) @@ -105,10 +106,23 @@ if __name__ == "__main__": page_url="forgot-password", build=lambda: pages.PlaceholderPage(placeholder_name="Passwort vergessen"), ), + Page( + name="EditProfile", + page_url="edit-profile", + build=lambda: pages.PlaceholderPage(placeholder_name="Profil bearbeiten"), + guard=logged_in_guard + ), + Page( + name="Account", + page_url="account", + build=lambda: pages.PlaceholderPage(placeholder_name="Guthabenkonto"), + guard=logged_in_guard + ), Page( name="Logout", page_url="logout", build=pages.LogoutPage, + guard=logged_in_guard ) ], theme=theme, diff --git a/src/ez_lan_manager/helpers/LoggedInGuard.py b/src/ez_lan_manager/helpers/LoggedInGuard.py new file mode 100644 index 0000000..0002984 --- /dev/null +++ b/src/ez_lan_manager/helpers/LoggedInGuard.py @@ -0,0 +1,10 @@ +from typing import Optional + +from rio import Session, URL + +from src.ez_lan_manager.types.SessionStorage import SessionStorage + + +def logged_in_guard(session: Session, _) -> Optional[URL]: + if session[SessionStorage].user_id is None: + return URL("./") -- 2.45.2 From 8960108bb499b69ca5dbd2beac631a6d167e4ea0 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 26 Aug 2024 01:20:12 +0200 Subject: [PATCH 26/85] add accounting page and fix issue with db selects --- src/EzLanManager.py | 2 +- src/ez_lan_manager/pages/Account.py | 158 ++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 1 + .../services/AccountingService.py | 3 + .../services/DatabaseService.py | 13 ++ 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/ez_lan_manager/pages/Account.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index f535f99..1542e7c 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -115,7 +115,7 @@ if __name__ == "__main__": Page( name="Account", page_url="account", - build=lambda: pages.PlaceholderPage(placeholder_name="Guthabenkonto"), + build=pages.AccountPage, guard=logged_in_guard ), Page( diff --git a/src/ez_lan_manager/pages/Account.py b/src/ez_lan_manager/pages/Account.py new file mode 100644 index 0000000..5f3adbf --- /dev/null +++ b/src/ez_lan_manager/pages/Account.py @@ -0,0 +1,158 @@ +from rio import Column, Component, event, Text, TextStyle, Button, Color, Spacer, Revealer, Row + +from src.ez_lan_manager import ConfigurationService, UserService, AccountingService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.types.SessionStorage import SessionStorage + + +class AccountPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Guthabenkonto") + + async def _on_banking_info_press(self): + self.banking_info_revealer.is_open = not self.banking_info_revealer.is_open + + def build(self) -> Component: + user = self.session[UserService].get_user(self.session[SessionStorage].user_id) + a_s = self.session[AccountingService] + self.banking_info_revealer = Revealer( + header=None, + content=Column( + Text( + "Bankverbindung:", + style=TextStyle( + fill=self.session.theme.background_color + ), + margin=0, + margin_top=0, + margin_bottom=1, + align_x=0.5 + ), + Text( + "Kontoinhaber: Einfach Zocken Gaming Gesellschaft\n" + "IBAN: DE47 5176 2434 0019 8566 07\n" + "BLZ: 51762434\n" + "BIC: GENODE51BIK\n\n" + "Verwendungszweck:", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.78 + ), + margin=0, + margin_bottom=1, + align_x=0.2 + ), + Text( + f"AUFLADUNG - {user.user_id} - {user.user_name}", + style=TextStyle( + fill=self.session.theme.neutral_color + ), + margin=0, + margin_bottom=1, + align_x=0.5 + ) + ), + margin = 2, + margin_top = 0, + margin_bottom = 1, + grow_x=True + ) + + transaction_history = Column( + Text( + "Transaktionshistorie", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=1, + margin_bottom=1, + align_x=0.5 + ) + ) + + for transaction in sorted(self.session[AccountingService].get_transaction_history(user.user_id), key=lambda t: t.transaction_date, reverse=True): + transaction_history.add( + Row( + Text( + f"{transaction.reference} ({transaction.transaction_date.strftime('%d.%m - %H:%M')})", + style=TextStyle( + fill=self.session.theme.danger_color if transaction.is_debit else self.session.theme.success_color, + font_size=0.8 + ), + margin=0, + margin_top=0, + margin_left=0.5, + margin_bottom=0.4, + align_x=0 + ), + Text( + f"{'-' if transaction.is_debit else '+'}{a_s.make_euro_string_from_int(transaction.value)}", + style=TextStyle( + fill=self.session.theme.danger_color if transaction.is_debit else self.session.theme.success_color, + font_size=0.8 + ), + margin=0, + margin_top=0, + margin_right=0.5, + margin_bottom=0.4, + align_x=1 + ) + ) + ) + return BasePage( + content=Column( + MainViewContentBox( + content=Text( + f"Kontostand: {a_s.make_euro_string_from_int(a_s.get_balance(user.user_id))}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=2, + align_x=0.5 + ) + ), + MainViewContentBox( + content=Column( + Text( + "LAN-Konto aufladen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=2, + align_x=0.5 + ), + Button( + content=Text("BANKÜBERWEISUNG", style=TextStyle(fill=Color.from_hex("121212"), font_size=0.8), justify="center"), + shape="rectangle", + style="major", + color="secondary", + grow_x=True, + margin=2, + margin_top=0, + margin_bottom=1, + on_press=self._on_banking_info_press + ), + self.banking_info_revealer, + Button( + content=Text("PAYPAL", style=TextStyle(fill=Color.from_hex("121212"), font_size=0.8), justify="center"), + shape="rectangle", + style="major", + color="secondary", + grow_x=True, + margin=2, + margin_top=0, + is_sensitive=False + ) + ) + ), + MainViewContentBox( + content=transaction_history + ), + align_y=0, + ) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 599b20e..f4a41d1 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -2,3 +2,4 @@ from .BasePage import BasePage from .NewsPage import NewsPage from .PlaceholderPage import PlaceholderPage from .Logout import LogoutPage +from.Account import AccountPage diff --git a/src/ez_lan_manager/services/AccountingService.py b/src/ez_lan_manager/services/AccountingService.py index 2190319..e3bec1e 100644 --- a/src/ez_lan_manager/services/AccountingService.py +++ b/src/ez_lan_manager/services/AccountingService.py @@ -47,6 +47,9 @@ class AccountingService: balance_buffer += transaction.value return balance_buffer + def get_transaction_history(self, user_id: int) -> list[Transaction]: + return 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. """ diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 5ca0ca6..9c70795 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -63,6 +63,7 @@ class DatabaseService: def get_user_by_name(self, user_name: str) -> Optional[User]: cursor = self._get_cursor() cursor.execute("SELECT * FROM users WHERE user_name=?", (user_name,)) + self._connection.commit() result = cursor.fetchone() if not result: return @@ -71,6 +72,7 @@ class DatabaseService: def get_user_by_id(self, user_id: int) -> Optional[User]: cursor = self._get_cursor() cursor.execute("SELECT * FROM users WHERE user_id=?", (user_id,)) + self._connection.commit() result = cursor.fetchone() if not result: return @@ -79,6 +81,7 @@ class DatabaseService: def get_user_by_mail(self, user_mail: str) -> Optional[User]: cursor = self._get_cursor() cursor.execute("SELECT * FROM users WHERE user_mail=?", (user_mail.lower(),)) + self._connection.commit() result = cursor.fetchone() if not result: return @@ -135,6 +138,7 @@ class DatabaseService: cursor = self._get_cursor() try: cursor.execute("SELECT * FROM transactions WHERE user_id=?", (user_id,)) + self._connection.commit() result = cursor.fetchall() except mariadb.Error as e: logger.error(f"Error getting all transactions for user: {e}") @@ -167,6 +171,7 @@ class DatabaseService: cursor = self._get_cursor() try: cursor.execute("SELECT * FROM news INNER JOIN users ON news.news_author = users.user_id WHERE news_date BETWEEN ? AND ?;", (dt_start, dt_end)) + self._connection.commit() except Exception as e: logger.warning(f"Error fetching news: {e}") return [] @@ -189,6 +194,7 @@ class DatabaseService: cursor = self._get_cursor() try: cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id;", ()) + self._connection.commit() except Exception as e: logger.warning(f"Error fetching tickets: {e}") return [] @@ -208,6 +214,7 @@ class DatabaseService: cursor = self._get_cursor() try: cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id WHERE user_id=?;", (user_id, )) + self._connection.commit() except Exception as e: logger.warning(f"Error fetching ticket for user: {e}") return @@ -273,6 +280,7 @@ class DatabaseService: cursor = self._get_cursor() try: cursor.execute("SELECT seats.*, users.* FROM seats LEFT JOIN users ON seats.user = users.user_id;") + self._connection.commit() except Exception as e: logger.warning(f"Error getting seats table: {e}") return results @@ -303,6 +311,7 @@ class DatabaseService: cursor = self._get_cursor() try: cursor.execute("SELECT * FROM catering_menu_items;") + self._connection.commit() except Exception as e: logger.warning(f"Error fetching menu items: {e}") return results @@ -323,6 +332,7 @@ class DatabaseService: cursor = self._get_cursor() try: cursor.execute("SELECT * FROM catering_menu_items WHERE catering_menu_item_id = ?;", (menu_item_id, )) + self._connection.commit() except Exception as e: logger.warning(f"Error fetching menu items: {e}") return @@ -439,6 +449,7 @@ class DatabaseService: cursor = self._get_cursor() try: cursor.execute(query) + self._connection.commit() except Exception as e: logger.warning(f"Error getting orders: {e}") return fetched_orders @@ -467,6 +478,7 @@ class DatabaseService: "WHERE order_id = ?;", (order_id, ) ) + self._connection.commit() except Exception as e: logger.warning(f"Error getting order items: {e}") return result @@ -498,6 +510,7 @@ class DatabaseService: cursor = self._get_cursor() try: cursor.execute("SELECT (picture) FROM user_profile_picture WHERE user_id = ?", (user_id, )) + self._connection.commit() r = cursor.fetchone() if r is None: return -- 2.45.2 From 473358229e60e2d163bdefc09efaa3c638207a0c Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 26 Aug 2024 16:37:19 +0200 Subject: [PATCH 27/85] make BLOB to MEDIUMBLOB --- sql/create_database.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/create_database.sql b/sql/create_database.sql index f6ef3dd..d25b250 100644 --- a/sql/create_database.sql +++ b/sql/create_database.sql @@ -155,7 +155,7 @@ DROP TABLE IF EXISTS `user_profile_picture`; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `user_profile_picture` ( `user_id` int(11) NOT NULL, - `picture` blob DEFAULT NULL, + `picture` mediumblob DEFAULT NULL, PRIMARY KEY (`user_id`), CONSTRAINT `fk_user_profile_picture_user_id` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE NO ACTION ON UPDATE NO ACTION ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -- 2.45.2 From bdae7b266bbb321922f74f132e63feb7c4c507d0 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 26 Aug 2024 16:37:31 +0200 Subject: [PATCH 28/85] add email validator lib --- requirements.txt | Bin 88 -> 136 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8199663ecf874f1ebeb56b12743fbdcd11037474..5ab009416786b426ef73f1c7379c8354d3e2554e 100644 GIT binary patch delta 54 zcma#(V4M(PkjjwDkjRk9ki!tqPzGe>0NE)(x`ZL0p@^Z5!Ir^@K@W@#7 Date: Mon, 26 Aug 2024 16:38:32 +0200 Subject: [PATCH 29/85] add edit profile page and default profile picture --- src/EzLanManager.py | 2 +- src/ez_lan_manager/assets/img/anon_pfp.png | 0 src/ez_lan_manager/pages/EditProfile.py | 239 +++++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 3 +- 4 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 src/ez_lan_manager/assets/img/anon_pfp.png create mode 100644 src/ez_lan_manager/pages/EditProfile.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 1542e7c..86395b4 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -109,7 +109,7 @@ if __name__ == "__main__": Page( name="EditProfile", page_url="edit-profile", - build=lambda: pages.PlaceholderPage(placeholder_name="Profil bearbeiten"), + build=pages.EditProfilePage, guard=logged_in_guard ), Page( diff --git a/src/ez_lan_manager/assets/img/anon_pfp.png b/src/ez_lan_manager/assets/img/anon_pfp.png new file mode 100644 index 0000000..e69de29 diff --git a/src/ez_lan_manager/pages/EditProfile.py b/src/ez_lan_manager/pages/EditProfile.py new file mode 100644 index 0000000..a14f49d --- /dev/null +++ b/src/ez_lan_manager/pages/EditProfile.py @@ -0,0 +1,239 @@ +from datetime import date, datetime +from asyncio import sleep +from hashlib import sha256 +from typing import Optional + +from from_root import from_root +from rio import Column, Component, event, Text, TextStyle, Button, Color, Row, TextInput, Image, TextInputChangeEvent, NoFileSelectedError +from email_validator import validate_email, EmailNotValidError + +from src.ez_lan_manager import ConfigurationService, UserService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.User import User + + +class EditProfilePage(Component): + @staticmethod + def optional_date_to_str(d: Optional[date]) -> str: + if not d: + return "" + return d.strftime("%d.%m.%Y") + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Profil bearbeiten") + + def on_email_changed(self, change_event: TextInputChangeEvent) -> None: + try: + validate_email(change_event.text, check_deliverability=False) + self.email_input.is_valid = True + except EmailNotValidError: + self.email_input.is_valid = False + + def on_birthday_changed(self, change_event: TextInputChangeEvent) -> None: + if len(change_event.text) == 0: + self.birthday_input.is_valid = True + return + try: + day, month, year = change_event.text.split(".") + year = int(year) + if year < 1900 or year > datetime.now().year - 12: + raise ValueError + date(day=int(day), month=int(month), year=year) + self.birthday_input.is_valid = True + except (ValueError, TypeError, IndexError): + self.birthday_input.is_valid = False + + async def upload_new_pfp(self) -> None: + try: + new_pfp = await self.session.file_chooser(file_extensions=("png", "jpg", "jpeg"), multiple=False) + except NoFileSelectedError: + return + + if new_pfp.size_in_bytes > 2 * 1_000_000: + await self.display_save_result_animation(False, optional_text="Bild zu groß! (> 2MB)") + return + + image_data = await new_pfp.read_bytes() + self.session[UserService].set_profile_picture(self.session[SessionStorage].user_id, image_data) + self.pfp_image_container.image = image_data + await self.display_save_result_animation(True) + + async def display_save_result_animation(self, success: bool, optional_text: Optional[str] = None) -> None: + self.saved_text.text = "" + if success: + self.saved_text.style = TextStyle( + fill=self.session.theme.success_color, + font_size=0.9 + ) + t = "Gespeichert!" if not optional_text else optional_text + for c in t: + self.saved_text.text = self.saved_text.text + c + await self.saved_text.force_refresh() + await sleep(0.08) + else: + self.saved_text.style = TextStyle( + fill=self.session.theme.danger_color, + font_size=0.9 + ) + t = "Fehler!" if not optional_text else optional_text + for c in t: + self.saved_text.text = self.saved_text.text + c + await self.saved_text.force_refresh() + await sleep(0.08) + + async def on_save_pressed(self) -> None: + if not all((self.email_input.is_valid, self.birthday_input.is_valid)): + await self.display_save_result_animation(False) + return + + if len(self.new_pw_1_input.text.strip()) > 0: + if self.new_pw_1_input.text.strip() != self.new_pw_2_input.text.strip(): + await self.display_save_result_animation(False) + return + + user: User = self.session[UserService].get_user(self.session[SessionStorage].user_id) + user.user_mail = self.email_input.text + + if len(self.birthday_input.text) == 0: + user.user_birth_day = None + else: + day, month, year = self.birthday_input.text.split(".") + user.user_birth_day = date(day=int(day), month=int(month), year=int(year)) + + user.user_first_name = self.first_name_input.text + user.user_last_name = self.last_name_input.text + if len(self.new_pw_1_input.text.strip()) > 0: + user.user_password = sha256(self.new_pw_1_input.text.encode(encoding="utf-8")).hexdigest() + + self.session[UserService].update_user(user) + await self.display_save_result_animation(True) + + + + def build(self) -> Component: + user = self.session[UserService].get_user(self.session[SessionStorage].user_id) + pfp = self.session[UserService].get_profile_picture(user.user_id) + + self.saved_text = Text( + "", + margin_top=2, + margin_bottom=1, + align_x=0.1 + ) + + self.email_input = TextInput( + label="E-Mail Adresse", + text=user.user_mail, + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + on_change=self.on_email_changed + ) + self.first_name_input = TextInput( + label="Vorname", + text=user.user_first_name, + margin_left=1, + margin_right=1, + grow_x=True + ) + self.last_name_input = TextInput( + label="Nachname", + text=user.user_last_name, + margin_right=1, + grow_x=True + ) + self.birthday_input = TextInput( + label="Geburtstag (TT.MM.JJJJ)", + text=self.optional_date_to_str(user.user_birth_day), + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + on_change=self.on_birthday_changed + ) + self.new_pw_1_input = TextInput( + label="Neues Passwort setzen", + text="", + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + is_secret=True + ) + self.new_pw_2_input = TextInput( + label="Neues Passwort wiederholen", + text="", + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + is_secret=True + ) + + self.pfp_image_container = Image( + from_root("src/ez_lan_manager/assets/img/anon_pfp.png") if pfp is None else pfp, + align_x=0.5, + min_width=10, + min_height=10, + margin_top=1, + margin_bottom=1 + ) + + return BasePage( + content=Column( + MainViewContentBox( + content=Column( + self.pfp_image_container, + Button( + content=Text( + "Neues Bild hochladen", + style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + ), + align_x=0.5, + margin_bottom=1, + shape="rectangle", + style="major", + color="primary", + on_press=self.upload_new_pfp + ), + Row( + TextInput(label="Deine User-ID", text=user.user_id, is_sensitive=False, margin_left=1, grow_x=False), + TextInput(label="Dein Nickname", text=user.user_name, is_sensitive=False, margin_left=1, margin_right=1, grow_x=True), + margin_bottom=1 + ), + self.email_input, + Row( + self.first_name_input, + self.last_name_input, + margin_bottom=1 + ), + self.birthday_input, + self.new_pw_1_input, + self.new_pw_2_input, + + Row( + self.saved_text, + Button( + content=Text( + "Speichern", + style=TextStyle(fill=self.session.theme.success_color, font_size=0.9), + align_x=0.2 + ), + align_x=0.9, + margin_top=2, + margin_bottom=1, + shape="rectangle", + style="major", + color="primary", + on_press=self.on_save_pressed + ), + ) + ) + ), + align_y=0, + ) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index f4a41d1..6d6c35c 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -2,4 +2,5 @@ from .BasePage import BasePage from .NewsPage import NewsPage from .PlaceholderPage import PlaceholderPage from .Logout import LogoutPage -from.Account import AccountPage +from .Account import AccountPage +from .EditProfile import EditProfilePage -- 2.45.2 From 4a6b09f41c91eab0a98001fc4cb25151ab5a348b Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 26 Aug 2024 22:22:03 +0200 Subject: [PATCH 30/85] improve error messages --- src/ez_lan_manager/pages/EditProfile.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ez_lan_manager/pages/EditProfile.py b/src/ez_lan_manager/pages/EditProfile.py index a14f49d..fdbaeb8 100644 --- a/src/ez_lan_manager/pages/EditProfile.py +++ b/src/ez_lan_manager/pages/EditProfile.py @@ -50,6 +50,7 @@ class EditProfilePage(Component): try: new_pfp = await self.session.file_chooser(file_extensions=("png", "jpg", "jpeg"), multiple=False) except NoFileSelectedError: + await self.display_save_result_animation(False, optional_text="Keine Datei ausgewählt!") return if new_pfp.size_in_bytes > 2 * 1_000_000: @@ -86,12 +87,12 @@ class EditProfilePage(Component): async def on_save_pressed(self) -> None: if not all((self.email_input.is_valid, self.birthday_input.is_valid)): - await self.display_save_result_animation(False) + await self.display_save_result_animation(False, optional_text="Ungültige Werte!") return if len(self.new_pw_1_input.text.strip()) > 0: if self.new_pw_1_input.text.strip() != self.new_pw_2_input.text.strip(): - await self.display_save_result_animation(False) + await self.display_save_result_animation(False, optional_text="Passwörter nicht gleich!") return user: User = self.session[UserService].get_user(self.session[SessionStorage].user_id) -- 2.45.2 From 704184d6f9aeb86d4311f5b2cc11ec46c84a09f3 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Mon, 26 Aug 2024 23:31:27 +0200 Subject: [PATCH 31/85] add forgot password page --- requirements.txt | Bin 136 -> 170 bytes src/EzLanManager.py | 2 +- src/ez_lan_manager/pages/ForgotPassword.py | 111 ++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 1 + src/ez_lan_manager/services/MailingService.py | 31 +++-- 5 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 src/ez_lan_manager/pages/ForgotPassword.py diff --git a/requirements.txt b/requirements.txt index 5ab009416786b426ef73f1c7379c8354d3e2554e..ffb0c349e554dd2e41200e45af8bc020c7172e8e 100644 GIT binary patch delta 41 tcmeBRT*Ww{Ln)CVlOdm>m?4*;grR^T2gplesAI5YFlNwWFaTmB1_0GT2u}b2 delta 6 NcmZ3**uglV0{{oo0!;t_ diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 86395b4..3ac34e5 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -104,7 +104,7 @@ if __name__ == "__main__": Page( name="ForgotPassword", page_url="forgot-password", - build=lambda: pages.PlaceholderPage(placeholder_name="Passwort vergessen"), + build=pages.ForgotPasswordPage, ), Page( name="EditProfile", diff --git a/src/ez_lan_manager/pages/ForgotPassword.py b/src/ez_lan_manager/pages/ForgotPassword.py new file mode 100644 index 0000000..7dc0212 --- /dev/null +++ b/src/ez_lan_manager/pages/ForgotPassword.py @@ -0,0 +1,111 @@ +from hashlib import sha256 +from random import choices + +from email_validator import validate_email, EmailNotValidError +from rio import Column, Component, event, Text, TextStyle, TextInput, TextInputChangeEvent, Button + +from src.ez_lan_manager import ConfigurationService, UserService, MailingService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage + +class ForgotPasswordPage(Component): + def on_email_changed(self, change_event: TextInputChangeEvent) -> None: + try: + validate_email(change_event.text, check_deliverability=False) + self.email_input.is_valid = True + self.submit_button.is_sensitive = True + except EmailNotValidError: + self.email_input.is_valid = False + self.submit_button.is_sensitive = False + + async def on_submit_button_pressed(self) -> None: + self.submit_button.is_loading = True + await self.submit_button.force_refresh() + lan_info = self.session[ConfigurationService].get_lan_info() + user_service = self.session[UserService] + mailing_service = self.session[MailingService] + user = user_service.get_user(self.email_input.text.strip()) + if user is not None: + new_password = "".join(choices(user_service.ALLOWED_USER_NAME_SYMBOLS, k=16)) + user.user_password = sha256(new_password.encode(encoding="utf-8")).hexdigest() + user_service.update_user(user) + await mailing_service.send_email( + subject=f"Dein neues Passwort für {lan_info.name}", + body=f"Du hast für den EZ-LAN Manager der {lan_info.name} ein neues Passwort angefragt. " + f"Und hier ist es schon:\n\n{new_password}\n\nSolltest du kein neues Passwort angefordert haben, " + f"ignoriere diese E-Mail.\n\nLiebe Grüße\nDein {lan_info.name} - Team", + receiver=self.email_input.text.strip() + ) + + self.submit_button.is_loading = False + self.email_input.text = "" + + self.info_text.text = "Falls für diese E-Mail ein Konto besteht, " \ + "bekommst du in den nächsten Minuten ein neues Passwort zugeschickt. " \ + "Bitte prüfe dein Spam-Postfach.", + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Passwort vergessen") + + def build(self) -> Component: + self.email_input = TextInput( + label="E-Mail Adresse", + text="", + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + on_change=self.on_email_changed + ) + self.submit_button = Button( + content=Text( + "Neues Passwort anfordern", + style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), + align_x=0.5 + ), + grow_x=True, + margin_top=2, + margin_left=1, + margin_right=1, + margin_bottom=1, + shape="rectangle", + style="minor", + color=self.session.theme.secondary_color, + on_press=self.on_submit_button_pressed, + is_sensitive=False + ) + self.info_text = Text( + text="", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=2, + margin_left=1, + margin_right=1, + margin_bottom=2, + wrap=True + ) + return BasePage( + content=Column( + MainViewContentBox( + content=Column( + Text( + "Passwort vergessen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ), + self.email_input, + self.submit_button, + self.info_text + ) + ), + align_y=0, + ) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 6d6c35c..e11b3bb 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -4,3 +4,4 @@ from .PlaceholderPage import PlaceholderPage from .Logout import LogoutPage from .Account import AccountPage from .EditProfile import EditProfilePage +from .ForgotPassword import ForgotPasswordPage diff --git a/src/ez_lan_manager/services/MailingService.py b/src/ez_lan_manager/services/MailingService.py index f16e479..0231827 100644 --- a/src/ez_lan_manager/services/MailingService.py +++ b/src/ez_lan_manager/services/MailingService.py @@ -1,7 +1,6 @@ import logging -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from smtplib import SMTP +from email.message import EmailMessage +import aiosmtplib from src.ez_lan_manager.types.ConfigurationTypes import MailingServiceConfiguration @@ -11,20 +10,20 @@ class MailingService: def __init__(self, configuration: MailingServiceConfiguration): self._config = configuration - def send_email(self, subject: str, body: str, receiver: str) -> None: - # ToDo: Check with Rio/FastAPI if this needs to be ASYNC + async def send_email(self, subject: str, body: str, receiver: str) -> None: try: - msg = MIMEMultipart() - msg['From'] = self._config.sender - msg['To'] = receiver - msg['Subject'] = subject - - msg.attach(MIMEText(body, 'plain')) - - with SMTP(self._config.smtp_server, self._config.smtp_port) as server: - server.starttls() - server.login(self._config.username, self._config.password) - server.sendmail(self._config.sender, receiver, msg.as_string()) + message = EmailMessage() + message["From"] = self._config.sender + message["To"] = receiver + message["Subject"] = subject + message.set_content(body) + await aiosmtplib.send( + message, + hostname=self._config.smtp_server, + port=self._config.smtp_port, + username=self._config.username, + password=self._config.password + ) except Exception as e: logger.error(f"Failed to send email: {e}") -- 2.45.2 From fe5566749d7718728d7c8953498d864d149560b3 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 00:20:06 +0200 Subject: [PATCH 32/85] add register page --- src/EzLanManager.py | 6 +- src/ez_lan_manager/helpers/LoggedInGuard.py | 6 + src/ez_lan_manager/pages/RegisterPage.py | 205 ++++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 1 + 4 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 src/ez_lan_manager/pages/RegisterPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 3ac34e5..bfca6df 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -6,7 +6,7 @@ from rio import App, Theme, Color, Font, Page, Session from from_root import from_root from src.ez_lan_manager import pages, init_services -from src.ez_lan_manager.helpers.LoggedInGuard import logged_in_guard +from src.ez_lan_manager.helpers.LoggedInGuard import logged_in_guard, not_logged_in_guard from src.ez_lan_manager.types.SessionStorage import SessionStorage logger = logging.getLogger(__name__.split(".")[-1]) @@ -99,12 +99,14 @@ if __name__ == "__main__": Page( name="Register", page_url="register", - build=lambda: pages.PlaceholderPage(placeholder_name="Registrierung"), + build=pages.RegisterPage, + guard=not_logged_in_guard ), Page( name="ForgotPassword", page_url="forgot-password", build=pages.ForgotPasswordPage, + guard=not_logged_in_guard ), Page( name="EditProfile", diff --git a/src/ez_lan_manager/helpers/LoggedInGuard.py b/src/ez_lan_manager/helpers/LoggedInGuard.py index 0002984..04aa8ca 100644 --- a/src/ez_lan_manager/helpers/LoggedInGuard.py +++ b/src/ez_lan_manager/helpers/LoggedInGuard.py @@ -5,6 +5,12 @@ from rio import Session, URL from src.ez_lan_manager.types.SessionStorage import SessionStorage +# Guards pages against access from users that are NOT logged in def logged_in_guard(session: Session, _) -> Optional[URL]: if session[SessionStorage].user_id is None: return URL("./") + +# Guards pages against access from users that ARE logged in +def not_logged_in_guard(session: Session, _) -> Optional[URL]: + if session[SessionStorage].user_id is not None: + return URL("./") diff --git a/src/ez_lan_manager/pages/RegisterPage.py b/src/ez_lan_manager/pages/RegisterPage.py new file mode 100644 index 0000000..7ed1f40 --- /dev/null +++ b/src/ez_lan_manager/pages/RegisterPage.py @@ -0,0 +1,205 @@ +import logging +from asyncio import sleep +from typing import Optional + +from email_validator import validate_email, EmailNotValidError +from rio import Column, Component, event, Text, TextStyle, TextInput, TextInputChangeEvent, Button + +from src.ez_lan_manager import ConfigurationService, UserService, MailingService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage + +MINIMUM_PASSWORD_LENGTH = 6 + +logger = logging.getLogger(__name__.split(".")[-1]) + +class RegisterPage(Component): + def on_pw_change(self, _: TextInputChangeEvent) -> None: + if not (self.pw_1.text == self.pw_2.text) or len(self.pw_1.text) < MINIMUM_PASSWORD_LENGTH: + self.pw_1.is_valid = False + self.pw_2.is_valid = False + return + self.pw_1.is_valid = True + self.pw_2.is_valid = True + + + def on_email_changed(self, change_event: TextInputChangeEvent) -> None: + try: + validate_email(change_event.text, check_deliverability=False) + self.email_input.is_valid = True + except EmailNotValidError: + self.email_input.is_valid = False + + async def on_submit_button_pressed(self) -> None: + self.submit_button.is_loading = True + await self.submit_button.force_refresh() + + if len(self.user_name_input.text) < 1: + await self.display_save_result_animation(False, optional_text="Nutzername darf nicht leer sein!") + self.submit_button.is_loading = False + return + + if not (self.pw_1.text == self.pw_2.text): + await self.display_save_result_animation(False, optional_text="Passwörter stimmen nicht überein!") + self.submit_button.is_loading = False + return + + if len(self.pw_1.text) < MINIMUM_PASSWORD_LENGTH: + await self.display_save_result_animation(False, optional_text=f"Passwort muss mindestens {MINIMUM_PASSWORD_LENGTH} Zeichen lang sein!") + self.submit_button.is_loading = False + return + + if not self.email_input.is_valid or len(self.email_input.text) < 3: + await self.display_save_result_animation(False, optional_text="E-Mail Adresse ungültig!") + self.submit_button.is_loading = False + return + + user_service = self.session[UserService] + mailing_service = self.session[MailingService] + lan_info = self.session[ConfigurationService].get_lan_info() + + if user_service.get_user(self.email_input.text) is not None or user_service.get_user(self.user_name_input.text) is not None: + await self.display_save_result_animation(False, optional_text="Benutzername oder E-Mail bereits regestriert!") + self.submit_button.is_loading = False + return + + try: + new_user = user_service.create_user(self.user_name_input.text, self.email_input.text, self.pw_1.text) + if not new_user: + raise RuntimeError("User could not be created") + except Exception as e: + logger.error(f"Unknown error during new user registration: {e}") + await self.display_save_result_animation(False, optional_text="Es ist ein unbekannter Fehler aufgetreten :(") + self.submit_button.is_loading = False + return + + await mailing_service.send_email( + subject="Erfolgreiche Registrierung", + body=f"Hallo {self.user_name_input.text},\n\n" + f"Du hast dich erfolgreich beim EZ-LAN Manager für {lan_info.name} {lan_info.iteration} registriert.\n\n" + f"Wenn du dich nicht registriert hast, kontaktiere bitte unser Team über unsere Homepage.\n\n" + f"Liebe Grüße\nDein {lan_info.name} - Team", + receiver=self.email_input.text + ) + + self.submit_button.is_loading = False + await self.display_save_result_animation(True, optional_text="Erfolgreich registriert!") + + async def display_save_result_animation(self, success: bool, optional_text: Optional[str] = None) -> None: + self.info_text.text = "" + if success: + self.info_text.style = TextStyle( + fill=self.session.theme.success_color, + font_size=1 + ) + t = "Gespeichert!" if not optional_text else optional_text + for c in t: + self.info_text.text = self.info_text.text + c + await self.info_text.force_refresh() + await sleep(0.04) + else: + self.info_text.style = TextStyle( + fill=self.session.theme.danger_color, + font_size=1 + ) + t = "Fehler!" if not optional_text else optional_text + for c in t: + self.info_text.text = self.info_text.text + c + await self.info_text.force_refresh() + await sleep(0.04) + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Registrieren") + + def build(self) -> Component: + self.user_name_input = TextInput( + label="Benutzername", + text="", + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True + ) + self.email_input = TextInput( + label="E-Mail Adresse", + text="", + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + on_change=self.on_email_changed + ) + self.pw_1 = TextInput( + label="Passwort", + text="", + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + is_secret=True, + on_change=self.on_pw_change + ) + self.pw_2 = TextInput( + label="Passwort wiederholen", + text="", + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + is_secret=True, + on_change=self.on_pw_change + ) + self.submit_button = Button( + content=Text( + "Registrieren", + style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), + align_x=0.5 + ), + grow_x=True, + margin_top=2, + margin_left=1, + margin_right=1, + margin_bottom=1, + shape="rectangle", + style="minor", + color=self.session.theme.secondary_color, + on_press=self.on_submit_button_pressed + ) + self.info_text = Text( + text="", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=2, + margin_left=1, + margin_right=1, + margin_bottom=2, + wrap=True + ) + return BasePage( + content=Column( + MainViewContentBox( + content=Column( + Text( + "Neues Konto anlegen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ), + self.user_name_input, + self.email_input, + self.pw_1, + self.pw_2, + self.submit_button, + self.info_text + ) + ), + align_y=0, + ) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index e11b3bb..8fa8873 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -5,3 +5,4 @@ from .Logout import LogoutPage from .Account import AccountPage from .EditProfile import EditProfilePage from .ForgotPassword import ForgotPasswordPage +from .RegisterPage import RegisterPage -- 2.45.2 From 7be5dc6a9bb72e564fdabb6917dd2a9e1fd4f327 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 00:25:40 +0200 Subject: [PATCH 33/85] add dynamic verison file --- VERSION | 1 + src/ez_lan_manager/pages/BasePage.py | 3 ++- src/ez_lan_manager/services/ConfigurationService.py | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 VERSION diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..8a9ecc2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1 \ No newline at end of file diff --git a/src/ez_lan_manager/pages/BasePage.py b/src/ez_lan_manager/pages/BasePage.py index 48332b1..f90f519 100644 --- a/src/ez_lan_manager/pages/BasePage.py +++ b/src/ez_lan_manager/pages/BasePage.py @@ -4,6 +4,7 @@ from typing import * # type: ignore from rio import Component, event, Spacer, Card, Container, Column, Row, Rectangle, TextStyle, Color, Text +from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.DesktopNavigation import DesktopNavigation class BasePage(Component): @@ -38,7 +39,7 @@ class BasePage(Component): Row( Spacer(grow_x=True, grow_y=False), Card( - content=Text("EZ LAN Manager Version 0.0.1 © EZ GG e.V.", align_x=0.5, align_y=0.5, style=TextStyle(fill=self.session.theme.primary_color, font_size=0.5)), + content=Text(f"EZ LAN Manager Version {self.session[ConfigurationService].APP_VERSION} © EZ GG e.V.", align_x=0.5, align_y=0.5, style=TextStyle(fill=self.session.theme.primary_color, font_size=0.5)), color=self.session.theme.neutral_color, corner_radius=(0, 0, 0.5, 0.5), grow_x=False, diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py index e740fd5..f379680 100644 --- a/src/ez_lan_manager/services/ConfigurationService.py +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -12,6 +12,13 @@ logger = logging.getLogger(__name__.split(".")[-1]) class ConfigurationService: def __init__(self, config_file_path: Path) -> None: + try: + with open(from_root("VERSION"), "r") as version_file: + self._version = version_file.read().strip() + except FileNotFoundError: + logger.warning("Could not find VERSION file, defaulting to '0.0.0'") + self._version = "0.0.0" + try: with open(config_file_path, "rb") as config_file: self._config = tomllib.load(config_file) @@ -81,3 +88,7 @@ class ConfigurationService: except KeyError: logger.fatal("Error loading seating configuration, exiting...") sys.exit(1) + + @property + def APP_VERSION(self) -> str: + return self._version -- 2.45.2 From ab01c3d9a49bbb996f7adc488c6f6158d9b054f8 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 13:48:33 +0200 Subject: [PATCH 34/85] add Imprint --- src/EzLanManager.py | 2 +- src/ez_lan_manager/pages/ImprintPage.py | 108 ++++++++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 1 + 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/ez_lan_manager/pages/ImprintPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index bfca6df..da0ffc7 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -94,7 +94,7 @@ if __name__ == "__main__": Page( name="Imprint", page_url="imprint", - build=lambda: pages.PlaceholderPage(placeholder_name="Impressum & DSGVO"), + build=pages.ImprintPage, ), Page( name="Register", diff --git a/src/ez_lan_manager/pages/ImprintPage.py b/src/ez_lan_manager/pages/ImprintPage.py new file mode 100644 index 0000000..4083976 --- /dev/null +++ b/src/ez_lan_manager/pages/ImprintPage.py @@ -0,0 +1,108 @@ +from rio import Text, Column, Rectangle, TextStyle, Component, event, Link, Color + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.components.NewsPost import NewsPost +from src.ez_lan_manager.pages import BasePage + +class ImprintPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Impressum & DSGVO") + + def build(self) -> Component: + return BasePage( + content=Column( + MainViewContentBox( + Column( + Text( + text="Impressum", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + align_x=0.5 + ), + Text( + text="Angaben gemäß § 5 TMG:\n\n" + "Einfach Zockem Gaming Gesellschaft e.V.\n" + "Im Elchgrund 18\n" + "35080 Bad Endbach - Bottenhorn\n\n" + + "Vertreten durch:\n\n" + + "1. Vorsitzender: David Rodenkirchen\n" + "2. Vorsitzender: Julia Albring\n" + "Schatzmeisterin: Jessica Rodenkirchen\n\n" + + "Kontakt:\n\n" + + "E-Mail: vorstand (at) ezgg-ev.de\n\n" + + "Registereintrag:\n\n" + + "Eingetragen im Vereinsregister.\n" + "Registergericht: Amtsgericht Marburg\n" + "Registernummer: VR 5837\n\n" + + "Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:\n\n" + + "David Rodenkirchen\n" + "Im Elchgrund 18\n" + "35080 Bad Endbach - Bottenhorn\n", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 + ), + margin=2, + wrap=True + ) + ) + ), + MainViewContentBox( + Column( + Text( + text="Datenschutzerklärung", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + align_x=0.5 + ), + Text( + text="Die Datenschutzerklärung kann über den untenstehenden Link eingesehen werden", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 + ), + margin_top=2, + margin_bottom=0, + wrap=True, + align_x=0.5, + grow_x=True, + min_width=30 + ), + Link( + content=Text( + text="Datenschutzerklärung", + style=TextStyle( + fill=Color.from_hex("000080"), + font_size=0.9, + underlined=True + ), + margin_bottom=1, + margin_top=1, + wrap=True, + align_x=0.5 + ), + target_url="https://ezgg-ev.de/privacy", + open_in_new_tab=True + ) + ) + ), + align_y=0 + ), + grow_x=True + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 8fa8873..7a8b942 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -6,3 +6,4 @@ from .Account import AccountPage from .EditProfile import EditProfilePage from .ForgotPassword import ForgotPasswordPage from .RegisterPage import RegisterPage +from .ImprintPage import ImprintPage -- 2.45.2 From 81ec29cda1ff66b61d86701a3fc9562140e781d9 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 14:50:42 +0200 Subject: [PATCH 35/85] add Contact Page --- config/config.example.toml | 1 + src/EzLanManager.py | 2 +- src/ez_lan_manager/pages/ContactPage.py | 164 ++++++++++++++++++ src/ez_lan_manager/pages/EditProfile.py | 7 + src/ez_lan_manager/pages/ImprintPage.py | 3 +- src/ez_lan_manager/pages/RegisterPage.py | 8 + src/ez_lan_manager/pages/__init__.py | 1 + .../services/ConfigurationService.py | 3 +- .../types/ConfigurationTypes.py | 1 + 9 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 src/ez_lan_manager/pages/ContactPage.py diff --git a/config/config.example.toml b/config/config.example.toml index aeb244d..918eeba 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -6,6 +6,7 @@ prices={ "LUXUS" = 3000, "NORMAL" = 2500 } # Eurocent date_from="2024-10-30 15:00:00" date_till="2024-11-01 12:00:00" + organizer_mail="tech@example.com" [database] db_user="demo_user" diff --git a/src/EzLanManager.py b/src/EzLanManager.py index da0ffc7..0fa8781 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -89,7 +89,7 @@ if __name__ == "__main__": Page( name="Contact", page_url="contact", - build=lambda: pages.PlaceholderPage(placeholder_name="Kontakt"), + build=pages.ContactPage, ), Page( name="Imprint", diff --git a/src/ez_lan_manager/pages/ContactPage.py b/src/ez_lan_manager/pages/ContactPage.py new file mode 100644 index 0000000..03dd126 --- /dev/null +++ b/src/ez_lan_manager/pages/ContactPage.py @@ -0,0 +1,164 @@ +from asyncio import sleep +from datetime import datetime, timedelta + +from rio import Text, Column, TextStyle, Component, event, TextInput, MultiLineTextInput, Row, Button + +from src.ez_lan_manager import ConfigurationService, UserService, MailingService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.types.SessionStorage import SessionStorage + + +class ContactPage(Component): + # Workaround: Can not reassign this value without rio triggering refresh + # Using list to bypass this behavior + last_message_sent: list[datetime] = [datetime(day=1, month=1, year=2000)] + display_printing: list[bool] = [False] + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Kontakt") + + async def display_info_animation(self, success: bool, text: str) -> None: + if self.display_printing[0]: + return + else: + self.display_printing[0] = True + self.info_text.text = "" + if success: + self.info_text.style = TextStyle( + fill=self.session.theme.success_color, + font_size=0.9 + ) + for c in text: + self.info_text.text = self.info_text.text + c + await self.info_text.force_refresh() + await sleep(0.08) + else: + self.info_text.style = TextStyle( + fill=self.session.theme.danger_color, + font_size=0.9 + ) + for c in text: + self.info_text.text = self.info_text.text + c + await self.info_text.force_refresh() + await sleep(0.08) + self.display_printing[0] = False + + async def on_send_pressed(self) -> None: + self.submit_button.is_loading = True + await self.submit_button.force_refresh() + now = datetime.now() + if not self.email_input.text: + self.is_send_button_loading = False + await self.display_info_animation(False, "E-Mail darf nicht leer sein!") + return + + if not self.subject_input.text: + self.is_send_button_loading = False + await self.display_info_animation(False, "Betreff darf nicht leer sein!") + return + + if not self.message_input.text: + self.is_send_button_loading = False + await self.display_info_animation(False, "Nachricht darf nicht leer sein!") + return + + if (now - self.last_message_sent[0]) < timedelta(minutes=1): + await self.display_info_animation(False, "Immer mit der Ruhe!") + return + + mail_recipient = self.session[ConfigurationService].get_lan_info().organizer_mail + msg = (f"Kontaktformular vom {now.strftime('%d.%m.%Y %H:%M')}:\n\n" + f"Betreff: {self.subject_input.text}\n" + f"Absender: {self.email_input.text}\n\n" + f"Inhalt:\n" + f"{self.message_input.text}\n") + + await self.session[MailingService].send_email("Kontaktformular-Mitteilung", msg, mail_recipient) + self.last_message_sent[0] = datetime.now() + self.submit_button.is_loading = False + await self.display_info_animation(True, "Nachricht erfolgreich gesendet!") + + def build(self) -> Component: + if self.session[SessionStorage].user_id is not None: + user = self.session[UserService].get_user(self.session[SessionStorage].user_id) + else: + user = None + + self.info_text = Text( + "", + margin_top=2, + margin_bottom=1, + align_x=0.1 + ) + + self.email_input = TextInput( + label="E-Mail Adresse", + text="" if not user else user.user_mail, + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True + ) + + self.subject_input = TextInput( + label="Betreff", + text="", + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True + ) + + self.message_input = MultiLineTextInput( + label="Deine Nachricht an uns", + text="", + margin_left=1, + margin_right=1, + margin_bottom=1, + min_height=5 + ) + + self.submit_button = Button( + content=Text( + "Absenden", + style=TextStyle(fill=self.session.theme.success_color, font_size=0.9), + align_x=0.2 + ), + align_x=0.9, + margin_top=2, + margin_bottom=1, + shape="rectangle", + style="major", + color="primary", + on_press=self.on_send_pressed + ) + return BasePage( + content=Column( + MainViewContentBox( + Column( + Text( + text="Kontakt", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=1, + align_x=0.5 + ), + self.email_input, + self.subject_input, + self.message_input, + Row( + self.info_text, + self.submit_button, + ) + ) + ), + align_y=0 + ), + grow_x=True + ) + diff --git a/src/ez_lan_manager/pages/EditProfile.py b/src/ez_lan_manager/pages/EditProfile.py index fdbaeb8..ff1043e 100644 --- a/src/ez_lan_manager/pages/EditProfile.py +++ b/src/ez_lan_manager/pages/EditProfile.py @@ -15,6 +15,8 @@ from src.ez_lan_manager.types.User import User class EditProfilePage(Component): + display_printing: list[bool] = [False] + @staticmethod def optional_date_to_str(d: Optional[date]) -> str: if not d: @@ -63,6 +65,10 @@ class EditProfilePage(Component): await self.display_save_result_animation(True) async def display_save_result_animation(self, success: bool, optional_text: Optional[str] = None) -> None: + if self.display_printing[0]: + return + else: + self.display_printing[0] = True self.saved_text.text = "" if success: self.saved_text.style = TextStyle( @@ -84,6 +90,7 @@ class EditProfilePage(Component): self.saved_text.text = self.saved_text.text + c await self.saved_text.force_refresh() await sleep(0.08) + self.display_printing[0] = False async def on_save_pressed(self) -> None: if not all((self.email_input.is_valid, self.birthday_input.is_valid)): diff --git a/src/ez_lan_manager/pages/ImprintPage.py b/src/ez_lan_manager/pages/ImprintPage.py index 4083976..eba4be5 100644 --- a/src/ez_lan_manager/pages/ImprintPage.py +++ b/src/ez_lan_manager/pages/ImprintPage.py @@ -1,8 +1,7 @@ -from rio import Text, Column, Rectangle, TextStyle, Component, event, Link, Color +from rio import Text, Column, TextStyle, Component, event, Link, Color from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.components.NewsPost import NewsPost from src.ez_lan_manager.pages import BasePage class ImprintPage(Component): diff --git a/src/ez_lan_manager/pages/RegisterPage.py b/src/ez_lan_manager/pages/RegisterPage.py index 7ed1f40..3f82a45 100644 --- a/src/ez_lan_manager/pages/RegisterPage.py +++ b/src/ez_lan_manager/pages/RegisterPage.py @@ -14,6 +14,8 @@ MINIMUM_PASSWORD_LENGTH = 6 logger = logging.getLogger(__name__.split(".")[-1]) class RegisterPage(Component): + display_printing: list[bool] = [False] + def on_pw_change(self, _: TextInputChangeEvent) -> None: if not (self.pw_1.text == self.pw_2.text) or len(self.pw_1.text) < MINIMUM_PASSWORD_LENGTH: self.pw_1.is_valid = False @@ -86,6 +88,11 @@ class RegisterPage(Component): await self.display_save_result_animation(True, optional_text="Erfolgreich registriert!") async def display_save_result_animation(self, success: bool, optional_text: Optional[str] = None) -> None: + if self.display_printing[0]: + return + else: + self.display_printing[0] = True + self.info_text.text = "" if success: self.info_text.style = TextStyle( @@ -107,6 +114,7 @@ class RegisterPage(Component): self.info_text.text = self.info_text.text + c await self.info_text.force_refresh() await sleep(0.04) + self.display_printing[0] = False @event.on_populate async def on_populate(self) -> None: diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 7a8b942..a0f93bc 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -7,3 +7,4 @@ from .EditProfile import EditProfilePage from .ForgotPassword import ForgotPasswordPage from .RegisterPage import RegisterPage from .ImprintPage import ImprintPage +from .ContactPage import ContactPage diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py index f379680..9f10d69 100644 --- a/src/ez_lan_manager/services/ConfigurationService.py +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -69,7 +69,8 @@ class ConfigurationService: iteration=lan_info["iteration"], ticket_info=ticket_info, date_from=datetime.strptime(lan_info["date_from"], "%Y-%m-%d %H:%M:%S"), - date_till=datetime.strptime(lan_info["date_till"], "%Y-%m-%d %H:%M:%S") + date_till=datetime.strptime(lan_info["date_till"], "%Y-%m-%d %H:%M:%S"), + organizer_mail=lan_info["organizer_mail"] ) except KeyError: logger.fatal("Error loading LAN Info, exiting...") diff --git a/src/ez_lan_manager/types/ConfigurationTypes.py b/src/ez_lan_manager/types/ConfigurationTypes.py index da5e6cf..e1d06d2 100644 --- a/src/ez_lan_manager/types/ConfigurationTypes.py +++ b/src/ez_lan_manager/types/ConfigurationTypes.py @@ -53,6 +53,7 @@ class LanInfo: ticket_info: TicketInfo date_from: datetime date_till: datetime + organizer_mail: str @dataclass(frozen=True) class SeatingConfiguration: -- 2.45.2 From 259786a1d3a0e5d527f9f33b169795f1428920c5 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 15:27:46 +0200 Subject: [PATCH 36/85] Refactor animated Text into component --- src/ez_lan_manager/components/AnimatedText.py | 38 ++++++++++++ src/ez_lan_manager/pages/ContactPage.py | 50 ++++------------ src/ez_lan_manager/pages/EditProfile.py | 51 +++------------- src/ez_lan_manager/pages/RegisterPage.py | 60 ++++--------------- 4 files changed, 69 insertions(+), 130 deletions(-) create mode 100644 src/ez_lan_manager/components/AnimatedText.py diff --git a/src/ez_lan_manager/components/AnimatedText.py b/src/ez_lan_manager/components/AnimatedText.py new file mode 100644 index 0000000..2bd69d9 --- /dev/null +++ b/src/ez_lan_manager/components/AnimatedText.py @@ -0,0 +1,38 @@ +from asyncio import sleep + +from rio import Text, Component, TextStyle + + +class AnimatedText(Component): + def __post_init__(self) -> None: + self._display_printing: list[bool] = [False] + self.text_comp = Text("") + + async def display_text(self, success: bool, text: str, speed: float = 0.08) -> None: + if self._display_printing[0]: + return + else: + self._display_printing[0] = True + self.text_comp.text = "" + if success: + self.text_comp.style = TextStyle( + fill=self.session.theme.success_color, + font_size=0.9 + ) + for c in text: + self.text_comp.text = self.text_comp.text + c + await self.text_comp.force_refresh() + await sleep(speed) + else: + self.text_comp.style = TextStyle( + fill=self.session.theme.danger_color, + font_size=0.9 + ) + for c in text: + self.text_comp.text = self.text_comp.text + c + await self.text_comp.force_refresh() + await sleep(speed) + self._display_printing[0] = False + + def build(self) -> Component: + return self.text_comp diff --git a/src/ez_lan_manager/pages/ContactPage.py b/src/ez_lan_manager/pages/ContactPage.py index 03dd126..e694887 100644 --- a/src/ez_lan_manager/pages/ContactPage.py +++ b/src/ez_lan_manager/pages/ContactPage.py @@ -1,9 +1,9 @@ -from asyncio import sleep from datetime import datetime, timedelta from rio import Text, Column, TextStyle, Component, event, TextInput, MultiLineTextInput, Row, Button from src.ez_lan_manager import ConfigurationService, UserService, MailingService +from src.ez_lan_manager.components.AnimatedText import AnimatedText from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.SessionStorage import SessionStorage @@ -19,53 +19,27 @@ class ContactPage(Component): async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Kontakt") - async def display_info_animation(self, success: bool, text: str) -> None: - if self.display_printing[0]: - return - else: - self.display_printing[0] = True - self.info_text.text = "" - if success: - self.info_text.style = TextStyle( - fill=self.session.theme.success_color, - font_size=0.9 - ) - for c in text: - self.info_text.text = self.info_text.text + c - await self.info_text.force_refresh() - await sleep(0.08) - else: - self.info_text.style = TextStyle( - fill=self.session.theme.danger_color, - font_size=0.9 - ) - for c in text: - self.info_text.text = self.info_text.text + c - await self.info_text.force_refresh() - await sleep(0.08) - self.display_printing[0] = False - async def on_send_pressed(self) -> None: self.submit_button.is_loading = True await self.submit_button.force_refresh() now = datetime.now() if not self.email_input.text: self.is_send_button_loading = False - await self.display_info_animation(False, "E-Mail darf nicht leer sein!") + await self.animated_text.display_text(False, "E-Mail darf nicht leer sein!") return if not self.subject_input.text: self.is_send_button_loading = False - await self.display_info_animation(False, "Betreff darf nicht leer sein!") + await self.animated_text.display_text(False, "Betreff darf nicht leer sein!") return if not self.message_input.text: self.is_send_button_loading = False - await self.display_info_animation(False, "Nachricht darf nicht leer sein!") + await self.animated_text.display_text(False, "Nachricht darf nicht leer sein!") return if (now - self.last_message_sent[0]) < timedelta(minutes=1): - await self.display_info_animation(False, "Immer mit der Ruhe!") + await self.animated_text.display_text(False, "Immer mit der Ruhe!") return mail_recipient = self.session[ConfigurationService].get_lan_info().organizer_mail @@ -78,7 +52,7 @@ class ContactPage(Component): await self.session[MailingService].send_email("Kontaktformular-Mitteilung", msg, mail_recipient) self.last_message_sent[0] = datetime.now() self.submit_button.is_loading = False - await self.display_info_animation(True, "Nachricht erfolgreich gesendet!") + await self.animated_text.display_text(True, "Nachricht erfolgreich gesendet!") def build(self) -> Component: if self.session[SessionStorage].user_id is not None: @@ -86,11 +60,10 @@ class ContactPage(Component): else: user = None - self.info_text = Text( - "", - margin_top=2, - margin_bottom=1, - align_x=0.1 + self.animated_text = AnimatedText( + margin_top = 2, + margin_bottom = 1, + align_x = 0.1 ) self.email_input = TextInput( @@ -152,7 +125,7 @@ class ContactPage(Component): self.subject_input, self.message_input, Row( - self.info_text, + self.animated_text, self.submit_button, ) ) @@ -161,4 +134,3 @@ class ContactPage(Component): ), grow_x=True ) - diff --git a/src/ez_lan_manager/pages/EditProfile.py b/src/ez_lan_manager/pages/EditProfile.py index ff1043e..774a7be 100644 --- a/src/ez_lan_manager/pages/EditProfile.py +++ b/src/ez_lan_manager/pages/EditProfile.py @@ -1,5 +1,4 @@ from datetime import date, datetime -from asyncio import sleep from hashlib import sha256 from typing import Optional @@ -8,6 +7,7 @@ from rio import Column, Component, event, Text, TextStyle, Button, Color, Row, T from email_validator import validate_email, EmailNotValidError from src.ez_lan_manager import ConfigurationService, UserService +from src.ez_lan_manager.components.AnimatedText import AnimatedText from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.SessionStorage import SessionStorage @@ -15,8 +15,6 @@ from src.ez_lan_manager.types.User import User class EditProfilePage(Component): - display_printing: list[bool] = [False] - @staticmethod def optional_date_to_str(d: Optional[date]) -> str: if not d: @@ -52,54 +50,26 @@ class EditProfilePage(Component): try: new_pfp = await self.session.file_chooser(file_extensions=("png", "jpg", "jpeg"), multiple=False) except NoFileSelectedError: - await self.display_save_result_animation(False, optional_text="Keine Datei ausgewählt!") + await self.animated_text.display_text(False, "Keine Datei ausgewählt!") return if new_pfp.size_in_bytes > 2 * 1_000_000: - await self.display_save_result_animation(False, optional_text="Bild zu groß! (> 2MB)") + await self.animated_text.display_text(False, "Bild zu groß! (> 2MB)") return image_data = await new_pfp.read_bytes() self.session[UserService].set_profile_picture(self.session[SessionStorage].user_id, image_data) self.pfp_image_container.image = image_data - await self.display_save_result_animation(True) - - async def display_save_result_animation(self, success: bool, optional_text: Optional[str] = None) -> None: - if self.display_printing[0]: - return - else: - self.display_printing[0] = True - self.saved_text.text = "" - if success: - self.saved_text.style = TextStyle( - fill=self.session.theme.success_color, - font_size=0.9 - ) - t = "Gespeichert!" if not optional_text else optional_text - for c in t: - self.saved_text.text = self.saved_text.text + c - await self.saved_text.force_refresh() - await sleep(0.08) - else: - self.saved_text.style = TextStyle( - fill=self.session.theme.danger_color, - font_size=0.9 - ) - t = "Fehler!" if not optional_text else optional_text - for c in t: - self.saved_text.text = self.saved_text.text + c - await self.saved_text.force_refresh() - await sleep(0.08) - self.display_printing[0] = False + await self.animated_text.display_text(True, "Gespeichert!") async def on_save_pressed(self) -> None: if not all((self.email_input.is_valid, self.birthday_input.is_valid)): - await self.display_save_result_animation(False, optional_text="Ungültige Werte!") + await self.animated_text.display_text(False, "Ungültige Werte!") return if len(self.new_pw_1_input.text.strip()) > 0: if self.new_pw_1_input.text.strip() != self.new_pw_2_input.text.strip(): - await self.display_save_result_animation(False, optional_text="Passwörter nicht gleich!") + await self.animated_text.display_text(False, "Passwörter nicht gleich!") return user: User = self.session[UserService].get_user(self.session[SessionStorage].user_id) @@ -117,16 +87,13 @@ class EditProfilePage(Component): user.user_password = sha256(self.new_pw_1_input.text.encode(encoding="utf-8")).hexdigest() self.session[UserService].update_user(user) - await self.display_save_result_animation(True) - - + await self.animated_text.display_text(True, "Gespeichert!") def build(self) -> Component: user = self.session[UserService].get_user(self.session[SessionStorage].user_id) pfp = self.session[UserService].get_profile_picture(user.user_id) - self.saved_text = Text( - "", + self.animated_text = AnimatedText( margin_top=2, margin_bottom=1, align_x=0.1 @@ -224,7 +191,7 @@ class EditProfilePage(Component): self.new_pw_2_input, Row( - self.saved_text, + self.animated_text, Button( content=Text( "Speichern", diff --git a/src/ez_lan_manager/pages/RegisterPage.py b/src/ez_lan_manager/pages/RegisterPage.py index 3f82a45..62ccab8 100644 --- a/src/ez_lan_manager/pages/RegisterPage.py +++ b/src/ez_lan_manager/pages/RegisterPage.py @@ -1,11 +1,10 @@ import logging -from asyncio import sleep -from typing import Optional from email_validator import validate_email, EmailNotValidError from rio import Column, Component, event, Text, TextStyle, TextInput, TextInputChangeEvent, Button from src.ez_lan_manager import ConfigurationService, UserService, MailingService +from src.ez_lan_manager.components.AnimatedText import AnimatedText from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.pages import BasePage @@ -14,8 +13,6 @@ MINIMUM_PASSWORD_LENGTH = 6 logger = logging.getLogger(__name__.split(".")[-1]) class RegisterPage(Component): - display_printing: list[bool] = [False] - def on_pw_change(self, _: TextInputChangeEvent) -> None: if not (self.pw_1.text == self.pw_2.text) or len(self.pw_1.text) < MINIMUM_PASSWORD_LENGTH: self.pw_1.is_valid = False @@ -37,22 +34,22 @@ class RegisterPage(Component): await self.submit_button.force_refresh() if len(self.user_name_input.text) < 1: - await self.display_save_result_animation(False, optional_text="Nutzername darf nicht leer sein!") + await self.animated_text.display_text(False, "Nutzername darf nicht leer sein!") self.submit_button.is_loading = False return if not (self.pw_1.text == self.pw_2.text): - await self.display_save_result_animation(False, optional_text="Passwörter stimmen nicht überein!") + await self.animated_text.display_text(False, "Passwörter stimmen nicht überein!") self.submit_button.is_loading = False return if len(self.pw_1.text) < MINIMUM_PASSWORD_LENGTH: - await self.display_save_result_animation(False, optional_text=f"Passwort muss mindestens {MINIMUM_PASSWORD_LENGTH} Zeichen lang sein!") + await self.animated_text.display_text(False, f"Passwort muss mindestens {MINIMUM_PASSWORD_LENGTH} Zeichen lang sein!") self.submit_button.is_loading = False return if not self.email_input.is_valid or len(self.email_input.text) < 3: - await self.display_save_result_animation(False, optional_text="E-Mail Adresse ungültig!") + await self.animated_text.display_text(False, "E-Mail Adresse ungültig!") self.submit_button.is_loading = False return @@ -61,7 +58,7 @@ class RegisterPage(Component): lan_info = self.session[ConfigurationService].get_lan_info() if user_service.get_user(self.email_input.text) is not None or user_service.get_user(self.user_name_input.text) is not None: - await self.display_save_result_animation(False, optional_text="Benutzername oder E-Mail bereits regestriert!") + await self.animated_text.display_text(False, "Benutzername oder E-Mail bereits regestriert!") self.submit_button.is_loading = False return @@ -71,7 +68,7 @@ class RegisterPage(Component): raise RuntimeError("User could not be created") except Exception as e: logger.error(f"Unknown error during new user registration: {e}") - await self.display_save_result_animation(False, optional_text="Es ist ein unbekannter Fehler aufgetreten :(") + await self.animated_text.display_text(False, "Es ist ein unbekannter Fehler aufgetreten :(") self.submit_button.is_loading = False return @@ -85,36 +82,7 @@ class RegisterPage(Component): ) self.submit_button.is_loading = False - await self.display_save_result_animation(True, optional_text="Erfolgreich registriert!") - - async def display_save_result_animation(self, success: bool, optional_text: Optional[str] = None) -> None: - if self.display_printing[0]: - return - else: - self.display_printing[0] = True - - self.info_text.text = "" - if success: - self.info_text.style = TextStyle( - fill=self.session.theme.success_color, - font_size=1 - ) - t = "Gespeichert!" if not optional_text else optional_text - for c in t: - self.info_text.text = self.info_text.text + c - await self.info_text.force_refresh() - await sleep(0.04) - else: - self.info_text.style = TextStyle( - fill=self.session.theme.danger_color, - font_size=1 - ) - t = "Fehler!" if not optional_text else optional_text - for c in t: - self.info_text.text = self.info_text.text + c - await self.info_text.force_refresh() - await sleep(0.04) - self.display_printing[0] = False + await self.animated_text.display_text(True, "Erfolgreich registriert!") @event.on_populate async def on_populate(self) -> None: @@ -174,17 +142,11 @@ class RegisterPage(Component): color=self.session.theme.secondary_color, on_press=self.on_submit_button_pressed ) - self.info_text = Text( - text="", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1 - ), + self.animated_text = AnimatedText( margin_top=2, margin_left=1, margin_right=1, - margin_bottom=2, - wrap=True + margin_bottom=2 ) return BasePage( content=Column( @@ -205,7 +167,7 @@ class RegisterPage(Component): self.pw_1, self.pw_2, self.submit_button, - self.info_text + self.animated_text ) ), align_y=0, -- 2.45.2 From 691602a727ed41fc43bb03e4dcd5e096ab32f25a Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 15:34:04 +0200 Subject: [PATCH 37/85] minor improvements --- src/ez_lan_manager/components/AnimatedText.py | 6 ++--- src/ez_lan_manager/pages/ContactPage.py | 26 ++++++++----------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/ez_lan_manager/components/AnimatedText.py b/src/ez_lan_manager/components/AnimatedText.py index 2bd69d9..e62f7af 100644 --- a/src/ez_lan_manager/components/AnimatedText.py +++ b/src/ez_lan_manager/components/AnimatedText.py @@ -8,7 +8,7 @@ class AnimatedText(Component): self._display_printing: list[bool] = [False] self.text_comp = Text("") - async def display_text(self, success: bool, text: str, speed: float = 0.08) -> None: + async def display_text(self, success: bool, text: str, speed: float = 0.06, font_size: float = 0.9) -> None: if self._display_printing[0]: return else: @@ -17,7 +17,7 @@ class AnimatedText(Component): if success: self.text_comp.style = TextStyle( fill=self.session.theme.success_color, - font_size=0.9 + font_size=font_size ) for c in text: self.text_comp.text = self.text_comp.text + c @@ -26,7 +26,7 @@ class AnimatedText(Component): else: self.text_comp.style = TextStyle( fill=self.session.theme.danger_color, - font_size=0.9 + font_size=font_size ) for c in text: self.text_comp.text = self.text_comp.text + c diff --git a/src/ez_lan_manager/pages/ContactPage.py b/src/ez_lan_manager/pages/ContactPage.py index e694887..24b454d 100644 --- a/src/ez_lan_manager/pages/ContactPage.py +++ b/src/ez_lan_manager/pages/ContactPage.py @@ -20,26 +20,22 @@ class ContactPage(Component): await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Kontakt") async def on_send_pressed(self) -> None: + error_msg = "" self.submit_button.is_loading = True await self.submit_button.force_refresh() now = datetime.now() if not self.email_input.text: - self.is_send_button_loading = False - await self.animated_text.display_text(False, "E-Mail darf nicht leer sein!") - return + error_msg = "E-Mail darf nicht leer sein!" + elif not self.subject_input.text: + error_msg = "Betreff darf nicht leer sein!" + elif not self.message_input.text: + error_msg = "Nachricht darf nicht leer sein!" + elif (now - self.last_message_sent[0]) < timedelta(minutes=1): + error_msg = "Immer mit der Ruhe!" - if not self.subject_input.text: - self.is_send_button_loading = False - await self.animated_text.display_text(False, "Betreff darf nicht leer sein!") - return - - if not self.message_input.text: - self.is_send_button_loading = False - await self.animated_text.display_text(False, "Nachricht darf nicht leer sein!") - return - - if (now - self.last_message_sent[0]) < timedelta(minutes=1): - await self.animated_text.display_text(False, "Immer mit der Ruhe!") + if error_msg: + self.submit_button.is_loading = False + await self.animated_text.display_text(False, error_msg) return mail_recipient = self.session[ConfigurationService].get_lan_info().organizer_mail -- 2.45.2 From 2609939ce10a8e1bbffd3bec8bb0130639d99d22 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 16:14:22 +0200 Subject: [PATCH 38/85] add margin to footer so it doesnt disappear on long pages --- src/ez_lan_manager/pages/BasePage.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ez_lan_manager/pages/BasePage.py b/src/ez_lan_manager/pages/BasePage.py index f90f519..9a7af35 100644 --- a/src/ez_lan_manager/pages/BasePage.py +++ b/src/ez_lan_manager/pages/BasePage.py @@ -45,7 +45,8 @@ class BasePage(Component): grow_x=False, grow_y=False, min_height=1.2, - min_width=53.1 + min_width=53.1, + margin_bottom=3 ), Spacer(grow_x=True, grow_y=False), grow_y=False -- 2.45.2 From 71d5910b359b937efa3c43e20f3bcde633cc2375 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 16:14:36 +0200 Subject: [PATCH 39/85] add Rules page --- src/EzLanManager.py | 2 +- src/ez_lan_manager/pages/RulesPage.py | 190 ++++++++++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 1 + 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 src/ez_lan_manager/pages/RulesPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 0fa8781..e98cf43 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -84,7 +84,7 @@ if __name__ == "__main__": Page( name="RulesGTC", page_url="rules-gtc", - build=lambda: pages.PlaceholderPage(placeholder_name="Regeln & AGB"), + build=pages.RulesPage ), Page( name="Contact", diff --git a/src/ez_lan_manager/pages/RulesPage.py b/src/ez_lan_manager/pages/RulesPage.py new file mode 100644 index 0000000..7344bb1 --- /dev/null +++ b/src/ez_lan_manager/pages/RulesPage.py @@ -0,0 +1,190 @@ +from rio import Column, Component, event, TextStyle, Text + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage + +RULES: list[str] = [ + "Respektvolles Verhalten: Sei höflich und respektvoll gegenüber anderen Gästen und dem Team.", + "Alkohol und Drogen: Konsumiere Alkohol in Maßen und halte dich an die eventuellen Regeln der Veranstaltung bezüglich Alkohol- und Drogenkonsum.", + "Sitzplätze: Respektiere die zugewiesenen Plätze und ändere sie nicht ohne Genehmigung.", + "Notausgänge und Sicherheitsvorschriften: Informiere dich über die Notausgänge und beachte die Sicherheitsanweisungen.", + "Müllentsorgung: Benutze die vorgesehenen Mülleimer und halte den Veranstaltungsort sauber.", + "Rauchen: Halte dich an die Rauchverbote und benutze nur die ausgewiesenen Raucherbereiche.", + "Hausrecht: Folge den Anweisungen des Veranstalters und des Sicherheitspersonals.", + "Illegales: Das brechen des deutschen Rechts, insbesondere des Urheberrechts, bleibt auch auf LAN verboten." +] + +AGB: dict[str, list[str]] = { + "§1": [ + "Die Veranstaltung wird von der Einfach Zocken Genuss Gesellschaft e.V. organisiert.", + "Unser Event verfolgt gemeinnützige Ziele und ist nicht auf Profit ausgerichtet. Die erhobenen Teilnahmebeiträge dienen lediglich der Kostendeckung. Überschüsse werden für die Organisation und Durchführung zukünftiger ähnlicher Veranstaltungen verwendet.", + "Die Organisatoren haben das Recht, unerwünschte oder störende Personen jederzeit von der Veranstaltung auszuschließen (siehe §3). Im Falle eines Ausschlusses aufgrund eines Regelverstoßes erfolgt keine Rückerstattung des Eintrittspreises." + ], + "§2": [ + "Die Teilnahme an der Veranstaltung ist nur Personen gestattet, die mindestens 18 Jahre alt sind. Ein amtlicher Altersnachweis ist erforderlich. Kann dieser Nachweis nicht erbracht werden, wird der Zugang zur Veranstaltung verweigert.", + "Jeder Teilnehmer muss die Teilnahmegebühr entrichtet haben und dies auf Anfrage nachweisen können. Mit der Bezahlung des Eintrittspreises erhält der Teilnehmer einen garantierten Platz auf der Veranstaltung.", + "Alle Teilnehmer sind verpflichtet, vor der Veranstaltung sicherheitsrelevante Patches und Updates für Betriebssysteme und Spiele einzuspielen. Es wird nicht garantiert, dass diese während der Veranstaltung heruntergeladen werden können." + ], + "§3": [ + "Innerhalb des Veranstaltungsgebäudes gilt ein striktes Rauchverbot.", + "Jeder Teilnehmer verpflichtet sich, während der Veranstaltung keine illegalen Handlungen durchzuführen.", + "Die unautorisierte Verbreitung von urheberrechtlich geschütztem Material ist strengstens untersagt.", + "Der Veranstalter übernimmt keine Haftung für Schäden an Geräten oder Daten der Teilnehmer, es sei denn, der Veranstalter oder seine Erfüllungsgehilfen haben die Schäden vorsätzlich oder grob fahrlässig verursacht. Ebenso wird keine Haftung bei Diebstahl oder Verlust persönlicher Gegenstände übernommen.", + "Teilnehmer dürfen den Ablauf der Veranstaltung nicht absichtlich stören, insbesondere nicht den Betrieb des Computer- und Stromnetzwerks. Als absichtliche Störung zählt auch die Nutzung von Software, die dem Spieler einen unfairen Vorteil verschafft (z.B. Cheats, Hacks) sowie das Ausnutzen von Bugs in Spielen, um einen Vorteil zu erzielen. Solche Verstöße führen zum sofortigen Ausschluss aus allen Turnieren. Betrifft der Verstoß ein Teammitglied, wird das gesamte Team disqualifiziert, auch wenn die anderen Mitglieder nicht direkt beteiligt waren. Wiederholte oder schwerwiegende Verstöße können zum Ausschluss von der gesamten Veranstaltung führen.", + "Die Nutzung von Aktivlautsprechern ist verboten, Kopfhörer sind Pflicht.", + "Verursacht ein Teilnehmer Schäden, haftet er vollumfänglich für die entstehenden Kosten.", + "Teilnehmer sind dazu verpflichtet, nach der Veranstaltung ihren Platz aufzuräumen und persönliche Gegenstände mitzunehmen." + ], + "§4": [ + "Der Veranstalter stellt während der Veranstaltung einen eingeschränkten Internetzugang zur Verfügung. Es wird jedoch keine Garantie für die Verfügbarkeit, Eignung oder Zuverlässigkeit des Zugangs übernommen. Der Veranstalter behält sich das Recht vor, den Zugang zeitweise oder vollständig einzuschränken oder zu sperren sowie bestimmte Dienste oder Websites zu blockieren.", + "Für alle über das Internet getätigten Aktivitäten, Datenübertragungen und Rechtsgeschäfte ist der Teilnehmer allein verantwortlich. Entstehende Kosten durch die Nutzung von Drittanbieterdiensten trägt der Teilnehmer. Es gilt das Einhalten der gesetzlichen Bestimmungen.", + "Der Teilnehmer stellt den Veranstalter von jeglichen Ansprüchen Dritter frei, die aus einer rechtswidrigen Nutzung des Internetzugangs oder einem Verstoß gegen diese Vereinbarung resultieren. Diese Freistellung schließt auch die Kosten für die Abwehr solcher Ansprüche ein.", + "Der Veranstalter behält sich das Recht vor, die Nutzung des Internetzugangs zu protokollieren, um im Bedarfsfall Beweise für die Nutzung durch bestimmte Teilnehmer vorzulegen und den Veranstalter vor Schäden zu schützen." + ] +} + +class RulesPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Regeln & AGB") + + def build(self) -> Component: + return BasePage( + content=Column( + MainViewContentBox( + Column( + Text( + text="Regeln", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="(AGB's in verständlichem deutsch)", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.5 + ), + margin_top=0.5, + margin_bottom=2, + align_x=0.5 + ), + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + wrap=True + ) for idx, rule in enumerate(RULES)], + ) + ), + MainViewContentBox( + Column( + Text( + text="AGB", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=1, + align_x=0.5 + ), + Text( + text="§ 1 Allgemeine Bestimmungen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=2, + margin_bottom=1, + align_x=0.5 + ), + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + wrap=True + ) for idx, rule in enumerate(AGB["§1"])], + Text( + text="§ 2 Teilnahmevoraussetzungen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=1, + margin_bottom=1, + align_x=0.5 + ), + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + wrap=True + ) for idx, rule in enumerate(AGB["§2"])], + Text( + text="§ 3 Verhaltensregeln", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=1, + margin_bottom=1, + align_x=0.5 + ), + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + wrap=True + ) for idx, rule in enumerate(AGB["§3"])], + Text( + text="§ 4 Internetzugang", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=1, + margin_bottom=1, + align_x=0.5 + ), + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + wrap=True + ) for idx, rule in enumerate(AGB["§4"])], + ) + ), + align_y=0 + ) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index a0f93bc..c3b19af 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -8,3 +8,4 @@ from .ForgotPassword import ForgotPasswordPage from .RegisterPage import RegisterPage from .ImprintPage import ImprintPage from .ContactPage import ContactPage +from .RulesPage import RulesPage \ No newline at end of file -- 2.45.2 From 3128c62ca9097055616b535945a6bbc44a53e436 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 17:01:35 +0200 Subject: [PATCH 40/85] add FAQ page --- src/EzLanManager.py | 2 +- .../components/DesktopNavigation.py | 1 + src/ez_lan_manager/pages/FaqPage.py | 64 +++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 3 +- 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 src/ez_lan_manager/pages/FaqPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index e98cf43..40fb59c 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -79,7 +79,7 @@ if __name__ == "__main__": Page( name="FAQ", page_url="faq", - build=lambda: pages.PlaceholderPage(placeholder_name="FAQ"), + build=pages.FaqPage, ), Page( name="RulesGTC", diff --git a/src/ez_lan_manager/components/DesktopNavigation.py b/src/ez_lan_manager/components/DesktopNavigation.py index 036f645..777839b 100644 --- a/src/ez_lan_manager/components/DesktopNavigation.py +++ b/src/ez_lan_manager/components/DesktopNavigation.py @@ -32,6 +32,7 @@ class DesktopNavigation(Component): DesktopNavigationButton("FAQ", "./faq"), DesktopNavigationButton("Regeln & AGB", "./rules-gtc"), Spacer(min_height=1), + DesktopNavigationButton("Discord", "#", open_new_tab=True), # Temporarily disabled: https://discord.gg/8gTjg34yyH DesktopNavigationButton("Die EZ GG e.V.", "https://ezgg-ev.de/about", open_new_tab=True), DesktopNavigationButton("Kontakt", "./contact"), DesktopNavigationButton("Impressum & DSGVO", "./imprint"), diff --git a/src/ez_lan_manager/pages/FaqPage.py b/src/ez_lan_manager/pages/FaqPage.py new file mode 100644 index 0000000..8b652c4 --- /dev/null +++ b/src/ez_lan_manager/pages/FaqPage.py @@ -0,0 +1,64 @@ +from rio import Column, Component, event, TextStyle, Text, Revealer + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage + +FAQ: list[list[str]] = [ + ["Wie melde ich mich für die LAN an?", "Registriere dich auf dieser Seite, lade dein Guthabenkonto auf und kaufe ein Ticket. Danach such dir einen freien Sitzplatz auf dem Sitzplan aus."], + ["Wie lade ich mein Guthabenkonto auf?", "Logge dich in deinen Account ein und klicke auf die Schaltfläche 'Guthaben' in der Navigationsleiste. Dort findest du alle weiteren Informationen."], + ["Wie kann ich mein Ticket stornieren?", "Schreibe uns eine Mail an tech@ezgg-ev.de, wir kümmern uns dann Zeitnah um die Stornierung."], + ["Was soll ich zur LAN mitbringen?", "Deinen PC inklusive aller zugehörigen Geräte (Maus, Tastatur, Monitor, Headset), sowie aller Anschlusskabel. Wir empfehlen ein LAN Kabel von mindestens 5 Metern Länge mitzubringen. Des weiteren benötigste du eine Mehrfachsteckdose, da dir an deinem Platz nur ein einzelner Steckplatz zugewiesen wird."], + ["Wohin mit technischen Problemen?", "Melde dich einfach am Einlass bzw in der Orga-Ecke, wir helfen gerne weiter."], + ["Wo entsorge ich meinen Müll?", "Im gesamten Veranstaltungsgebäude findest du Mülltüten/Mülleimer."], + ["Darf ich Cannabis konsumieren?", "Generell verbieten wir den Konsum von Cannabis nicht. Beachte aber die allgemeine Gesetzeslage und ziehe ggf. die Bubatzkarte zu Rat."], + ["Gibt es einen Discord oder TeamSpeak?", "Du kannst gerne unseren Vereins-TeamSpeak3-Server unter ts3.ezgg-ev.de nutzen. Den Link zum offiziellen Discord findest du in der Navigationsleiste."], + ["Wo bleibt mein Essen?", "Vermutlich ist es auf dem Weg. Du kannst auf der Catering-Seite den Status deiner Bestellung überprüfen. Hast du Bedenken das sie verloren gegangen sein könnte, sprich ein Team-Mitglied an der Theke darauf an."], + ["Wie lange dauert eine Aufladung per Überweißung?", "In der Regel wird das Guthaben deinem Konto innerhalb von 2 bis 3 Werktagen gutgeschrieben. In Ausnahmefällen kann es bis zu 7 Tagen dauern."], + ["Wie melde ich meinen Clan an?", "Wenn in deiner Gruppe mehr als 3 Personen sind, dann schreib uns bitte eine Mail mit dem Betreff 'Gruppenticket' an tech@ezgg-ev.de. Schreibe uns dort die Nutzer-ID's sowie die Sitzplätze deiner Gruppe auf. Gehe sicher das jede Person in deiner Gruppe entweder bereits ein passendes Ticket besitzt oder über genug Guthaben verfügt um ein Ticket zu kaufen."], + ["Wo kann ich schlafen?", "Im Veranstaltungsgebäude sind offizielle Schlafbereiche ausgewiesen. Solange du keine Zugangs-, Durchgangs-, oder Rettungswege blockierst, darfst du überall schlafen."] +] + +class FaqPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - FAQ") + + def build(self) -> Component: + return BasePage( + content=Column( + MainViewContentBox( + Column( + Text( + text="FAQ", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + *[Revealer( + header=question, + content=Text( + text=answer, + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 + ), + margin=1, + wrap=True + ), + margin=1, + grow_x=True, + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ) + ) for question, answer in FAQ] + ) + ), + align_y=0 + ) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index c3b19af..59e7ffc 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -8,4 +8,5 @@ from .ForgotPassword import ForgotPasswordPage from .RegisterPage import RegisterPage from .ImprintPage import ImprintPage from .ContactPage import ContactPage -from .RulesPage import RulesPage \ No newline at end of file +from .RulesPage import RulesPage +from .FaqPage import FaqPage \ No newline at end of file -- 2.45.2 From 5800f723be95275426cbe061417c0136719ad2e7 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 17:09:19 +0200 Subject: [PATCH 41/85] refactor rules page --- src/ez_lan_manager/pages/RulesPage.py | 144 +++++++++++++------------- 1 file changed, 74 insertions(+), 70 deletions(-) diff --git a/src/ez_lan_manager/pages/RulesPage.py b/src/ez_lan_manager/pages/RulesPage.py index 7344bb1..5077c55 100644 --- a/src/ez_lan_manager/pages/RulesPage.py +++ b/src/ez_lan_manager/pages/RulesPage.py @@ -1,4 +1,4 @@ -from rio import Column, Component, event, TextStyle, Text +from rio import Column, Component, event, TextStyle, Text, Revealer from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox @@ -6,7 +6,7 @@ from src.ez_lan_manager.pages import BasePage RULES: list[str] = [ "Respektvolles Verhalten: Sei höflich und respektvoll gegenüber anderen Gästen und dem Team.", - "Alkohol und Drogen: Konsumiere Alkohol in Maßen und halte dich an die eventuellen Regeln der Veranstaltung bezüglich Alkohol- und Drogenkonsum.", + "Alkohol und Drogen: Konsumiere Alkohol in Maßen und halte dich an die gültige Gesetzeslage.", "Sitzplätze: Respektiere die zugewiesenen Plätze und ändere sie nicht ohne Genehmigung.", "Notausgänge und Sicherheitsvorschriften: Informiere dich über die Notausgänge und beachte die Sicherheitsanweisungen.", "Müllentsorgung: Benutze die vorgesehenen Mülleimer und halte den Veranstaltungsort sauber.", @@ -99,90 +99,94 @@ class RulesPage(Component): margin_bottom=1, align_x=0.5 ), - Text( - text="§ 1 Allgemeine Bestimmungen", - style=TextStyle( + Revealer( + header="§ 1 Allgemeine Bestimmungen", + header_style=TextStyle( fill=self.session.theme.background_color, font_size=1 ), + margin=1, margin_top=2, - margin_bottom=1, - align_x=0.5 + content=Column( + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + wrap=True + ) for idx, rule in enumerate(AGB["§1"])] + ) ), - *[Text( - f"{idx + 1}. {rule}", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ), - margin_bottom=0.8, - margin_left=1, - margin_right=1, - wrap=True - ) for idx, rule in enumerate(AGB["§1"])], - Text( - text="§ 2 Teilnahmevoraussetzungen", - style=TextStyle( + Revealer( + header="§ 2 Teilnahmevoraussetzungen", + header_style=TextStyle( fill=self.session.theme.background_color, font_size=1 ), - margin_top=1, - margin_bottom=1, - align_x=0.5 + margin=1, + margin_top=0, + content=Column( + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + wrap=True + ) for idx, rule in enumerate(AGB["§2"])] + ) ), - *[Text( - f"{idx + 1}. {rule}", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ), - margin_bottom=0.8, - margin_left=1, - margin_right=1, - wrap=True - ) for idx, rule in enumerate(AGB["§2"])], - Text( - text="§ 3 Verhaltensregeln", - style=TextStyle( + Revealer( + header="§ 3 Verhaltensregeln", + header_style=TextStyle( fill=self.session.theme.background_color, font_size=1 ), - margin_top=1, - margin_bottom=1, - align_x=0.5 + margin=1, + margin_top=0, + content=Column( + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + wrap=True + ) for idx, rule in enumerate(AGB["§3"])] + ) ), - *[Text( - f"{idx + 1}. {rule}", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ), - margin_bottom=0.8, - margin_left=1, - margin_right=1, - wrap=True - ) for idx, rule in enumerate(AGB["§3"])], - Text( - text="§ 4 Internetzugang", - style=TextStyle( + Revealer( + header="§ 4 Internetzugang", + header_style=TextStyle( fill=self.session.theme.background_color, font_size=1 ), - margin_top=1, - margin_bottom=1, - align_x=0.5 - ), - *[Text( - f"{idx + 1}. {rule}", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ), - margin_bottom=0.8, - margin_left=1, - margin_right=1, - wrap=True - ) for idx, rule in enumerate(AGB["§4"])], + margin=1, + margin_top=0, + content=Column( + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + wrap=True + ) for idx, rule in enumerate(AGB["§4"])] + ) + ) ) ), align_y=0 -- 2.45.2 From 23070a4f6999a19fdea00071c8f0b838771a5b80 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 22:38:25 +0200 Subject: [PATCH 42/85] extend demo data creation script --- .../helpers/create_demo_database_content.py | 131 +++++++++++++++--- 1 file changed, 108 insertions(+), 23 deletions(-) diff --git a/src/ez_lan_manager/helpers/create_demo_database_content.py b/src/ez_lan_manager/helpers/create_demo_database_content.py index 6c109d4..2da29bb 100644 --- a/src/ez_lan_manager/helpers/create_demo_database_content.py +++ b/src/ez_lan_manager/helpers/create_demo_database_content.py @@ -2,6 +2,7 @@ from from_root import from_root from src.ez_lan_manager import init_services +from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory DEMO_USERS = [ { "user_name": "manfred", "user_mail": "manfred@demomail.com", "password_clear_text": "manfred" }, # Gast @@ -13,36 +14,120 @@ DEMO_USERS = [ if __name__ == "__main__": services = init_services() + catering_service = services[1] user_service = services[8] accounting_service = services[0] ticket_service = services[7] seating_service = services[6] - seating_service.generate_new_seating_table(from_root("config/seating_plan.example.drawio")) - # MANFRED - manfred = user_service.create_user(DEMO_USERS[0]["user_name"], DEMO_USERS[0]["user_mail"], DEMO_USERS[0]["password_clear_text"]) + if input("Generate seating table? (y/N): ").lower() == "y": + seating_service.generate_new_seating_table(from_root("config/seating_plan.example.drawio")) - # GUSTAV - gustav = user_service.create_user(DEMO_USERS[1]["user_name"], DEMO_USERS[1]["user_mail"], DEMO_USERS[1]["password_clear_text"]) - accounting_service.add_balance(gustav.user_id, 100000, "DEMO EINZAHLUNG") - ticket_service.purchase_ticket(gustav.user_id, "NORMAL") + if not input("Generate users? (Y/n): ").lower() == "n": + # MANFRED + manfred = user_service.create_user(DEMO_USERS[0]["user_name"], DEMO_USERS[0]["user_mail"], DEMO_USERS[0]["password_clear_text"]) - # JASON - jason = user_service.create_user(DEMO_USERS[2]["user_name"], DEMO_USERS[2]["user_mail"], DEMO_USERS[2]["password_clear_text"]) - accounting_service.add_balance(jason.user_id, 100000, "DEMO EINZAHLUNG") - ticket_service.purchase_ticket(jason.user_id, "NORMAL") - seating_service.seat_user(30, "D10") + # GUSTAV + gustav = user_service.create_user(DEMO_USERS[1]["user_name"], DEMO_USERS[1]["user_mail"], DEMO_USERS[1]["password_clear_text"]) + accounting_service.add_balance(gustav.user_id, 100000, "DEMO EINZAHLUNG") + ticket_service.purchase_ticket(gustav.user_id, "NORMAL") - # LISA - lisa = user_service.create_user(DEMO_USERS[3]["user_name"], DEMO_USERS[3]["user_mail"], DEMO_USERS[3]["password_clear_text"]) - accounting_service.add_balance(lisa.user_id, 100000, "DEMO EINZAHLUNG") - lisa.is_team_member = True - user_service.update_user(lisa) + # JASON + jason = user_service.create_user(DEMO_USERS[2]["user_name"], DEMO_USERS[2]["user_mail"], DEMO_USERS[2]["password_clear_text"]) + accounting_service.add_balance(jason.user_id, 100000, "DEMO EINZAHLUNG") + ticket_service.purchase_ticket(jason.user_id, "NORMAL") + seating_service.seat_user(30, "D10") - # THOMAS - thomas = user_service.create_user(DEMO_USERS[4]["user_name"], DEMO_USERS[4]["user_mail"], DEMO_USERS[4]["password_clear_text"]) - accounting_service.add_balance(thomas.user_id, 100000, "DEMO EINZAHLUNG") - thomas.is_team_member = True - thomas.is_admin = True - user_service.update_user(thomas) + # LISA + lisa = user_service.create_user(DEMO_USERS[3]["user_name"], DEMO_USERS[3]["user_mail"], DEMO_USERS[3]["password_clear_text"]) + accounting_service.add_balance(lisa.user_id, 100000, "DEMO EINZAHLUNG") + lisa.is_team_member = True + user_service.update_user(lisa) + # THOMAS + thomas = user_service.create_user(DEMO_USERS[4]["user_name"], DEMO_USERS[4]["user_mail"], DEMO_USERS[4]["password_clear_text"]) + accounting_service.add_balance(thomas.user_id, 100000, "DEMO EINZAHLUNG") + thomas.is_team_member = True + thomas.is_admin = True + user_service.update_user(thomas) + + if not input("Generate catering menu? (Y/n): ").lower() == "n": + # MAIN_COURSE + catering_service.add_menu_item("Schnitzel Wiener Art", "mit Pommes", 1050, CateringMenuItemCategory.MAIN_COURSE) + catering_service.add_menu_item("Jäger Schnitzel mit Champignonrahm Sauce", "mit Pommes", 1150, CateringMenuItemCategory.MAIN_COURSE) + catering_service.add_menu_item("Tortellini in Käsesauce mit Fleischfüllung", "", 1050, CateringMenuItemCategory.MAIN_COURSE) + catering_service.add_menu_item("Tortellini in Käsesauce ohne Fleischfüllung", "Vegetarisch", 1050, CateringMenuItemCategory.MAIN_COURSE) + + # SNACK + catering_service.add_menu_item("Käse Schinken Wrap", "", 500, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Puten Paprika Wrap", "", 700, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Tomate Mozzarella Wrap", "", 600, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Portion Pommes", "", 400, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Rinds-Currywurst", "", 450, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Rinds-Currywurst mit Pommes", "", 650, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Nudelsalat", "", 450, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Nudelsalat mit Bockwurst", "", 600, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Kartoffelsalat", "", 450, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Kartoffelsalat mit Bockwurst", "", 600, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Sandwichtoast - Schinken", "mit Margarine", 180, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Sandwichtoast - Käse", "mit Margarine", 180, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Sandwichtoast - Schinken/Käse", "mit Margarine", 210, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Sandwichtoast - Salami", "mit Margarine", 180, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Sandwichtoast - Salami/Käse", "mit Margarine", 210, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Chips - Western Style", "", 130, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Nachos - Salted", "", 130, CateringMenuItemCategory.SNACK) + + # DESSERT + catering_service.add_menu_item("Panna Cotta mit Erdbeersauce", "", 700, CateringMenuItemCategory.DESSERT) + catering_service.add_menu_item("Panna Cotta mit Blaubeersauce", "", 700, CateringMenuItemCategory.DESSERT) + catering_service.add_menu_item("Mousse au Chocolat", "", 700, CateringMenuItemCategory.DESSERT) + + # BREAKFAST + catering_service.add_menu_item("Fruit Loops", "", 150, CateringMenuItemCategory.BREAKFAST) + catering_service.add_menu_item("Smacks", "", 150, CateringMenuItemCategory.BREAKFAST) + catering_service.add_menu_item("Knuspermüsli", "Schoko", 200, CateringMenuItemCategory.BREAKFAST) + catering_service.add_menu_item("Cini Minis", "", 150, CateringMenuItemCategory.BREAKFAST) + catering_service.add_menu_item("Brötchen - Schinken", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) + catering_service.add_menu_item("Brötchen - Käse", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) + catering_service.add_menu_item("Brötchen - Schinken/Käse", "mit Margarine", 140, CateringMenuItemCategory.BREAKFAST) + catering_service.add_menu_item("Brötchen - Salami", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) + catering_service.add_menu_item("Brötchen - Salami/Käse", "mit Margarine", 140, CateringMenuItemCategory.BREAKFAST) + catering_service.add_menu_item("Brötchen - Nutella", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) + + # BEVERAGE_NON_ALCOHOLIC + catering_service.add_menu_item("Wasser - Still", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + catering_service.add_menu_item("Wasser - Medium", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + catering_service.add_menu_item("Wasser - Spritzig", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + catering_service.add_menu_item("Coca-Cola", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + catering_service.add_menu_item("Coca-Cola Zero", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + catering_service.add_menu_item("Fanta", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + catering_service.add_menu_item("Sprite", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + catering_service.add_menu_item("Spezi", "von Paulaner, 0,5L Flasche", 150, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + catering_service.add_menu_item("Red Bull", "", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + catering_service.add_menu_item("Energy", "Hausmarke", 150, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + + # BEVERAGE_ALCOHOLIC + catering_service.add_menu_item("Pils", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + catering_service.add_menu_item("Radler", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + catering_service.add_menu_item("Diesel", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + catering_service.add_menu_item("Apfelwein Pur", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + catering_service.add_menu_item("Apfelwein Sauer", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + catering_service.add_menu_item("Apfelwein Cola", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + + # BEVERAGE_COCKTAIL + catering_service.add_menu_item("Vodka Energy", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + catering_service.add_menu_item("Vodka O-Saft", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + catering_service.add_menu_item("Whiskey Cola", "mit Bourbon", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + catering_service.add_menu_item("Jägermeister Energy", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + catering_service.add_menu_item("Sex on the Beach", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + catering_service.add_menu_item("Long Island Ice Tea", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + catering_service.add_menu_item("Caipirinha", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + + # BEVERAGE_SHOT + catering_service.add_menu_item("Jägermeister", "", 200, CateringMenuItemCategory.BEVERAGE_SHOT) + catering_service.add_menu_item("Tequila", "", 200, CateringMenuItemCategory.BEVERAGE_SHOT) + catering_service.add_menu_item("PfEZzi", "Getunter Pfefferminz-Schnaps", 199, CateringMenuItemCategory.BEVERAGE_SHOT) + + # NON_FOOD + catering_service.add_menu_item("Zigaretten", "Elixyr", 800, CateringMenuItemCategory.NON_FOOD) + catering_service.add_menu_item("Mentholfilter", "passend für Elixyr", 120, CateringMenuItemCategory.NON_FOOD) -- 2.45.2 From 093f0d6a94393944e8b4164830c106563c75dcc2 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 27 Aug 2024 23:49:07 +0200 Subject: [PATCH 43/85] add Tournaments and Guest Page, add template for empty sites, extend user and db service --- src/EzLanManager.py | 4 +- src/ez_lan_manager/pages/GuestsPage.py | 83 +++++++++++++++++++ src/ez_lan_manager/pages/TEMPLATE.py | 40 +++++++++ src/ez_lan_manager/pages/TournamentsPage.py | 40 +++++++++ src/ez_lan_manager/pages/__init__.py | 4 +- .../services/DatabaseService.py | 15 ++++ src/ez_lan_manager/services/UserService.py | 3 + 7 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 src/ez_lan_manager/pages/GuestsPage.py create mode 100644 src/ez_lan_manager/pages/TEMPLATE.py create mode 100644 src/ez_lan_manager/pages/TournamentsPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 40fb59c..64b25c3 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -69,12 +69,12 @@ if __name__ == "__main__": Page( name="Guests", page_url="guests", - build=lambda: pages.PlaceholderPage(placeholder_name="Teilnehmer"), + build=pages.GuestsPage, ), Page( name="Tournaments", page_url="tournaments", - build=lambda: pages.PlaceholderPage(placeholder_name="Turniere"), + build=pages.TournamentsPage, ), Page( name="FAQ", diff --git a/src/ez_lan_manager/pages/GuestsPage.py b/src/ez_lan_manager/pages/GuestsPage.py new file mode 100644 index 0000000..363a556 --- /dev/null +++ b/src/ez_lan_manager/pages/GuestsPage.py @@ -0,0 +1,83 @@ +from typing import Optional + +from rio import Column, Component, event, TextStyle, Text, Button, Row, TextInput, Spacer, TextInputChangeEvent + +from src.ez_lan_manager import ConfigurationService, UserService, TicketingService, SeatingService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.types.User import User + + +class GuestsPage(Component): + table_elements: list[Button] = [] + users_with_tickets: list[User] = [] + user_filter: Optional[str] = None + + + def __post_init__(self) -> None: + user_service = self.session[UserService] + all_users = user_service.get_all_users() + ticketing_service = self.session[TicketingService] + self.users_with_tickets = list(filter(lambda user: ticketing_service.get_user_ticket(user.user_id) is not None, all_users)) + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Teilnehmer") + + def on_searchbar_content_change(self, change_event: TextInputChangeEvent) -> None: + self.user_filter = change_event.text + + def build(self) -> Component: + seating_service = self.session[SeatingService] + if self.user_filter: + users = [user for user in self.users_with_tickets if self.user_filter.lower() in user.user_name or self.user_filter.lower() in str(user.user_id)] + else: + users = self.users_with_tickets + self.table_elements.clear() + for idx, user in enumerate(users): + seat = seating_service.get_user_seat(user.user_id) + self.table_elements.append( + Button( + content=Row(Text(text=f"{user.user_id:0>4}", align_x=0, margin_right=1), Text(text=user.user_name, grow_x=True, wrap="ellipsize"), Text(text="-" if seat is None else seat.seat_id, align_x=1)), + shape="rectangle", + grow_x=True, + color=self.session.theme.hud_color if idx % 2 == 0 else self.session.theme.primary_color + ) + ) + + return BasePage( + content=Column( + MainViewContentBox( + Column( + Text( + text="Teilnehmer", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ), + TextInput( + label="Suche nach Name oder ID", + margin=1, + margin_left=3, + margin_right=3, + on_change=self.on_searchbar_content_change + ), + Button( + content=Row(Text(text="ID ", align_x=0, margin_right=1), Text(text="Benutzername", grow_x=True), Text(text="Sitzplatz", align_x=1)), + shape="rectangle", + grow_x=True, + color=self.session.theme.primary_color, + style="plain", + is_sensitive=False + ), + *self.table_elements, + Spacer(min_height=1) + ) + ), + align_y=0 + ) + ) diff --git a/src/ez_lan_manager/pages/TEMPLATE.py b/src/ez_lan_manager/pages/TEMPLATE.py new file mode 100644 index 0000000..1380947 --- /dev/null +++ b/src/ez_lan_manager/pages/TEMPLATE.py @@ -0,0 +1,40 @@ +from rio import Column, Component, event, TextStyle, Text + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage + +class PAGENAME(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - PAGENAME") + + def build(self) -> Component: + return BasePage( + content=Column( + MainViewContentBox( + Column( + Text( + text="HEADER", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="BASIC TEXT", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 + ), + margin=1, + wrap=True + ) + ) + ), + align_y=0 + ) + ) diff --git a/src/ez_lan_manager/pages/TournamentsPage.py b/src/ez_lan_manager/pages/TournamentsPage.py new file mode 100644 index 0000000..ec04017 --- /dev/null +++ b/src/ez_lan_manager/pages/TournamentsPage.py @@ -0,0 +1,40 @@ +from rio import Column, Component, event, TextStyle, Text + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage + +class TournamentsPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turniere") + + def build(self) -> Component: + return BasePage( + content=Column( + MainViewContentBox( + Column( + Text( + text="Turniere", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="Aktuell ist noch kein Turnierplan hinterlegt.", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 + ), + margin=1, + wrap=True + ) + ) + ), + align_y=0 + ) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 59e7ffc..e4d2c66 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -9,4 +9,6 @@ from .RegisterPage import RegisterPage from .ImprintPage import ImprintPage from .ContactPage import ContactPage from .RulesPage import RulesPage -from .FaqPage import FaqPage \ No newline at end of file +from .FaqPage import FaqPage +from .TournamentsPage import TournamentsPage +from .GuestsPage import GuestsPage diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 9c70795..943b81a 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -518,3 +518,18 @@ class DatabaseService: except Exception as e: logger.warning(f"Error setting user profile picture: {e}") return None + + def get_all_users(self) -> list[User]: + results = [] + cursor = self._get_cursor() + try: + cursor.execute("SELECT * FROM users;") + self._connection.commit() + except Exception as e: + logger.warning(f"Error getting all users: {e}") + return results + + for user_raw in cursor.fetchall(): + results.append(self._map_db_result_to_user(user_raw)) + + return results diff --git a/src/ez_lan_manager/services/UserService.py b/src/ez_lan_manager/services/UserService.py index 2e4fc68..aa00be4 100644 --- a/src/ez_lan_manager/services/UserService.py +++ b/src/ez_lan_manager/services/UserService.py @@ -16,6 +16,9 @@ class UserService: def __init__(self, db_service: DatabaseService) -> None: self._db_service = db_service + def get_all_users(self) -> list[User]: + return self._db_service.get_all_users() + def get_user(self, accessor: Optional[Union[str, int]]) -> Optional[User]: if accessor is None: return -- 2.45.2 From 53c08dff280da7d566bb948bece8f6f1ba637c8a Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 28 Aug 2024 00:24:55 +0200 Subject: [PATCH 44/85] attach NewsPage to database source --- src/ez_lan_manager/components/NewsPost.py | 31 +++++++++++++++++++++-- src/ez_lan_manager/pages/NewsPage.py | 29 ++++++++------------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/src/ez_lan_manager/components/NewsPost.py b/src/ez_lan_manager/components/NewsPost.py index edf6f8e..87daa35 100644 --- a/src/ez_lan_manager/components/NewsPost.py +++ b/src/ez_lan_manager/components/NewsPost.py @@ -5,6 +5,8 @@ class NewsPost(Component): title: str = "" text: str = "" date: str = "" + subtitle: str = "" + author: str = "" def build(self) -> Component: return Rectangle( @@ -12,7 +14,6 @@ class NewsPost(Component): Row( Text( self.title, - align_x=0, grow_x=True, margin=2, margin_bottom=0, @@ -20,7 +21,7 @@ class NewsPost(Component): fill=self.session.theme.background_color, font_size=1.3 ), - wrap=False + wrap="ellipsize" ), Text( self.date, @@ -33,6 +34,18 @@ class NewsPost(Component): wrap=True ) ), + Text( + self.subtitle, + grow_x=True, + margin=2, + margin_top=0, + margin_bottom=0, + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + wrap="ellipsize" + ), Text( self.text, margin=2, @@ -40,6 +53,20 @@ class NewsPost(Component): fill=self.session.theme.background_color ), wrap=True + ), + Text( + f"Geschrieben von {self.author}", + align_x=0, + grow_x=True, + margin=2, + margin_top=0, + margin_bottom=1, + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.5, + italic=True + ), + wrap=False ) ), fill=self.session.theme.primary_color, diff --git a/src/ez_lan_manager/pages/NewsPage.py b/src/ez_lan_manager/pages/NewsPage.py index a187c15..ec241f3 100644 --- a/src/ez_lan_manager/pages/NewsPage.py +++ b/src/ez_lan_manager/pages/NewsPage.py @@ -1,6 +1,6 @@ -from rio import Text, Column, Rectangle, TextStyle, Component, event +from rio import Column, Component, event -from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager import ConfigurationService, NewsService from src.ez_lan_manager.components.NewsPost import NewsPost from src.ez_lan_manager.pages import BasePage @@ -10,25 +10,16 @@ class NewsPage(Component): await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Neuigkeiten") def build(self) -> Component: + posts = [NewsPost( + title=news.title, + subtitle=news.subtitle, + text=news.content, + date=news.news_date.strftime("%d.%m.%Y"), + author=news.author.user_name + ) for news in self.session[NewsService].get_news()[:8]] return BasePage( content=Column( - NewsPost( - title="EZ LAN Manager", - text="Der EZ LAN Manager ist die offizielle Software der EZ GG e.V. um LAn-Parties zu verwalten." - "Ist schon echt cool wie der funktioniert! So kann LAN Party richtig geschmeidig ablaufen.", - date="23.08.2024" - ), - NewsPost( - title="Alkohöl", - text="Der Verein 'EZ GG e.V.' ist bekannt für seinen unstillbaren Durst. " - "Bei jedem Treffen fließt der Alkohol in Strömen – egal ob Bier, Wein oder Hochprozentiges. " - "Kein Glas bleibt lange leer, und bevor der Pegel auch nur ansatzweise sinkt, " - "wird schon nachgefüllt. Die Mitglieder feiern ausgiebig und trinken dabei so viel, " - "dass die Vorräte nie lange halten. Bei jeder Gelegenheit wird angestoßen, " - "die Stimmung steigt und der Alkohol fließt ohne Ende. " - "Ihr Motto: 'Kein Abend ohne reichlich Alkohol!'", - date="23.08.2024" - ), + *posts, align_y=0, ) ) -- 2.45.2 From bde331a32c8eed0aafbd2047e22a05dfb0a572ec Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 28 Aug 2024 00:32:34 +0200 Subject: [PATCH 45/85] add demo news generation --- .../helpers/create_demo_database_content.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/ez_lan_manager/helpers/create_demo_database_content.py b/src/ez_lan_manager/helpers/create_demo_database_content.py index 2da29bb..4e3d091 100644 --- a/src/ez_lan_manager/helpers/create_demo_database_content.py +++ b/src/ez_lan_manager/helpers/create_demo_database_content.py @@ -1,8 +1,13 @@ # USE THIS ON AN EMPTY DATABASE TO GENERATE DEMO DATA +from datetime import date + +import sys + from from_root import from_root from src.ez_lan_manager import init_services from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory +from src.ez_lan_manager.types.News import News DEMO_USERS = [ { "user_name": "manfred", "user_mail": "manfred@demomail.com", "password_clear_text": "manfred" }, # Gast @@ -19,6 +24,7 @@ if __name__ == "__main__": accounting_service = services[0] ticket_service = services[7] seating_service = services[6] + news_service = services[5] if input("Generate seating table? (y/N): ").lower() == "y": seating_service.generate_new_seating_table(from_root("config/seating_plan.example.drawio")) @@ -131,3 +137,23 @@ if __name__ == "__main__": # NON_FOOD catering_service.add_menu_item("Zigaretten", "Elixyr", 800, CateringMenuItemCategory.NON_FOOD) catering_service.add_menu_item("Mentholfilter", "passend für Elixyr", 120, CateringMenuItemCategory.NON_FOOD) + + if not input("Generate default new post? (Y/n): ").lower() == "n": + loops = 0 + user = None + while loops < 1000: + user = user_service.get_user(loops) + if user is not None: + break + loops += 1 + + if user is None: + sys.exit("Database does not contain users! Exiting...") + + news_service.add_news(News( + title="Der EZ LAN Manager", + subtitle="Eine Software des EZ GG e.V.", + content="Dies ist eine WIP-Version des EZ LAN Managers. Diese Software soll uns helfen in Zukunft die LAN Parties des EZ GG e.V.'s zu organisieren. Wer Fehler findet darf sie behalten. (Oder er meldet sie)", + author=user, + news_date=date.today() + )) -- 2.45.2 From b00a819325d917e3bc182f77d8f5ad2020b634c4 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 28 Aug 2024 11:59:25 +0200 Subject: [PATCH 46/85] Refactor logged-in and out messaging, Prepare Catering Module with shopping cart --- src/EzLanManager.py | 8 +- .../components/CateringCartItem.py | 30 ++++ .../components/DesktopNavigation.py | 10 +- src/ez_lan_manager/components/LoginBox.py | 4 +- src/ez_lan_manager/components/UserInfoBox.py | 18 ++- src/ez_lan_manager/pages/CateringPage.py | 131 ++++++++++++++++++ src/ez_lan_manager/pages/Logout.py | 30 ---- src/ez_lan_manager/pages/__init__.py | 2 +- .../services/CateringService.py | 24 ++++ src/ez_lan_manager/types/SessionStorage.py | 22 ++- 10 files changed, 227 insertions(+), 52 deletions(-) create mode 100644 src/ez_lan_manager/components/CateringCartItem.py create mode 100644 src/ez_lan_manager/pages/CateringPage.py delete mode 100644 src/ez_lan_manager/pages/Logout.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 64b25c3..d8e31d8 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -64,7 +64,7 @@ if __name__ == "__main__": Page( name="Catering", page_url="catering", - build=lambda: pages.PlaceholderPage(placeholder_name="Catering"), + build=pages.CateringPage, ), Page( name="Guests", @@ -119,12 +119,6 @@ if __name__ == "__main__": page_url="account", build=pages.AccountPage, guard=logged_in_guard - ), - Page( - name="Logout", - page_url="logout", - build=pages.LogoutPage, - guard=logged_in_guard ) ], theme=theme, diff --git a/src/ez_lan_manager/components/CateringCartItem.py b/src/ez_lan_manager/components/CateringCartItem.py new file mode 100644 index 0000000..6298995 --- /dev/null +++ b/src/ez_lan_manager/components/CateringCartItem.py @@ -0,0 +1,30 @@ +from typing import Callable + +import rio +from rio import Component, Row, Text, IconButton, TextStyle + +from src.ez_lan_manager import AccountingService + +MAX_LEN = 24 + +class CateringCartItem(Component): + article_name: str + article_price: int + article_id: int + list_id: int + remove_item_cb: Callable + + @staticmethod + def ellipsize_string(string: str) -> str: + if len(string) <= MAX_LEN: + return string + + return string[:MAX_LEN - 3] + "..." + + def build(self) -> rio.Component: + return Row( + Text(self.ellipsize_string(self.article_name), align_x=0, wrap=True, min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), + Text(AccountingService.make_euro_string_from_int(self.article_price), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), + IconButton(icon="material/close", size=2, color=self.session.theme.danger_color, style="plain", on_press=lambda: self.remove_item_cb(self.list_id)), + proportions=(19, 5, 2) + ) diff --git a/src/ez_lan_manager/components/DesktopNavigation.py b/src/ez_lan_manager/components/DesktopNavigation.py index 777839b..0d03a77 100644 --- a/src/ez_lan_manager/components/DesktopNavigation.py +++ b/src/ez_lan_manager/components/DesktopNavigation.py @@ -7,20 +7,20 @@ from src.ez_lan_manager.components.UserInfoBox import UserInfoBox from src.ez_lan_manager.types.SessionStorage import SessionStorage class DesktopNavigation(Component): + def __post_init__(self) -> None: + self.session[SessionStorage].subscribe_to_logged_in_or_out_event(self.__class__.__name__, self.refresh_cb) + async def refresh_cb(self) -> None: - self.box = self.login_box if self.session[SessionStorage].user_id is None else self.user_info_box await self.force_refresh() def build(self) -> Component: - self.user_info_box = UserInfoBox() - self.login_box = LoginBox(self.refresh_cb) - self.box = self.login_box if self.session[SessionStorage].user_id is None else self.user_info_box + box = LoginBox() if self.session[SessionStorage].user_id is None else UserInfoBox() lan_info = self.session[ConfigurationService].get_lan_info() return Card( Column( Text(lan_info.name, align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.hud_color, font_size=2.5)), Text(f"Edition {lan_info.iteration}", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=1.2), margin_top=0.3, margin_bottom=2), - self.box, + box, DesktopNavigationButton("News", "./news"), Spacer(min_height=1), DesktopNavigationButton(f"Über {lan_info.name} {lan_info.iteration}", "./overview"), diff --git a/src/ez_lan_manager/components/LoginBox.py b/src/ez_lan_manager/components/LoginBox.py index 2060932..56bde83 100644 --- a/src/ez_lan_manager/components/LoginBox.py +++ b/src/ez_lan_manager/components/LoginBox.py @@ -8,17 +8,15 @@ from src.ez_lan_manager.types.SessionStorage import SessionStorage class LoginBox(Component): TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) - refresh_cb: Callable async def _on_login_pressed(self) -> None: self.login_button.is_loading = True user_name = self.user_name_input.text.lower() if self.session[UserService].is_login_valid(user_name, self.password_input.text): - self.session[SessionStorage].user_id = self.session[UserService].get_user(user_name).user_id self.user_name_input.is_valid = True self.password_input.is_valid = True self.login_button.is_loading = False - await self.refresh_cb() + await self.session[SessionStorage].set_user_id(self.session[UserService].get_user(user_name).user_id) else: self.user_name_input.is_valid = False self.password_input.is_valid = False diff --git a/src/ez_lan_manager/components/UserInfoBox.py b/src/ez_lan_manager/components/UserInfoBox.py index a34448b..40b0e7f 100644 --- a/src/ez_lan_manager/components/UserInfoBox.py +++ b/src/ez_lan_manager/components/UserInfoBox.py @@ -1,6 +1,6 @@ from random import choice -from rio import Component, Card, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput, Link +from rio import Component, Column, Text, Row, Rectangle, Button, TextStyle, Color, Link from src.ez_lan_manager import UserService, AccountingService, TicketingService, SeatingService from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton @@ -35,6 +35,10 @@ class UserInfoBox(Component): def get_greeting() -> str: return choice(["Grüße", "Hallo", "Willkommen", "Moin", "Ahoi"]) + async def logout(self) -> None: + await self.session[SessionStorage].clear() + await self.force_refresh() + def build(self) -> Component: user = self.session[UserService].get_user(self.session[SessionStorage].user_id) if user is None: # Noone logged in @@ -52,7 +56,17 @@ class UserInfoBox(Component): ), UserInfoBoxButton("Profil bearbeiten", "./edit-profile"), UserInfoBoxButton(f"Guthaben: {a_s.make_euro_string_from_int(a_s.get_balance(user.user_id))}", "./account"), - UserInfoBoxButton("Ausloggen", "./logout") + Button( + content=Text("Ausloggen", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.6)), + shape="rectangle", + style="minor", + color="secondary", + grow_x=True, + margin_left=0.6, + margin_right=0.6, + margin_top=0.6, + on_press=self.logout + ) ), fill=Color.TRANSPARENT, min_height=8, diff --git a/src/ez_lan_manager/pages/CateringPage.py b/src/ez_lan_manager/pages/CateringPage.py new file mode 100644 index 0000000..2400889 --- /dev/null +++ b/src/ez_lan_manager/pages/CateringPage.py @@ -0,0 +1,131 @@ +from typing import Optional + +from rio import Column, Component, event, TextStyle, Text, ScrollContainer, Row, Button, Spacer, IconButton + +from src.ez_lan_manager import ConfigurationService, CateringService, AccountingService +from src.ez_lan_manager.components.CateringCartItem import CateringCartItem +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem +from src.ez_lan_manager.types.SessionStorage import SessionStorage + + +class CateringPage(Component): + def __post_init__(self) -> None: + self.session[SessionStorage].subscribe_to_logged_in_or_out_event(self.__class__.__name__, self.on_user_logged_in_status_changed) + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Catering") + + async def on_user_logged_in_status_changed(self) -> None: + await self.force_refresh() + + async def on_remove_item(self, list_id: int) -> None: + catering_service = self.session[CateringService] + user_id = self.session[SessionStorage].user_id + cart = catering_service.get_cart(user_id) + try: + cart.pop(list_id) + except IndexError: + return + catering_service.save_cart(user_id, cart) + await self.force_refresh() + + async def on_empty_cart_pressed(self) -> None: + self.session[CateringService].save_cart(self.session[SessionStorage].user_id, []) + await self.force_refresh() + + def build(self) -> Component: + user_id = self.session[SessionStorage].user_id + catering_service = self.session[CateringService] + cart = catering_service.get_cart(user_id) + cart_container = ScrollContainer( + content=Column( + *[CateringCartItem( + article_name=cart_item.name, + article_price=cart_item.price, + article_id=cart_item.item_id, + remove_item_cb=self.on_remove_item, + list_id=idx + ) for idx, cart_item in enumerate(cart)], + Spacer(grow_y=True) + ), + min_height=8, + min_width=33, + margin=1 + ) + shopping_cart = MainViewContentBox( + Column( + Text( + text="Catering", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="Warenkorb", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_top=0.2, + margin_bottom=0, + align_x=0.5 + ), + cart_container, + Row( + Text( + text=f"Preis: {AccountingService.make_euro_string_from_int(sum(cart_item.price for cart_item in cart))}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin=1 + ), + Button( + content=Text( + "Warenkorb leeren", + style=TextStyle(fill=self.session.theme.danger_color, font_size=0.9), + align_x=0.2 + ), + margin=1, + margin_left=0, + shape="rectangle", + style="major", + color="primary", + on_press=self.on_empty_cart_pressed + ), + Button( + content=Text( + "Bestellen", + style=TextStyle(fill=self.session.theme.success_color, font_size=0.9), + align_x=0.2 + ), + margin=1, + margin_left=0, + shape="rectangle", + style="major", + color="primary" + ), + ) + ) + ) if user_id else Spacer() + + + + return BasePage( + content=Column( + # SHOPPING CART + shopping_cart, + # ITEM SELECTION + MainViewContentBox( + + ), + align_y=0 + ) + ) diff --git a/src/ez_lan_manager/pages/Logout.py b/src/ez_lan_manager/pages/Logout.py deleted file mode 100644 index 2d5dde3..0000000 --- a/src/ez_lan_manager/pages/Logout.py +++ /dev/null @@ -1,30 +0,0 @@ -from rio import Column, Component, event, Text, TextStyle - -from src.ez_lan_manager import ConfigurationService -from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage -from src.ez_lan_manager.types.SessionStorage import SessionStorage - - -class LogoutPage(Component): - @event.on_populate - async def on_populate(self) -> None: - await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Logout") - - def build(self) -> Component: - self.session[SessionStorage].clear() - return BasePage( - content=Column( - MainViewContentBox( - content=Text( - "Auf wiedersehen o/", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.4 - ), - margin=2 - ) - ), - align_y=0, - ) - ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index e4d2c66..fddb2d2 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -1,7 +1,6 @@ from .BasePage import BasePage from .NewsPage import NewsPage from .PlaceholderPage import PlaceholderPage -from .Logout import LogoutPage from .Account import AccountPage from .EditProfile import EditProfilePage from .ForgotPassword import ForgotPasswordPage @@ -12,3 +11,4 @@ from .RulesPage import RulesPage from .FaqPage import FaqPage from .TournamentsPage import TournamentsPage from .GuestsPage import GuestsPage +from .CateringPage import CateringPage diff --git a/src/ez_lan_manager/services/CateringService.py b/src/ez_lan_manager/services/CateringService.py index df8f771..6287e83 100644 --- a/src/ez_lan_manager/services/CateringService.py +++ b/src/ez_lan_manager/services/CateringService.py @@ -19,6 +19,16 @@ class CateringService: self._db_service = db_service self._accounting_service = accounting_service self._user_service = user_service + self.cached_cart: dict[int, list[CateringMenuItem]] = { # REMOVE + 27: [ + CateringMenuItem(1, "Bockwurst", 150, CateringMenuItemCategory.SNACK), + CateringMenuItem(2, "Pils", 120, CateringMenuItemCategory.SNACK), + CateringMenuItem(3, "Pfezzi", 200, CateringMenuItemCategory.SNACK), + CateringMenuItem(3, "Pfezzi", 200, CateringMenuItemCategory.SNACK), + CateringMenuItem(4, "Pizza", 1150, CateringMenuItemCategory.MAIN_COURSE), + CateringMenuItem(5, "Zigaretten", 800, CateringMenuItemCategory.NON_FOOD), + ] + } # ORDERS @@ -110,3 +120,17 @@ class CateringService: def enable_menu_items_by_category(self, category: CateringMenuItemCategory) -> bool: items = self.get_menu(category=category) return all([self.enable_menu_item(item.item_id) for item in items]) + + # CART + + def save_cart(self, user_id: Optional[int], cart: list[CateringMenuItem]) -> None: + if user_id: + self.cached_cart[user_id] = cart + + def get_cart(self, user_id: Optional[int]) -> list[CateringMenuItem]: + if user_id is None: + return [] + try: + return self.cached_cart[user_id] + except KeyError: + return [] diff --git a/src/ez_lan_manager/types/SessionStorage.py b/src/ez_lan_manager/types/SessionStorage.py index b8199c6..ede4b78 100644 --- a/src/ez_lan_manager/types/SessionStorage.py +++ b/src/ez_lan_manager/types/SessionStorage.py @@ -1,4 +1,5 @@ -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, field from typing import Optional @@ -6,7 +7,20 @@ from typing import Optional # Note for ToDo: rio.UserSettings are saved LOCALLY, do not just read a user_id here! @dataclass(frozen=False) class SessionStorage: - user_id: Optional[int] = None # DEBUG: Put user ID here to skip login + _user_id: Optional[int] = None # DEBUG: Put user ID here to skip login + _notification_callbacks: dict[str, Callable] = field(default_factory=dict) - def clear(self) -> None: - self.user_id = None + async def clear(self) -> None: + await self.set_user_id(None) + + def subscribe_to_logged_in_or_out_event(self, component_id: str, callback: Callable) -> None: + self._notification_callbacks[component_id] = callback + + @property + def user_id(self) -> Optional[int]: + return self._user_id + + async def set_user_id(self, user_id: Optional[int]) -> None: + self._user_id = user_id + for callback in self._notification_callbacks.values(): + await callback() -- 2.45.2 From 90a344e4609dfb334e7d10e49586b7ee4db81ab6 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 28 Aug 2024 14:20:41 +0200 Subject: [PATCH 47/85] refactor catering, add orders view --- .../components/CateringOrderItem.py | 40 +++ .../components/CateringSelectionItem.py | 70 ++++ .../components/MainViewContentBox.py | 2 +- .../components/ShoppingCartAndOrders.py | 116 ++++++ .../helpers/create_demo_database_content.py | 10 +- src/ez_lan_manager/pages/BasePage.py | 8 +- src/ez_lan_manager/pages/CateringPage.py | 331 ++++++++++++------ .../services/CateringService.py | 11 +- src/ez_lan_manager/types/CateringMenuItem.py | 2 + src/ez_lan_manager/types/SessionStorage.py | 2 +- 10 files changed, 472 insertions(+), 120 deletions(-) create mode 100644 src/ez_lan_manager/components/CateringOrderItem.py create mode 100644 src/ez_lan_manager/components/CateringSelectionItem.py create mode 100644 src/ez_lan_manager/components/ShoppingCartAndOrders.py diff --git a/src/ez_lan_manager/components/CateringOrderItem.py b/src/ez_lan_manager/components/CateringOrderItem.py new file mode 100644 index 0000000..4018c1a --- /dev/null +++ b/src/ez_lan_manager/components/CateringOrderItem.py @@ -0,0 +1,40 @@ +from datetime import datetime +from typing import Callable + +import rio +from rio import Component, Row, Text, IconButton, TextStyle, Color + +from src.ez_lan_manager import AccountingService +from src.ez_lan_manager.types.CateringOrder import CateringOrderStatus + +MAX_LEN = 24 + +class CateringOrderItem(Component): + order_id: int + order_datetime: datetime + order_status: CateringOrderStatus + + def get_display_text_and_color_for_order_status(self, order_status: CateringOrderStatus) -> tuple[str, Color]: + match order_status: + case CateringOrderStatus.RECEIVED: + return "In Bearbeitung", self.session.theme.success_color + case CateringOrderStatus.DELAYED: + return "Verspätet", Color.from_hex("eed202") + case CateringOrderStatus.READY_FOR_PICKUP: + return "Abholbereit", self.session.theme.success_color + case CateringOrderStatus.EN_ROUTE: + return "Unterwegs", self.session.theme.success_color + case CateringOrderStatus.COMPLETED: + return "Abgeschlossen", self.session.theme.success_color + case CateringOrderStatus.CANCELED: + return "Storniert", self.session.theme.danger_color + case _: + return "Unbekannt(wtf?)", self.session.theme.danger_color + + def build(self) -> rio.Component: + order_status, color = self.get_display_text_and_color_for_order_status(self.order_status) + return Row( + Text(f"ID: {str(self.order_id):0>6}", align_x=0, wrap=True, min_width=10, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), margin_right=1), + Text(order_status, wrap=True, min_width=10, style=TextStyle(fill=color, font_size=0.9), margin_right=1), + Text(self.order_datetime.strftime("%d.%m. %H:%M"), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), align_x=1) + ) diff --git a/src/ez_lan_manager/components/CateringSelectionItem.py b/src/ez_lan_manager/components/CateringSelectionItem.py new file mode 100644 index 0000000..bfbb56d --- /dev/null +++ b/src/ez_lan_manager/components/CateringSelectionItem.py @@ -0,0 +1,70 @@ +from typing import Callable + +import rio +from rio import Component, Row, Text, IconButton, TextStyle, Column, Spacer, Card, Color + +from src.ez_lan_manager import AccountingService + +MAX_LEN = 24 + +class CateringSelectionItem(Component): + article_name: str + article_price: int + article_id: int + on_add_callback: Callable + is_sensitive: bool + additional_info: str + is_grey: bool + + @staticmethod + def split_article_name(article_name: str) -> tuple[str, str]: + if len(article_name) <= MAX_LEN: + return article_name, "" + top, bottom = "", "" + words = article_name.split(" ") + last_word_added = "" + while len(top) <= MAX_LEN: + w = words.pop(0) + top += f" {w}" + last_word_added = w + + top = top.replace(last_word_added, "") + bottom = f"{last_word_added} " + " ".join(words) + + return top.strip(), bottom.strip() + + + def build(self) -> rio.Component: + article_name_top, article_name_bottom = self.split_article_name(self.article_name) + + return Card( + content=Column( + Row( + Text(article_name_top, align_x=0, wrap=True, min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), + Text(AccountingService.make_euro_string_from_int(self.article_price), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), + IconButton( + icon="material/add", + size=2, + color=self.session.theme.success_color, + style="plain", + on_press=lambda: self.on_add_callback(self.article_id), + is_sensitive=self.is_sensitive + ), + proportions=(19, 5, 2), + margin_bottom=0 + ), + Spacer() if not article_name_bottom else Text(article_name_bottom, align_x=0, wrap=True, min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), + Row( + Text( + self.additional_info, + align_x=0, + wrap=True, + min_width=19, + style=TextStyle(fill=self.session.theme.background_color, font_size=0.6) + ), + margin_top=0 + ), + margin_bottom=0.5, + ), + color=Color.from_hex("d3d3d3") if self.is_grey else self.session.theme.primary_color + ) diff --git a/src/ez_lan_manager/components/MainViewContentBox.py b/src/ez_lan_manager/components/MainViewContentBox.py index ac635aa..bb5de04 100644 --- a/src/ez_lan_manager/components/MainViewContentBox.py +++ b/src/ez_lan_manager/components/MainViewContentBox.py @@ -16,7 +16,7 @@ class MainViewContentBox(Component): fill=self.session.theme.primary_color, margin_left=1, margin_right=1, - margin_top=2, + margin_top=1, margin_bottom=1, shadow_radius=0.5, shadow_color=self.session.theme.hud_color, diff --git a/src/ez_lan_manager/components/ShoppingCartAndOrders.py b/src/ez_lan_manager/components/ShoppingCartAndOrders.py new file mode 100644 index 0000000..1639626 --- /dev/null +++ b/src/ez_lan_manager/components/ShoppingCartAndOrders.py @@ -0,0 +1,116 @@ +import rio +from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer + +from src.ez_lan_manager.components.CateringCartItem import CateringCartItem +from src.ez_lan_manager.components.CateringOrderItem import CateringOrderItem +from src.ez_lan_manager.services.AccountingService import AccountingService +from src.ez_lan_manager.services.CateringService import CateringService +from src.ez_lan_manager.types.CateringOrder import CateringOrder +from src.ez_lan_manager.types.SessionStorage import SessionStorage + + +class ShoppingCartAndOrders(Component): + show_cart: bool = True + + async def switch(self) -> None: + self.show_cart = not self.show_cart + + async def on_remove_item(self, list_id: int) -> None: + catering_service = self.session[CateringService] + user_id = self.session[SessionStorage].user_id + cart = catering_service.get_cart(user_id) + try: + cart.pop(list_id) + except IndexError: + return + catering_service.save_cart(user_id, cart) + await self.force_refresh() + + async def on_empty_cart_pressed(self) -> None: + self.session[CateringService].save_cart(self.session[SessionStorage].user_id, []) + await self.force_refresh() + + async def on_add_item(self, article_id: int) -> None: + catering_service = self.session[CateringService] + user_id = self.session[SessionStorage].user_id + if not user_id: + return + cart = catering_service.get_cart(user_id) + cart.append(catering_service.get_menu_item_by_id(article_id)) + catering_service.save_cart(user_id, cart) + await self.force_refresh() + + def build(self) -> rio.Component: + user_id = self.session[SessionStorage].user_id + catering_service = self.session[CateringService] + if self.show_cart: + cart = catering_service.get_cart(user_id) + cart_container = ScrollContainer( + content=Column( + *[CateringCartItem( + article_name=cart_item.name, + article_price=cart_item.price, + article_id=cart_item.item_id, + remove_item_cb=self.on_remove_item, + list_id=idx + ) for idx, cart_item in enumerate(cart)], + Spacer(grow_y=True) + ), + min_height=8, + min_width=33, + margin=1 + ) + return Column( + cart_container, + Row( + Text( + text=f"Preis: {AccountingService.make_euro_string_from_int(sum(cart_item.price for cart_item in cart))}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin=1 + ), + Button( + content=Text( + "Warenkorb leeren", + style=TextStyle(fill=self.session.theme.danger_color, font_size=0.9), + align_x=0.2 + ), + margin=1, + margin_left=0, + shape="rectangle", + style="major", + color="primary", + on_press=self.on_empty_cart_pressed + ), + Button( + content=Text( + "Bestellen", + style=TextStyle(fill=self.session.theme.success_color, font_size=0.9), + align_x=0.2 + ), + margin=1, + margin_left=0, + shape="rectangle", + style="major", + color="primary" + ) + ) + ) + else: + orders = catering_service.get_orders_for_user(user_id) + orders_container = ScrollContainer( + content=Column( + *[CateringOrderItem( + order_id=order_item.order_id, + order_datetime=order_item.order_date, + order_status=order_item.status, + ) for order_item in orders], + Spacer(grow_y=True) + ), + min_height=8, + min_width=33, + margin=1 + ) + return Column(orders_container) \ No newline at end of file diff --git a/src/ez_lan_manager/helpers/create_demo_database_content.py b/src/ez_lan_manager/helpers/create_demo_database_content.py index 4e3d091..491c8a4 100644 --- a/src/ez_lan_manager/helpers/create_demo_database_content.py +++ b/src/ez_lan_manager/helpers/create_demo_database_content.py @@ -75,11 +75,11 @@ if __name__ == "__main__": catering_service.add_menu_item("Nudelsalat mit Bockwurst", "", 600, CateringMenuItemCategory.SNACK) catering_service.add_menu_item("Kartoffelsalat", "", 450, CateringMenuItemCategory.SNACK) catering_service.add_menu_item("Kartoffelsalat mit Bockwurst", "", 600, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Sandwichtoast - Schinken", "mit Margarine", 180, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Sandwichtoast - Käse", "mit Margarine", 180, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Sandwichtoast - Schinken/Käse", "mit Margarine", 210, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Sandwichtoast - Salami", "mit Margarine", 180, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Sandwichtoast - Salami/Käse", "mit Margarine", 210, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Sandwichtoast - Schinken", "", 180, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Sandwichtoast - Käse", "", 180, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Sandwichtoast - Schinken/Käse", "", 210, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Sandwichtoast - Salami", "", 180, CateringMenuItemCategory.SNACK) + catering_service.add_menu_item("Sandwichtoast - Salami/Käse", "", 210, CateringMenuItemCategory.SNACK) catering_service.add_menu_item("Chips - Western Style", "", 130, CateringMenuItemCategory.SNACK) catering_service.add_menu_item("Nachos - Salted", "", 130, CateringMenuItemCategory.SNACK) diff --git a/src/ez_lan_manager/pages/BasePage.py b/src/ez_lan_manager/pages/BasePage.py index 9a7af35..8c30072 100644 --- a/src/ez_lan_manager/pages/BasePage.py +++ b/src/ez_lan_manager/pages/BasePage.py @@ -27,7 +27,6 @@ class BasePage(Component): if self.session.window_width > 28: return Container( content=Column( - Row(), Column( Row( Spacer(grow_x=True, grow_y=True), @@ -50,10 +49,9 @@ class BasePage(Component): ), Spacer(grow_x=True, grow_y=False), grow_y=False - ) - ), - Row(), - proportions=[4, 92, 4] + ), + margin_top=4 + ) ), grow_x=True, grow_y=True diff --git a/src/ez_lan_manager/pages/CateringPage.py b/src/ez_lan_manager/pages/CateringPage.py index 2400889..09af45f 100644 --- a/src/ez_lan_manager/pages/CateringPage.py +++ b/src/ez_lan_manager/pages/CateringPage.py @@ -1,16 +1,17 @@ -from typing import Optional +from rio import Column, Component, event, TextStyle, Text, Spacer, Revealer, SwitcherBar, SwitcherBarChangeEvent -from rio import Column, Component, event, TextStyle, Text, ScrollContainer, Row, Button, Spacer, IconButton - -from src.ez_lan_manager import ConfigurationService, CateringService, AccountingService -from src.ez_lan_manager.components.CateringCartItem import CateringCartItem +from src.ez_lan_manager import ConfigurationService, CateringService +from src.ez_lan_manager.components.CateringSelectionItem import CateringSelectionItem from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.components.ShoppingCartAndOrders import ShoppingCartAndOrders from src.ez_lan_manager.pages import BasePage -from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem +from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory from src.ez_lan_manager.types.SessionStorage import SessionStorage class CateringPage(Component): + show_cart = True + def __post_init__(self) -> None: self.session[SessionStorage].subscribe_to_logged_in_or_out_event(self.__class__.__name__, self.on_user_logged_in_status_changed) @@ -21,110 +22,244 @@ class CateringPage(Component): async def on_user_logged_in_status_changed(self) -> None: await self.force_refresh() - async def on_remove_item(self, list_id: int) -> None: - catering_service = self.session[CateringService] - user_id = self.session[SessionStorage].user_id - cart = catering_service.get_cart(user_id) - try: - cart.pop(list_id) - except IndexError: - return - catering_service.save_cart(user_id, cart) - await self.force_refresh() - - async def on_empty_cart_pressed(self) -> None: - self.session[CateringService].save_cart(self.session[SessionStorage].user_id, []) - await self.force_refresh() + async def on_switcher_bar_changed(self, _: SwitcherBarChangeEvent) -> None: + await self.shopping_cart_and_orders.switch() def build(self) -> Component: user_id = self.session[SessionStorage].user_id catering_service = self.session[CateringService] - cart = catering_service.get_cart(user_id) - cart_container = ScrollContainer( - content=Column( - *[CateringCartItem( - article_name=cart_item.name, - article_price=cart_item.price, - article_id=cart_item.item_id, - remove_item_cb=self.on_remove_item, - list_id=idx - ) for idx, cart_item in enumerate(cart)], - Spacer(grow_y=True) - ), - min_height=8, - min_width=33, - margin=1 + self.shopping_cart_and_orders = ShoppingCartAndOrders() + switcher_bar = SwitcherBar( + values=["cart", "orders"], + names=["Warenkorb", "Bestellungen"], + selected_value="cart", + margin_left=5, + margin_right=5, + margin_top=1, + margin_bottom=1, + color=self.session.theme.hud_color, + on_change=self.on_switcher_bar_changed ) - shopping_cart = MainViewContentBox( - Column( - Text( - text="Catering", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=0, - align_x=0.5 - ), - Text( - text="Warenkorb", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ), - margin_top=0.2, - margin_bottom=0, - align_x=0.5 - ), - cart_container, - Row( - Text( - text=f"Preis: {AccountingService.make_euro_string_from_int(sum(cart_item.price for cart_item in cart))}", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ), - margin=1 - ), - Button( - content=Text( - "Warenkorb leeren", - style=TextStyle(fill=self.session.theme.danger_color, font_size=0.9), - align_x=0.2 - ), - margin=1, - margin_left=0, - shape="rectangle", - style="major", - color="primary", - on_press=self.on_empty_cart_pressed - ), - Button( - content=Text( - "Bestellen", - style=TextStyle(fill=self.session.theme.success_color, font_size=0.9), - align_x=0.2 - ), - margin=1, - margin_left=0, - shape="rectangle", - style="major", - color="primary" - ), - ) - ) - ) if user_id else Spacer() - + shopping_cart_and_orders_container = MainViewContentBox( + Column( + Text( + text="Catering", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + switcher_bar, + self.shopping_cart_and_orders + ) + ) if user_id else Spacer() return BasePage( content=Column( # SHOPPING CART - shopping_cart, + shopping_cart_and_orders_container, # ITEM SELECTION MainViewContentBox( - + Revealer( + header="Snacks", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders.on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.SNACK))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Frühstück", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders.on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.BREAKFAST))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Hauptspeisen", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders.on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.MAIN_COURSE))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Desserts", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders.on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.DESSERT))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Wasser & Softdrinks", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders.on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Alkoholische Getränke", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders.on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.BEVERAGE_ALCOHOLIC))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Cocktails & Longdrinks", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders.on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.BEVERAGE_COCKTAIL))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Shots", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders.on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.BEVERAGE_SHOT))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Sonstiges", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders.on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.NON_FOOD))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) ), align_y=0 ) diff --git a/src/ez_lan_manager/services/CateringService.py b/src/ez_lan_manager/services/CateringService.py index 6287e83..33effae 100644 --- a/src/ez_lan_manager/services/CateringService.py +++ b/src/ez_lan_manager/services/CateringService.py @@ -19,16 +19,7 @@ class CateringService: self._db_service = db_service self._accounting_service = accounting_service self._user_service = user_service - self.cached_cart: dict[int, list[CateringMenuItem]] = { # REMOVE - 27: [ - CateringMenuItem(1, "Bockwurst", 150, CateringMenuItemCategory.SNACK), - CateringMenuItem(2, "Pils", 120, CateringMenuItemCategory.SNACK), - CateringMenuItem(3, "Pfezzi", 200, CateringMenuItemCategory.SNACK), - CateringMenuItem(3, "Pfezzi", 200, CateringMenuItemCategory.SNACK), - CateringMenuItem(4, "Pizza", 1150, CateringMenuItemCategory.MAIN_COURSE), - CateringMenuItem(5, "Zigaretten", 800, CateringMenuItemCategory.NON_FOOD), - ] - } + self.cached_cart: dict[int, list[CateringMenuItem]] = {} # ORDERS diff --git a/src/ez_lan_manager/types/CateringMenuItem.py b/src/ez_lan_manager/types/CateringMenuItem.py index 5de46b6..09119b6 100644 --- a/src/ez_lan_manager/types/CateringMenuItem.py +++ b/src/ez_lan_manager/types/CateringMenuItem.py @@ -1,5 +1,7 @@ from dataclasses import dataclass from enum import StrEnum +from typing import Self + class CateringMenuItemCategory(StrEnum): MAIN_COURSE = "MAIN_COURSE" diff --git a/src/ez_lan_manager/types/SessionStorage.py b/src/ez_lan_manager/types/SessionStorage.py index ede4b78..de3d45a 100644 --- a/src/ez_lan_manager/types/SessionStorage.py +++ b/src/ez_lan_manager/types/SessionStorage.py @@ -7,7 +7,7 @@ from typing import Optional # Note for ToDo: rio.UserSettings are saved LOCALLY, do not just read a user_id here! @dataclass(frozen=False) class SessionStorage: - _user_id: Optional[int] = None # DEBUG: Put user ID here to skip login + _user_id: Optional[int] = 30 # DEBUG: Put user ID here to skip login _notification_callbacks: dict[str, Callable] = field(default_factory=dict) async def clear(self) -> None: -- 2.45.2 From 2be572ea9004d9c0e38c6d64f18359a7e4657df4 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 28 Aug 2024 14:21:09 +0200 Subject: [PATCH 48/85] undo --- src/ez_lan_manager/types/SessionStorage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ez_lan_manager/types/SessionStorage.py b/src/ez_lan_manager/types/SessionStorage.py index de3d45a..ede4b78 100644 --- a/src/ez_lan_manager/types/SessionStorage.py +++ b/src/ez_lan_manager/types/SessionStorage.py @@ -7,7 +7,7 @@ from typing import Optional # Note for ToDo: rio.UserSettings are saved LOCALLY, do not just read a user_id here! @dataclass(frozen=False) class SessionStorage: - _user_id: Optional[int] = 30 # DEBUG: Put user ID here to skip login + _user_id: Optional[int] = None # DEBUG: Put user ID here to skip login _notification_callbacks: dict[str, Callable] = field(default_factory=dict) async def clear(self) -> None: -- 2.45.2 From 3a20f6c97654ee25d0dd439fb2a434d10516c93e Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 28 Aug 2024 14:58:46 +0200 Subject: [PATCH 49/85] refactor login box --- .../components/DesktopNavigation.py | 6 +- src/ez_lan_manager/components/LoginBox.py | 85 --------- .../components/UserInfoAndLoginBox.py | 168 ++++++++++++++++++ src/ez_lan_manager/components/UserInfoBox.py | 77 -------- 4 files changed, 170 insertions(+), 166 deletions(-) delete mode 100644 src/ez_lan_manager/components/LoginBox.py create mode 100644 src/ez_lan_manager/components/UserInfoAndLoginBox.py delete mode 100644 src/ez_lan_manager/components/UserInfoBox.py diff --git a/src/ez_lan_manager/components/DesktopNavigation.py b/src/ez_lan_manager/components/DesktopNavigation.py index 0d03a77..dbcb381 100644 --- a/src/ez_lan_manager/components/DesktopNavigation.py +++ b/src/ez_lan_manager/components/DesktopNavigation.py @@ -2,8 +2,7 @@ from rio import * from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.DesktopNavigationButton import DesktopNavigationButton -from src.ez_lan_manager.components.LoginBox import LoginBox -from src.ez_lan_manager.components.UserInfoBox import UserInfoBox +from src.ez_lan_manager.components.UserInfoAndLoginBox import UserInfoAndLoginBox from src.ez_lan_manager.types.SessionStorage import SessionStorage class DesktopNavigation(Component): @@ -14,13 +13,12 @@ class DesktopNavigation(Component): await self.force_refresh() def build(self) -> Component: - box = LoginBox() if self.session[SessionStorage].user_id is None else UserInfoBox() lan_info = self.session[ConfigurationService].get_lan_info() return Card( Column( Text(lan_info.name, align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.hud_color, font_size=2.5)), Text(f"Edition {lan_info.iteration}", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=1.2), margin_top=0.3, margin_bottom=2), - box, + UserInfoAndLoginBox(refresh_cb=self.refresh_cb), DesktopNavigationButton("News", "./news"), Spacer(min_height=1), DesktopNavigationButton(f"Über {lan_info.name} {lan_info.iteration}", "./overview"), diff --git a/src/ez_lan_manager/components/LoginBox.py b/src/ez_lan_manager/components/LoginBox.py deleted file mode 100644 index 56bde83..0000000 --- a/src/ez_lan_manager/components/LoginBox.py +++ /dev/null @@ -1,85 +0,0 @@ -from typing import Callable - -from rio import Component, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput - -from src.ez_lan_manager import UserService -from src.ez_lan_manager.types.SessionStorage import SessionStorage - - -class LoginBox(Component): - TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) - - async def _on_login_pressed(self) -> None: - self.login_button.is_loading = True - user_name = self.user_name_input.text.lower() - if self.session[UserService].is_login_valid(user_name, self.password_input.text): - self.user_name_input.is_valid = True - self.password_input.is_valid = True - self.login_button.is_loading = False - await self.session[SessionStorage].set_user_id(self.session[UserService].get_user(user_name).user_id) - else: - self.user_name_input.is_valid = False - self.password_input.is_valid = False - self.login_button.is_loading = False - - def build(self) -> Component: - self.user_name_input = TextInput( - text="", - label="Benutzername", - accessibility_label = "Benutzername", - min_height=0.5, - on_confirm=lambda _: self._on_login_pressed() - ) - self.password_input = TextInput( - text="", - label="Passwort", - accessibility_label="Passwort", - is_secret=True, - on_confirm=lambda _: self._on_login_pressed() - ) - self.login_button = Button( - Text("LOGIN", style=self.TEXT_STYLE, justify="center"), - shape="rectangle", - style="minor", - color="secondary", - margin_bottom=0.4, - on_press=self._on_login_pressed - ) - self.register_button = Button( - Text("REG", style=self.TEXT_STYLE, justify="center"), - shape="rectangle", - style="minor", - color="secondary", - on_press=lambda: self.session.navigate_to("./register") - ) - self.forgot_password_button = Button( - Text("LST PWD",style=self.TEXT_STYLE, justify="center"), - shape="rectangle", - style="minor", - color="secondary", - on_press=lambda: self.session.navigate_to("./forgot-password") - ) - return Rectangle( - content=Column( - self.user_name_input, - self.password_input, - Column( - Row( - self.login_button - ), - Row( - self.register_button, - Spacer(), - self.forgot_password_button, - proportions=(49, 2, 49) - ) - ), - spacing=0.4 - ), - fill=Color.TRANSPARENT, - min_height=8, - min_width=12, - align_x=0.5, - margin_top=0.3, - margin_bottom=2 - ) diff --git a/src/ez_lan_manager/components/UserInfoAndLoginBox.py b/src/ez_lan_manager/components/UserInfoAndLoginBox.py new file mode 100644 index 0000000..1ac3255 --- /dev/null +++ b/src/ez_lan_manager/components/UserInfoAndLoginBox.py @@ -0,0 +1,168 @@ +from random import choice +from typing import Callable + +from rio import Component, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput, Link + +from src.ez_lan_manager import UserService +from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton +from src.ez_lan_manager.services.AccountingService import AccountingService +from src.ez_lan_manager.services.TicketingService import TicketingService +from src.ez_lan_manager.services.SeatingService import SeatingService +from src.ez_lan_manager.types.SessionStorage import SessionStorage + +class StatusButton(Component): + STYLE = TextStyle(fill=Color.from_hex("121212"), font_size=0.5) + label: str + target_url: str + enabled: bool + + def build(self) -> Component: + return Link( + content=Button( + content=Text(self.label, style=self.STYLE, justify="center"), + shape="rectangle", + style="major", + color="success" if self.enabled else "danger", + grow_x=True, + margin_left=0.6, + margin_right=0.6, + margin_top=0.6 + ), + target_url=self.target_url, + align_y=0.5, + grow_y=False + ) + + +class UserInfoAndLoginBox(Component): + refresh_cb: Callable + TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + show_login: bool = True + + @staticmethod + def get_greeting() -> str: + return choice(["Grüße", "Hallo", "Willkommen", "Moin", "Ahoi"]) + + async def logout(self) -> None: + self.show_login = True + await self.session[SessionStorage].clear() + await self.force_refresh() + await self.refresh_cb() + + async def _on_login_pressed(self) -> None: + self.login_button.is_loading = True + await self.login_button.force_refresh() + user_name = self.user_name_input.text.lower() + if self.session[UserService].is_login_valid(user_name, self.password_input.text): + self.user_name_input.is_valid = True + self.password_input.is_valid = True + self.login_button.is_loading = False + await self.session[SessionStorage].set_user_id(self.session[UserService].get_user(user_name).user_id) + self.show_login = False + await self.refresh_cb() + else: + self.user_name_input.is_valid = False + self.password_input.is_valid = False + self.login_button.is_loading = False + + def build(self) -> Component: + self.user_name_input = TextInput( + text="", + label="Benutzername", + accessibility_label="Benutzername", + min_height=0.5, + on_confirm=lambda _: self._on_login_pressed() + ) + self.password_input = TextInput( + text="", + label="Passwort", + accessibility_label="Passwort", + is_secret=True, + on_confirm=lambda _: self._on_login_pressed() + ) + self.login_button = Button( + Text("LOGIN", style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary", + margin_bottom=0.4, + on_press=self._on_login_pressed + ) + self.register_button = Button( + Text("REG", style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary", + on_press=lambda: self.session.navigate_to("./register") + ) + self.forgot_password_button = Button( + Text("LST PWD", style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary", + on_press=lambda: self.session.navigate_to("./forgot-password") + ) + + if self.show_login: + return Rectangle( + content=Column( + self.user_name_input, + self.password_input, + Column( + Row( + self.login_button + ), + Row( + self.register_button, + Spacer(), + self.forgot_password_button, + proportions=(49, 2, 49) + ) + ), + spacing=0.4 + ), + fill=Color.TRANSPARENT, + min_height=8, + min_width=12, + align_x=0.5, + margin_top=0.3, + margin_bottom=2 + ) + else: + user = self.session[UserService].get_user(self.session[SessionStorage].user_id) + if user is None: + print("ERROR") + a_s = self.session[AccountingService] + return Rectangle( + content=Column( + Text(f"{self.get_greeting()},", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9), justify="center"), + Text(f"{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.session[TicketingService].get_user_ticket(user.user_id) is not None), + StatusButton(label="SITZPLATZ", target_url="./seating", + enabled=self.session[SeatingService].get_user_seat(user.user_id) is not None), + proportions=(50, 50), + grow_y=False + ), + UserInfoBoxButton("Profil bearbeiten", "./edit-profile"), + UserInfoBoxButton(f"Guthaben: {a_s.make_euro_string_from_int(a_s.get_balance(user.user_id))}", "./account"), + Button( + content=Text("Ausloggen", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.6)), + shape="rectangle", + style="minor", + color="secondary", + grow_x=True, + margin_left=0.6, + margin_right=0.6, + margin_top=0.6, + on_press=self.logout + ) + ), + fill=Color.TRANSPARENT, + min_height=8, + min_width=12, + align_x=0.5, + margin_top=0.3, + margin_bottom=2 + ) diff --git a/src/ez_lan_manager/components/UserInfoBox.py b/src/ez_lan_manager/components/UserInfoBox.py deleted file mode 100644 index 40b0e7f..0000000 --- a/src/ez_lan_manager/components/UserInfoBox.py +++ /dev/null @@ -1,77 +0,0 @@ -from random import choice - -from rio import Component, Column, Text, Row, Rectangle, Button, TextStyle, Color, Link - -from src.ez_lan_manager import UserService, AccountingService, TicketingService, SeatingService -from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton -from src.ez_lan_manager.types.SessionStorage import SessionStorage - -class StatusButton(Component): - STYLE = TextStyle(fill=Color.from_hex("121212"), font_size=0.5) - label: str - target_url: str - enabled: bool - - def build(self) -> Component: - return Link( - content=Button( - content=Text(self.label, style=self.STYLE, justify="center"), - shape="rectangle", - style="major", - color="success" if self.enabled else "danger", - grow_x=True, - margin_left=0.6, - margin_right=0.6, - margin_top=0.6 - ), - target_url=self.target_url, - align_y=0.5, - grow_y=False - ) - - -class UserInfoBox(Component): - @staticmethod - def get_greeting() -> str: - return choice(["Grüße", "Hallo", "Willkommen", "Moin", "Ahoi"]) - - async def logout(self) -> None: - await self.session[SessionStorage].clear() - await self.force_refresh() - - def build(self) -> Component: - user = self.session[UserService].get_user(self.session[SessionStorage].user_id) - if user is None: # Noone logged in - return Text("") - a_s = self.session[AccountingService] - return Rectangle( - content=Column( - Text(f"{self.get_greeting()},", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9), justify="center"), - Text(f"{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.session[TicketingService].get_user_ticket(user.user_id) is not None), - StatusButton(label="SITZPLATZ", target_url="./seating", enabled=self.session[SeatingService].get_user_seat(user.user_id) is not None), - proportions=(50, 50), - grow_y=False - ), - UserInfoBoxButton("Profil bearbeiten", "./edit-profile"), - UserInfoBoxButton(f"Guthaben: {a_s.make_euro_string_from_int(a_s.get_balance(user.user_id))}", "./account"), - Button( - content=Text("Ausloggen", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.6)), - shape="rectangle", - style="minor", - color="secondary", - grow_x=True, - margin_left=0.6, - margin_right=0.6, - margin_top=0.6, - on_press=self.logout - ) - ), - fill=Color.TRANSPARENT, - min_height=8, - min_width=12, - align_x=0.5, - margin_top=0.3, - margin_bottom=2 - ) -- 2.45.2 From 7b7e52c474314b588936c603d8ae17d6432d7238 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 28 Aug 2024 15:02:27 +0200 Subject: [PATCH 50/85] logout hotfix --- src/ez_lan_manager/components/UserInfoAndLoginBox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ez_lan_manager/components/UserInfoAndLoginBox.py b/src/ez_lan_manager/components/UserInfoAndLoginBox.py index 1ac3255..02df408 100644 --- a/src/ez_lan_manager/components/UserInfoAndLoginBox.py +++ b/src/ez_lan_manager/components/UserInfoAndLoginBox.py @@ -103,7 +103,7 @@ class UserInfoAndLoginBox(Component): on_press=lambda: self.session.navigate_to("./forgot-password") ) - if self.show_login: + if self.show_login and self.session[SessionStorage].user_id is None: return Rectangle( content=Column( self.user_name_input, -- 2.45.2 From e9f3e78cbdf1a7cee6951bce57f86273e30dc891 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 28 Aug 2024 15:26:37 +0200 Subject: [PATCH 51/85] document bug, implement workaround and move on --- .../components/UserInfoAndLoginBox.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ez_lan_manager/components/UserInfoAndLoginBox.py b/src/ez_lan_manager/components/UserInfoAndLoginBox.py index 02df408..c3bfccd 100644 --- a/src/ez_lan_manager/components/UserInfoAndLoginBox.py +++ b/src/ez_lan_manager/components/UserInfoAndLoginBox.py @@ -41,17 +41,24 @@ class UserInfoAndLoginBox(Component): @staticmethod def get_greeting() -> str: - return choice(["Grüße", "Hallo", "Willkommen", "Moin", "Ahoi"]) + return choice(["Guten Tacho", "Tuten Gag", "Hallöchen mit Öchen", "Servus", "Moinjour", "Hallöchen Popöchen", "Heyho", "Moinsen"]) + # @FixMe: If the user logs out and then tries to log back in, it does not work + # If the user navigates to another page and then tries again. It works. + # When fixed, remove the workaround below. async def logout(self) -> None: self.show_login = True await self.session[SessionStorage].clear() await self.force_refresh() - await self.refresh_cb() + # @FixMe: Workaround for the bug described above. Navigating to another page solves the issue. + # Yet, this is not desired behavior. + subpage = str(self.session.active_page_url) + if subpage.endswith("/") or subpage.endswith("news"): + self.session.navigate_to("./overview") + else: + self.session.navigate_to("./news") async def _on_login_pressed(self) -> None: - self.login_button.is_loading = True - await self.login_button.force_refresh() user_name = self.user_name_input.text.lower() if self.session[UserService].is_login_valid(user_name, self.password_input.text): self.user_name_input.is_valid = True -- 2.45.2 From e3359229ecd61d9a73173b4914a8e23e2472a58f Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 28 Aug 2024 22:16:15 +0200 Subject: [PATCH 52/85] implement devmode --- config/config.example.toml | 2 ++ src/ez_lan_manager/__init__.py | 2 +- .../services/ConfigurationService.py | 4 ++++ src/ez_lan_manager/services/MailingService.py | 14 +++++++++++--- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/config/config.example.toml b/config/config.example.toml index 918eeba..2b1bf40 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -24,3 +24,5 @@ [seating] base_svg_path="" + +dev_mode_active=false # Supresses E-Mail sending diff --git a/src/ez_lan_manager/__init__.py b/src/ez_lan_manager/__init__.py index 28b5bb0..88c136e 100644 --- a/src/ez_lan_manager/__init__.py +++ b/src/ez_lan_manager/__init__.py @@ -22,7 +22,7 @@ def init_services() -> tuple[AccountingService, CateringService, ConfigurationSe user_service = UserService(db_service) accounting_service = AccountingService(db_service) news_service = NewsService(db_service) - mailing_service = MailingService(configuration_service.get_mailing_service_configuration()) + mailing_service = MailingService(configuration_service) ticketing_service = TicketingService(configuration_service.get_lan_info(), db_service, accounting_service) seating_service = SeatingService(configuration_service.get_seating_configuration(), configuration_service.get_lan_info(), db_service, ticketing_service) catering_service = CateringService(db_service, accounting_service, user_service) diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py index 9f10d69..0c731e6 100644 --- a/src/ez_lan_manager/services/ConfigurationService.py +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -93,3 +93,7 @@ class ConfigurationService: @property def APP_VERSION(self) -> str: return self._version + + @property + def DEV_MODE_ACTIVE(self) -> bool: + return self._config["dev_mode_active"] diff --git a/src/ez_lan_manager/services/MailingService.py b/src/ez_lan_manager/services/MailingService.py index 0231827..54e4354 100644 --- a/src/ez_lan_manager/services/MailingService.py +++ b/src/ez_lan_manager/services/MailingService.py @@ -1,16 +1,24 @@ import logging from email.message import EmailMessage +from asyncio import sleep + import aiosmtplib -from src.ez_lan_manager.types.ConfigurationTypes import MailingServiceConfiguration +from src.ez_lan_manager.services.ConfigurationService import ConfigurationService logger = logging.getLogger(__name__.split(".")[-1]) class MailingService: - def __init__(self, configuration: MailingServiceConfiguration): - self._config = configuration + def __init__(self, configuration_service: ConfigurationService): + self._configuration_service = configuration_service + self._config = self._configuration_service.get_mailing_service_configuration() async def send_email(self, subject: str, body: str, receiver: str) -> None: + if self._configuration_service.DEV_MODE_ACTIVE: + logger.info(f"Skipped sending mail to {receiver} because demo mode is active.") + await sleep(1) + return + try: message = EmailMessage() message["From"] = self._config.sender -- 2.45.2 From 23269071419b8d602a750bfcde8b136cc241f1b2 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 28 Aug 2024 22:31:04 +0200 Subject: [PATCH 53/85] limit username length --- src/ez_lan_manager/components/UserInfoAndLoginBox.py | 2 +- src/ez_lan_manager/pages/RegisterPage.py | 8 +++++++- src/ez_lan_manager/services/UserService.py | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/ez_lan_manager/components/UserInfoAndLoginBox.py b/src/ez_lan_manager/components/UserInfoAndLoginBox.py index c3bfccd..813f49f 100644 --- a/src/ez_lan_manager/components/UserInfoAndLoginBox.py +++ b/src/ez_lan_manager/components/UserInfoAndLoginBox.py @@ -41,7 +41,7 @@ class UserInfoAndLoginBox(Component): @staticmethod def get_greeting() -> str: - return choice(["Guten Tacho", "Tuten Gag", "Hallöchen mit Öchen", "Servus", "Moinjour", "Hallöchen Popöchen", "Heyho", "Moinsen"]) + return choice(["Guten Tacho", "Tuten Gag", "Servus", "Moinjour", "Hallöchen Popöchen", "Heyho", "Moinsen"]) # @FixMe: If the user logs out and then tries to log back in, it does not work # If the user navigates to another page and then tries again. It works. diff --git a/src/ez_lan_manager/pages/RegisterPage.py b/src/ez_lan_manager/pages/RegisterPage.py index 62ccab8..d03e459 100644 --- a/src/ez_lan_manager/pages/RegisterPage.py +++ b/src/ez_lan_manager/pages/RegisterPage.py @@ -29,6 +29,11 @@ class RegisterPage(Component): except EmailNotValidError: self.email_input.is_valid = False + def on_user_name_input_change(self, _: TextInputChangeEvent) -> None: + current_text = self.user_name_input.text + if len(current_text) > UserService.MAX_USERNAME_LENGTH: + self.user_name_input.text = current_text[:UserService.MAX_USERNAME_LENGTH] + async def on_submit_button_pressed(self) -> None: self.submit_button.is_loading = True await self.submit_button.force_refresh() @@ -95,7 +100,8 @@ class RegisterPage(Component): margin_left=1, margin_right=1, margin_bottom=1, - grow_x=True + grow_x=True, + on_change=self.on_user_name_input_change ) self.email_input = TextInput( label="E-Mail Adresse", diff --git a/src/ez_lan_manager/services/UserService.py b/src/ez_lan_manager/services/UserService.py index aa00be4..d24f213 100644 --- a/src/ez_lan_manager/services/UserService.py +++ b/src/ez_lan_manager/services/UserService.py @@ -12,6 +12,7 @@ class NameNotAllowedError(Exception): class UserService: ALLOWED_USER_NAME_SYMBOLS = ascii_letters + digits + "!#$%&*+,-./:;<=>?[]^_{|}~" + MAX_USERNAME_LENGTH = 14 def __init__(self, db_service: DatabaseService) -> None: self._db_service = db_service -- 2.45.2 From ec2b5f0b0dc3a1f159016bf24b9cb9303290dfb8 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 29 Aug 2024 08:56:56 +0200 Subject: [PATCH 54/85] improve error handling for the case the database can not be reached --- src/EzLanManager.py | 44 ++++++++++- src/ez_lan_manager/pages/DbErrorPage.py | 78 +++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 1 + .../services/DatabaseService.py | 7 +- 4 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 src/ez_lan_manager/pages/DbErrorPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index d8e31d8..2ece807 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -1,12 +1,14 @@ import logging +import sys from pathlib import Path from rio import App, Theme, Color, Font, Page, Session from from_root import from_root -from src.ez_lan_manager import pages, init_services +from src.ez_lan_manager import pages, init_services, ConfigurationService from src.ez_lan_manager.helpers.LoggedInGuard import logged_in_guard, not_logged_in_guard +from src.ez_lan_manager.services.DatabaseService import NoDatabaseConnectionError from src.ez_lan_manager.types.SessionStorage import SessionStorage logger = logging.getLogger(__name__.split(".")[-1]) @@ -26,7 +28,43 @@ if __name__ == "__main__": font=Font(from_root("src/ez_lan_manager/assets/fonts/joystix.otf")) ) - services = init_services() + try: + services = init_services() + except NoDatabaseConnectionError: + configuration_service = ConfigurationService(from_root("config.toml")) + lan_info = configuration_service.get_lan_info() + app = App( + name="EZ LAN Manager", + pages=[ + Page( + name="DbErrorPage", + page_url="", + build=pages.DbErrorPage, + ) + ], + theme=theme, + default_attachments=[configuration_service], + assets_dir=Path(__file__).parent / "assets", + icon=from_root("src/ez_lan_manager/assets/img/favicon.png"), + meta_tags={ + "robots": "INDEX,FOLLOW", + "description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.", + "og:description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.", + "keywords": "Gaming, Clan, Guild, Verein, Club, Einfach, Zocken, Genuss, Gesellschaft, Videospiele, " + "Videogames, LAN, Party, EZ, LAN, Manager", + "author": "David Rodenkirchen", + "publisher": "EZ GG e.V.", + "copyright": "EZ GG e.V.", + "audience": "Alle", + "page-type": "Management Application", + "page-topic": "LAN Party", + "expires": "", + "revisit-after": "2 days" + } + ) + sys.exit(app.run_as_web_server()) + + lan_info = services[2].get_lan_info() async def on_session_start(session: Session) -> None: @@ -143,4 +181,4 @@ if __name__ == "__main__": } ) - app.run_as_web_server() + sys.exit(app.run_as_web_server()) diff --git a/src/ez_lan_manager/pages/DbErrorPage.py b/src/ez_lan_manager/pages/DbErrorPage.py new file mode 100644 index 0000000..33558a8 --- /dev/null +++ b/src/ez_lan_manager/pages/DbErrorPage.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import * # type: ignore + +from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox + + +class DbErrorPage(Component): + @event.on_window_size_change + async def on_window_size_change(self): + await self.force_refresh() + + def build(self) -> Component: + content = Card( + content=MainViewContentBox( + content=Text( + text="Ouh-oh, da läuft gerade irgendwas schief mit unserer Datenbank.\n\nUnser Team kümmert sich bereits um das Problem.", + margin=2, + style=TextStyle( + fill=self.session.theme.danger_color, + font_size=1.3 + ), + wrap=True + ) + ), + color="secondary", + min_width=38, + corner_radius=(0, 0.5, 0, 0) + ) + if self.session.window_width > 28: + return Container( + content=Column( + Column( + Row( + Spacer(grow_x=True, grow_y=True), + Card( + content=Spacer(), + color=self.session.theme.neutral_color, + min_width=15, + grow_y=True, + corner_radius=(0.5, 0, 0, 0), + margin_right=0.1 + ), + content, + Spacer(grow_x=True, grow_y=True), + grow_y=True + ), + Row( + Spacer(grow_x=True, grow_y=False), + Card( + content=Text(f"EZ LAN Manager Version {self.session[ConfigurationService].APP_VERSION} © EZ GG e.V.", align_x=0.5, align_y=0.5, style=TextStyle(fill=self.session.theme.primary_color, font_size=0.5)), + color=self.session.theme.neutral_color, + corner_radius=(0, 0, 0.5, 0.5), + grow_x=False, + grow_y=False, + min_height=1.2, + min_width=53.1, + margin_bottom=3 + ), + Spacer(grow_x=True, grow_y=False), + grow_y=False + ), + margin_top=4 + ) + ), + grow_x=True, + grow_y=True + ) + else: + return Text( + "Der EZ LAN Manager wird\nauf mobilen Endgeräten nur\nim Querformat unterstützt.\nBitte drehe dein Gerät.", + align_x=0.5, + align_y=0.5, + style=TextStyle(fill=Color.from_hex("FFFFFF"), font_size=0.8) + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index fddb2d2..1950e63 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -12,3 +12,4 @@ from .FaqPage import FaqPage from .TournamentsPage import TournamentsPage from .GuestsPage import GuestsPage from .CateringPage import CateringPage +from .DbErrorPage import DbErrorPage diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 943b81a..c5cf563 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -21,6 +21,9 @@ logger = logging.getLogger(__name__.split(".")[-1]) class DuplicationError(Exception): pass +class NoDatabaseConnectionError(Exception): + pass + class DatabaseService: def __init__(self, database_config: DatabaseConfiguration) -> None: self._database_config = database_config @@ -37,8 +40,8 @@ class DatabaseService: database=self._database_config.db_name ) except mariadb.Error as e: - logger.fatal(f"Error connecting to database: {e}") - sys.exit(1) + logger.error(f"Error connecting to database: {e}") + raise NoDatabaseConnectionError def _get_cursor(self) -> Cursor: return self._connection.cursor() -- 2.45.2 From ac805e96da4297fb871d6849f52c3bdd463d68e3 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 29 Aug 2024 11:21:11 +0200 Subject: [PATCH 55/85] improve database error handling, implement automatic reconnects, raise specialized errors --- .../services/DatabaseService.py | 121 +++++++++++++++--- 1 file changed, 105 insertions(+), 16 deletions(-) diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index c5cf563..1615436 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -1,5 +1,6 @@ import logging -import sys +from time import sleep + from datetime import date, datetime from typing import Optional @@ -25,23 +26,33 @@ class NoDatabaseConnectionError(Exception): pass class DatabaseService: + MAX_CONNECTION_RETRIES = 5 def __init__(self, database_config: DatabaseConfiguration) -> None: self._database_config = database_config - try: - logger.info( - f"Connecting to database '{self._database_config.db_name}' on " - f"{self._database_config.db_user}@{self._database_config.db_host}:{self._database_config.db_port}" - ) - self._connection = mariadb.connect( - user=self._database_config.db_user, - password=self._database_config.db_password, - host=self._database_config.db_host, - port=self._database_config.db_port, - database=self._database_config.db_name - ) - except mariadb.Error as e: - logger.error(f"Error connecting to database: {e}") - raise NoDatabaseConnectionError + logger.info( + f"Connecting to database '{self._database_config.db_name}' on " + f"{self._database_config.db_user}@{self._database_config.db_host}:{self._database_config.db_port}" + ) + self._establish_new_connection() + + def _establish_new_connection(self) -> None: + retries = 0 + for _ in range(DatabaseService.MAX_CONNECTION_RETRIES): + try: + self._connection = mariadb.connect( + user=self._database_config.db_user, + password=self._database_config.db_password, + host=self._database_config.db_host, + port=self._database_config.db_port, + database=self._database_config.db_name + ) + except mariadb.Error: + sleep(0.5) + retries += 1 + continue + return + raise NoDatabaseConnectionError + def _get_cursor(self) -> Cursor: return self._connection.cursor() @@ -98,6 +109,9 @@ class DatabaseService: "VALUES (?, ?, ?)", (user_name, user_mail.lower(), password_hash) ) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.create_user(user_name, user_mail, password_hash) except mariadb.IntegrityError as e: logger.warning(f"Aborted duplication entry: {e}") raise DuplicationError @@ -115,6 +129,9 @@ class DatabaseService: user.user_id) ) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.update_user(user) except mariadb.IntegrityError as e: logger.warning(f"Aborted duplication entry: {e}") raise DuplicationError @@ -129,6 +146,9 @@ class DatabaseService: (transaction.user_id, transaction.value, transaction.is_debit, transaction.transaction_date, transaction.reference) ) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.add_transaction(transaction) except Exception as e: logger.warning(f"Error adding Transaction: {e}") return @@ -143,6 +163,9 @@ class DatabaseService: cursor.execute("SELECT * FROM transactions WHERE user_id=?", (user_id,)) self._connection.commit() result = cursor.fetchall() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_all_transactions_for_user(user_id) except mariadb.Error as e: logger.error(f"Error getting all transactions for user: {e}") return [] @@ -166,6 +189,9 @@ class DatabaseService: (news.content, news.title, news.subtitle, news.author.user_id, news.news_date) ) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.add_news(news) except Exception as e: logger.warning(f"Error adding Transaction: {e}") @@ -175,6 +201,9 @@ class DatabaseService: try: cursor.execute("SELECT * FROM news INNER JOIN users ON news.news_author = users.user_id WHERE news_date BETWEEN ? AND ?;", (dt_start, dt_end)) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_news(dt_start, dt_end) except Exception as e: logger.warning(f"Error fetching news: {e}") return [] @@ -198,6 +227,9 @@ class DatabaseService: try: cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id;", ()) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_tickets() except Exception as e: logger.warning(f"Error fetching tickets: {e}") return [] @@ -218,6 +250,9 @@ class DatabaseService: try: cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id WHERE user_id=?;", (user_id, )) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_ticket_for_user(user_id) except Exception as e: logger.warning(f"Error fetching ticket for user: {e}") return @@ -239,6 +274,9 @@ class DatabaseService: try: cursor.execute("INSERT INTO tickets (ticket_category, user) VALUES (?, ?)", (category, user_id)) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.generate_ticket_for_user(user_id, category) except Exception as e: logger.warning(f"Error generating ticket for user: {e}") return @@ -251,6 +289,9 @@ class DatabaseService: cursor.execute("UPDATE tickets SET user = ? WHERE ticket_id = ?;", (new_owner_id, ticket_id)) affected_rows = cursor.rowcount self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.change_ticket_owner(ticket_id, new_owner_id) except Exception as e: logger.warning(f"Error transferring ticket to user: {e}") return False @@ -261,6 +302,9 @@ class DatabaseService: try: cursor.execute("DELETE FROM tickets WHERE ticket_id = ?;", (ticket_id, )) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.change_ticket_owner(ticket_id) except Exception as e: logger.warning(f"Error deleting ticket: {e}") return False @@ -274,6 +318,9 @@ class DatabaseService: for seat in seats: cursor.execute("INSERT INTO seats (seat_id, seat_category) VALUES (?, ?);", (seat[0], seat[1])) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.generate_fresh_seats_table(seats) except Exception as e: logger.warning(f"Error generating fresh seats table: {e}") return @@ -284,6 +331,9 @@ class DatabaseService: try: cursor.execute("SELECT seats.*, users.* FROM seats LEFT JOIN users ON seats.user = users.user_id;") self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_seating_info() except Exception as e: logger.warning(f"Error getting seats table: {e}") return results @@ -304,6 +354,9 @@ class DatabaseService: cursor.execute("UPDATE seats SET user = ? WHERE seat_id = ?;", (user_id, seat_id)) affected_rows = cursor.rowcount self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.seat_user(seat_id, user_id) except Exception as e: logger.warning(f"Error seating user: {e}") return False @@ -315,6 +368,9 @@ class DatabaseService: try: cursor.execute("SELECT * FROM catering_menu_items;") self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_menu_items() except Exception as e: logger.warning(f"Error fetching menu items: {e}") return results @@ -336,6 +392,9 @@ class DatabaseService: try: cursor.execute("SELECT * FROM catering_menu_items WHERE catering_menu_item_id = ?;", (menu_item_id, )) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_menu_item(menu_item_id) except Exception as e: logger.warning(f"Error fetching menu items: {e}") return @@ -360,6 +419,9 @@ class DatabaseService: (name, info, price, category.value, is_disabled) ) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.add_menu_item(name, info, price, category, is_disabled) except Exception as e: logger.warning(f"Error adding menu item: {e}") return @@ -378,6 +440,9 @@ class DatabaseService: try: cursor.execute("DELETE FROM catering_menu_items WHERE catering_menu_item_id = ?;", (menu_item_id,)) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.delete_menu_item(menu_item_id) except Exception as e: logger.warning(f"Error deleting menu item: {e}") return False @@ -392,6 +457,9 @@ class DatabaseService: ) affected_rows = cursor.rowcount self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.update_menu_item(updated_item) except Exception as e: logger.warning(f"Error updating menu item: {e}") return False @@ -420,6 +488,9 @@ class DatabaseService: customer=self.get_user_by_id(user_id), is_delivery=is_delivery ) + except mariadb.InterfaceError: + self._establish_new_connection() + return self.add_new_order(menu_items, user_id, is_delivery) except Exception as e: logger.warning(f"Error placing order: {e}") return @@ -433,6 +504,9 @@ class DatabaseService: ) affected_rows = cursor.rowcount self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.change_order_status(order_id, status) except Exception as e: logger.warning(f"Error updating menu item: {e}") return False @@ -453,6 +527,9 @@ class DatabaseService: try: cursor.execute(query) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_orders(user_id, status) except Exception as e: logger.warning(f"Error getting orders: {e}") return fetched_orders @@ -482,6 +559,9 @@ class DatabaseService: (order_id, ) ) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_menu_items_for_order(order_id) except Exception as e: logger.warning(f"Error getting order items: {e}") return result @@ -506,6 +586,9 @@ class DatabaseService: (user_id, picture_data) ) self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.set_user_profile_picture(user_id, picture_data) except Exception as e: logger.warning(f"Error setting user profile picture: {e}") @@ -518,6 +601,9 @@ class DatabaseService: if r is None: return return r[0] + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_user_profile_picture(user_id) except Exception as e: logger.warning(f"Error setting user profile picture: {e}") return None @@ -528,6 +614,9 @@ class DatabaseService: try: cursor.execute("SELECT * FROM users;") self._connection.commit() + except mariadb.InterfaceError: + self._establish_new_connection() + return self.get_all_users() except Exception as e: logger.warning(f"Error getting all users: {e}") return results -- 2.45.2 From dc514895df6c35c1e89bf91a7cf409808cacc4f2 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 29 Aug 2024 12:05:04 +0200 Subject: [PATCH 56/85] further improve db error handling --- src/EzLanManager.py | 43 ++-------- src/ez_lan_manager/pages/DbErrorPage.py | 14 +++- src/ez_lan_manager/pages/NewsPage.py | 11 ++- .../services/DatabaseService.py | 84 ++++++++++++------- 4 files changed, 84 insertions(+), 68 deletions(-) diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 2ece807..0e709c5 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -6,12 +6,12 @@ from pathlib import Path from rio import App, Theme, Color, Font, Page, Session from from_root import from_root -from src.ez_lan_manager import pages, init_services, ConfigurationService +from src.ez_lan_manager import pages, init_services from src.ez_lan_manager.helpers.LoggedInGuard import logged_in_guard, not_logged_in_guard from src.ez_lan_manager.services.DatabaseService import NoDatabaseConnectionError from src.ez_lan_manager.types.SessionStorage import SessionStorage -logger = logging.getLogger(__name__.split(".")[-1]) +logger = logging.getLogger("EzLanManager") if __name__ == "__main__": theme = Theme.from_colors( @@ -31,38 +31,8 @@ if __name__ == "__main__": try: services = init_services() except NoDatabaseConnectionError: - configuration_service = ConfigurationService(from_root("config.toml")) - lan_info = configuration_service.get_lan_info() - app = App( - name="EZ LAN Manager", - pages=[ - Page( - name="DbErrorPage", - page_url="", - build=pages.DbErrorPage, - ) - ], - theme=theme, - default_attachments=[configuration_service], - assets_dir=Path(__file__).parent / "assets", - icon=from_root("src/ez_lan_manager/assets/img/favicon.png"), - meta_tags={ - "robots": "INDEX,FOLLOW", - "description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.", - "og:description": f"Info und Verwaltungs-Seite der LAN Party '{lan_info.name} - {lan_info.iteration}'.", - "keywords": "Gaming, Clan, Guild, Verein, Club, Einfach, Zocken, Genuss, Gesellschaft, Videospiele, " - "Videogames, LAN, Party, EZ, LAN, Manager", - "author": "David Rodenkirchen", - "publisher": "EZ GG e.V.", - "copyright": "EZ GG e.V.", - "audience": "Alle", - "page-type": "Management Application", - "page-topic": "LAN Party", - "expires": "", - "revisit-after": "2 days" - } - ) - sys.exit(app.run_as_web_server()) + logger.fatal("Could not connect to database, exiting...") + sys.exit(1) lan_info = services[2].get_lan_info() @@ -157,6 +127,11 @@ if __name__ == "__main__": page_url="account", build=pages.AccountPage, guard=logged_in_guard + ), + Page( + name="DbErrorPage", + page_url="db-error", + build=pages.DbErrorPage, ) ], theme=theme, diff --git a/src/ez_lan_manager/pages/DbErrorPage.py b/src/ez_lan_manager/pages/DbErrorPage.py index 33558a8..c20b634 100644 --- a/src/ez_lan_manager/pages/DbErrorPage.py +++ b/src/ez_lan_manager/pages/DbErrorPage.py @@ -1,23 +1,33 @@ from __future__ import annotations +from asyncio import sleep from typing import * # type: ignore from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text +from src.ez_lan_manager.services.DatabaseService import DatabaseService from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox class DbErrorPage(Component): @event.on_window_size_change - async def on_window_size_change(self): + async def on_window_size_change(self) -> None: await self.force_refresh() + @event.on_mount + async def retry_db_connect(self) -> None: + while not self.session[DatabaseService].is_connected: + await sleep(2) + self.session.navigate_to("./") + def build(self) -> Component: content = Card( content=MainViewContentBox( content=Text( - text="Ouh-oh, da läuft gerade irgendwas schief mit unserer Datenbank.\n\nUnser Team kümmert sich bereits um das Problem.", + text="Ouh-oh, da läuft gerade irgendwas schief.\n\n" + "Unser Team kümmert sich bereits um das Problem.\n\n" + "Du wirst automatisch weitergeleitet sobald das System wieder verfügbar ist.", margin=2, style=TextStyle( fill=self.session.theme.danger_color, diff --git a/src/ez_lan_manager/pages/NewsPage.py b/src/ez_lan_manager/pages/NewsPage.py index ec241f3..8aad72c 100644 --- a/src/ez_lan_manager/pages/NewsPage.py +++ b/src/ez_lan_manager/pages/NewsPage.py @@ -3,11 +3,20 @@ from rio import Column, Component, event from src.ez_lan_manager import ConfigurationService, NewsService from src.ez_lan_manager.components.NewsPost import NewsPost from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.services.DatabaseService import NoDatabaseConnectionError +from src.ez_lan_manager.types.News import News + class NewsPage(Component): + news_posts: list[News] = [] + @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Neuigkeiten") + try: + self.news_posts = self.session[NewsService].get_news()[:8] + except NoDatabaseConnectionError: + self.session.navigate_to("db-error") def build(self) -> Component: posts = [NewsPost( @@ -16,7 +25,7 @@ class NewsPage(Component): text=news.content, date=news.news_date.strftime("%d.%m.%Y"), author=news.author.user_name - ) for news in self.session[NewsService].get_news()[:8]] + ) for news in self.news_posts] return BasePage( content=Column( *posts, diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 1615436..920c744 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -33,10 +33,31 @@ class DatabaseService: f"Connecting to database '{self._database_config.db_name}' on " f"{self._database_config.db_user}@{self._database_config.db_host}:{self._database_config.db_port}" ) - self._establish_new_connection() + self._connection: Optional[mariadb.Connection] = None + self._reestablishment_lock = False + self.establish_new_connection() + + @property + def is_connected(self) -> bool: + try: + self._connection.ping() + except Exception: + try: + self.establish_new_connection() + return True + except NoDatabaseConnectionError: + return False + return True + + def establish_new_connection(self) -> None: + if self._reestablishment_lock: + return + self._reestablishment_lock = True + + if isinstance(self._connection, mariadb.Connection): + self._connection.close() + self._connection = None - def _establish_new_connection(self) -> None: - retries = 0 for _ in range(DatabaseService.MAX_CONNECTION_RETRIES): try: self._connection = mariadb.connect( @@ -47,10 +68,11 @@ class DatabaseService: database=self._database_config.db_name ) except mariadb.Error: - sleep(0.5) - retries += 1 + sleep(0.4) continue + self._reestablishment_lock = False return + self._reestablishment_lock = False raise NoDatabaseConnectionError @@ -110,7 +132,7 @@ class DatabaseService: ) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.create_user(user_name, user_mail, password_hash) except mariadb.IntegrityError as e: logger.warning(f"Aborted duplication entry: {e}") @@ -130,7 +152,7 @@ class DatabaseService: ) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.update_user(user) except mariadb.IntegrityError as e: logger.warning(f"Aborted duplication entry: {e}") @@ -147,7 +169,7 @@ class DatabaseService: ) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.add_transaction(transaction) except Exception as e: logger.warning(f"Error adding Transaction: {e}") @@ -164,7 +186,7 @@ class DatabaseService: self._connection.commit() result = cursor.fetchall() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_all_transactions_for_user(user_id) except mariadb.Error as e: logger.error(f"Error getting all transactions for user: {e}") @@ -190,7 +212,7 @@ class DatabaseService: ) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.add_news(news) except Exception as e: logger.warning(f"Error adding Transaction: {e}") @@ -202,7 +224,7 @@ class DatabaseService: cursor.execute("SELECT * FROM news INNER JOIN users ON news.news_author = users.user_id WHERE news_date BETWEEN ? AND ?;", (dt_start, dt_end)) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_news(dt_start, dt_end) except Exception as e: logger.warning(f"Error fetching news: {e}") @@ -228,7 +250,7 @@ class DatabaseService: cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id;", ()) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_tickets() except Exception as e: logger.warning(f"Error fetching tickets: {e}") @@ -251,7 +273,7 @@ class DatabaseService: cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id WHERE user_id=?;", (user_id, )) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_ticket_for_user(user_id) except Exception as e: logger.warning(f"Error fetching ticket for user: {e}") @@ -275,7 +297,7 @@ class DatabaseService: cursor.execute("INSERT INTO tickets (ticket_category, user) VALUES (?, ?)", (category, user_id)) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.generate_ticket_for_user(user_id, category) except Exception as e: logger.warning(f"Error generating ticket for user: {e}") @@ -290,7 +312,7 @@ class DatabaseService: affected_rows = cursor.rowcount self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.change_ticket_owner(ticket_id, new_owner_id) except Exception as e: logger.warning(f"Error transferring ticket to user: {e}") @@ -303,7 +325,7 @@ class DatabaseService: cursor.execute("DELETE FROM tickets WHERE ticket_id = ?;", (ticket_id, )) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.change_ticket_owner(ticket_id) except Exception as e: logger.warning(f"Error deleting ticket: {e}") @@ -319,7 +341,7 @@ class DatabaseService: cursor.execute("INSERT INTO seats (seat_id, seat_category) VALUES (?, ?);", (seat[0], seat[1])) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.generate_fresh_seats_table(seats) except Exception as e: logger.warning(f"Error generating fresh seats table: {e}") @@ -332,7 +354,7 @@ class DatabaseService: cursor.execute("SELECT seats.*, users.* FROM seats LEFT JOIN users ON seats.user = users.user_id;") self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_seating_info() except Exception as e: logger.warning(f"Error getting seats table: {e}") @@ -355,7 +377,7 @@ class DatabaseService: affected_rows = cursor.rowcount self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.seat_user(seat_id, user_id) except Exception as e: logger.warning(f"Error seating user: {e}") @@ -369,7 +391,7 @@ class DatabaseService: cursor.execute("SELECT * FROM catering_menu_items;") self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_menu_items() except Exception as e: logger.warning(f"Error fetching menu items: {e}") @@ -393,7 +415,7 @@ class DatabaseService: cursor.execute("SELECT * FROM catering_menu_items WHERE catering_menu_item_id = ?;", (menu_item_id, )) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_menu_item(menu_item_id) except Exception as e: logger.warning(f"Error fetching menu items: {e}") @@ -420,7 +442,7 @@ class DatabaseService: ) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.add_menu_item(name, info, price, category, is_disabled) except Exception as e: logger.warning(f"Error adding menu item: {e}") @@ -441,7 +463,7 @@ class DatabaseService: cursor.execute("DELETE FROM catering_menu_items WHERE catering_menu_item_id = ?;", (menu_item_id,)) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.delete_menu_item(menu_item_id) except Exception as e: logger.warning(f"Error deleting menu item: {e}") @@ -458,7 +480,7 @@ class DatabaseService: affected_rows = cursor.rowcount self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.update_menu_item(updated_item) except Exception as e: logger.warning(f"Error updating menu item: {e}") @@ -489,7 +511,7 @@ class DatabaseService: is_delivery=is_delivery ) except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.add_new_order(menu_items, user_id, is_delivery) except Exception as e: logger.warning(f"Error placing order: {e}") @@ -505,7 +527,7 @@ class DatabaseService: affected_rows = cursor.rowcount self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.change_order_status(order_id, status) except Exception as e: logger.warning(f"Error updating menu item: {e}") @@ -528,7 +550,7 @@ class DatabaseService: cursor.execute(query) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_orders(user_id, status) except Exception as e: logger.warning(f"Error getting orders: {e}") @@ -560,7 +582,7 @@ class DatabaseService: ) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_menu_items_for_order(order_id) except Exception as e: logger.warning(f"Error getting order items: {e}") @@ -587,7 +609,7 @@ class DatabaseService: ) self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.set_user_profile_picture(user_id, picture_data) except Exception as e: logger.warning(f"Error setting user profile picture: {e}") @@ -602,7 +624,7 @@ class DatabaseService: return return r[0] except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_user_profile_picture(user_id) except Exception as e: logger.warning(f"Error setting user profile picture: {e}") @@ -615,7 +637,7 @@ class DatabaseService: cursor.execute("SELECT * FROM users;") self._connection.commit() except mariadb.InterfaceError: - self._establish_new_connection() + self.establish_new_connection() return self.get_all_users() except Exception as e: logger.warning(f"Error getting all users: {e}") -- 2.45.2 From 140d1cb1db06c30206763bd18ee97c0593754669 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 29 Aug 2024 12:07:20 +0200 Subject: [PATCH 57/85] set correct window title for db error page --- src/ez_lan_manager/pages/DbErrorPage.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ez_lan_manager/pages/DbErrorPage.py b/src/ez_lan_manager/pages/DbErrorPage.py index c20b634..97c50f0 100644 --- a/src/ez_lan_manager/pages/DbErrorPage.py +++ b/src/ez_lan_manager/pages/DbErrorPage.py @@ -17,6 +17,7 @@ class DbErrorPage(Component): @event.on_mount async def retry_db_connect(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Fehler") while not self.session[DatabaseService].is_connected: await sleep(2) self.session.navigate_to("./") -- 2.45.2 From deae96d8fa77b27bca380f5aef96ccc6d1fbc1e9 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 29 Aug 2024 13:25:20 +0200 Subject: [PATCH 58/85] move db error handling from per-page to base page --- src/ez_lan_manager/components/UserInfoAndLoginBox.py | 6 +++++- src/ez_lan_manager/pages/BasePage.py | 10 ++++++++-- src/ez_lan_manager/pages/NewsPage.py | 6 +----- src/ez_lan_manager/services/DatabaseService.py | 3 +++ 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/ez_lan_manager/components/UserInfoAndLoginBox.py b/src/ez_lan_manager/components/UserInfoAndLoginBox.py index 813f49f..769cd2d 100644 --- a/src/ez_lan_manager/components/UserInfoAndLoginBox.py +++ b/src/ez_lan_manager/components/UserInfoAndLoginBox.py @@ -1,3 +1,4 @@ +import logging from random import choice from typing import Callable @@ -6,10 +7,13 @@ from rio import Component, Column, Text, Row, Rectangle, Button, TextStyle, Colo from src.ez_lan_manager import UserService from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton from src.ez_lan_manager.services.AccountingService import AccountingService +from src.ez_lan_manager.services.DatabaseService import NoDatabaseConnectionError, DatabaseService from src.ez_lan_manager.services.TicketingService import TicketingService from src.ez_lan_manager.services.SeatingService import SeatingService from src.ez_lan_manager.types.SessionStorage import SessionStorage +logger = logging.getLogger(__name__.split(".")[-1]) + class StatusButton(Component): STYLE = TextStyle(fill=Color.from_hex("121212"), font_size=0.5) label: str @@ -138,7 +142,7 @@ class UserInfoAndLoginBox(Component): else: user = self.session[UserService].get_user(self.session[SessionStorage].user_id) if user is None: - print("ERROR") + logger.warning("User could not be found, this should not have happend.") a_s = self.session[AccountingService] return Rectangle( content=Column( diff --git a/src/ez_lan_manager/pages/BasePage.py b/src/ez_lan_manager/pages/BasePage.py index 8c30072..a89efb4 100644 --- a/src/ez_lan_manager/pages/BasePage.py +++ b/src/ez_lan_manager/pages/BasePage.py @@ -2,9 +2,9 @@ from __future__ import annotations from typing import * # type: ignore -from rio import Component, event, Spacer, Card, Container, Column, Row, Rectangle, TextStyle, Color, Text +from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text -from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager import ConfigurationService, DatabaseService from src.ez_lan_manager.components.DesktopNavigation import DesktopNavigation class BasePage(Component): @@ -14,6 +14,11 @@ class BasePage(Component): async def on_window_size_change(self): await self.force_refresh() + @event.on_populate + async def check_db_connection(self): + if not self.session[DatabaseService].is_connected: + self.session.navigate_to("./db-error") + def build(self) -> Component: if self.content is None: content = Spacer() @@ -63,3 +68,4 @@ class BasePage(Component): align_y=0.5, style=TextStyle(fill=Color.from_hex("FFFFFF"), font_size=0.8) ) + diff --git a/src/ez_lan_manager/pages/NewsPage.py b/src/ez_lan_manager/pages/NewsPage.py index 8aad72c..d8c73d5 100644 --- a/src/ez_lan_manager/pages/NewsPage.py +++ b/src/ez_lan_manager/pages/NewsPage.py @@ -3,7 +3,6 @@ from rio import Column, Component, event from src.ez_lan_manager import ConfigurationService, NewsService from src.ez_lan_manager.components.NewsPost import NewsPost from src.ez_lan_manager.pages import BasePage -from src.ez_lan_manager.services.DatabaseService import NoDatabaseConnectionError from src.ez_lan_manager.types.News import News @@ -13,10 +12,7 @@ class NewsPage(Component): @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Neuigkeiten") - try: - self.news_posts = self.session[NewsService].get_news()[:8] - except NoDatabaseConnectionError: - self.session.navigate_to("db-error") + self.news_posts = self.session[NewsService].get_news()[:8] def build(self) -> Component: posts = [NewsPost( diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 920c744..c177740 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -36,9 +36,12 @@ class DatabaseService: self._connection: Optional[mariadb.Connection] = None self._reestablishment_lock = False self.establish_new_connection() + self.calls = 0 @property def is_connected(self) -> bool: + self.calls += 1 + print(f"{self.calls} Calls") try: self._connection.ping() except Exception: -- 2.45.2 From a9597b5c4f95896503c167e46ca015823d99b2ae Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 29 Aug 2024 13:40:35 +0200 Subject: [PATCH 59/85] remove debug symbols --- src/ez_lan_manager/services/DatabaseService.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index c177740..bbcd4ba 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -2,7 +2,7 @@ import logging from time import sleep from datetime import date, datetime -from typing import Optional +from typing import Optional, Coroutine import mariadb from mariadb import Cursor @@ -36,12 +36,9 @@ class DatabaseService: self._connection: Optional[mariadb.Connection] = None self._reestablishment_lock = False self.establish_new_connection() - self.calls = 0 @property def is_connected(self) -> bool: - self.calls += 1 - print(f"{self.calls} Calls") try: self._connection.ping() except Exception: -- 2.45.2 From 30b32a4c025390242ebabdaef933214a96c58c03 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 3 Sep 2024 14:30:32 +0200 Subject: [PATCH 60/85] aiomysql refactor --- requirements.txt | Bin 170 -> 190 bytes src/EzLanManager.py | 17 +- .../components/CateringOrderItem.py | 4 +- .../components/DesktopNavigation.py | 8 +- .../components/ShoppingCartAndOrders.py | 9 +- .../components/UserInfoAndLoginBox.py | 51 +- src/ez_lan_manager/pages/Account.py | 39 +- src/ez_lan_manager/pages/BasePage.py | 7 +- src/ez_lan_manager/pages/CateringPage.py | 56 +- src/ez_lan_manager/pages/ContactPage.py | 14 +- src/ez_lan_manager/pages/DbErrorPage.py | 12 +- src/ez_lan_manager/pages/EditProfile.py | 43 +- src/ez_lan_manager/pages/ForgotPassword.py | 4 +- src/ez_lan_manager/pages/GuestsPage.py | 31 +- src/ez_lan_manager/pages/NewsPage.py | 2 +- src/ez_lan_manager/pages/RegisterPage.py | 4 +- .../services/AccountingService.py | 22 +- .../services/CateringService.py | 70 +- .../services/DatabaseService.py | 1149 +++++++++-------- src/ez_lan_manager/services/NewsService.py | 15 +- src/ez_lan_manager/services/SeatingService.py | 34 +- .../services/TicketingService.py | 30 +- src/ez_lan_manager/services/UserService.py | 32 +- src/ez_lan_manager/types/User.py | 3 + 24 files changed, 901 insertions(+), 755 deletions(-) diff --git a/requirements.txt b/requirements.txt index ffb0c349e554dd2e41200e45af8bc020c7172e8e..54b23df4e6b70533b8f4a90e613f680d0a59643a 100644 GIT binary patch delta 60 zcmZ3*xQ}synsg#VCPO|$E<+_lF+(9k4nrM-ErS7r9)l5s-b6=XVOuEI5-4QMP{feT JU@$Q_8~}))3(f!l delta 40 pcmdnTxQcOtnshEhB0~{FCXh^FNMfjCuw^i0(1YNKj=~c&!T`W52 None: + init_result = await a.default_attachments[3].init_db_pool() + if not init_result: + logger.fatal("Could not connect to database, exiting...") + sys.exit(1) + app = App( name="EZ LAN Manager", pages=[ @@ -138,6 +140,7 @@ if __name__ == "__main__": assets_dir=Path(__file__).parent / "assets", default_attachments=services, on_session_start=on_session_start, + on_app_start=on_app_start, icon=from_root("src/ez_lan_manager/assets/img/favicon.png"), meta_tags={ "robots": "INDEX,FOLLOW", diff --git a/src/ez_lan_manager/components/CateringOrderItem.py b/src/ez_lan_manager/components/CateringOrderItem.py index 4018c1a..f82a49e 100644 --- a/src/ez_lan_manager/components/CateringOrderItem.py +++ b/src/ez_lan_manager/components/CateringOrderItem.py @@ -1,10 +1,8 @@ from datetime import datetime -from typing import Callable import rio -from rio import Component, Row, Text, IconButton, TextStyle, Color +from rio import Component, Row, Text, TextStyle, Color -from src.ez_lan_manager import AccountingService from src.ez_lan_manager.types.CateringOrder import CateringOrderStatus MAX_LEN = 24 diff --git a/src/ez_lan_manager/components/DesktopNavigation.py b/src/ez_lan_manager/components/DesktopNavigation.py index dbcb381..ae86f70 100644 --- a/src/ez_lan_manager/components/DesktopNavigation.py +++ b/src/ez_lan_manager/components/DesktopNavigation.py @@ -6,19 +6,13 @@ from src.ez_lan_manager.components.UserInfoAndLoginBox import UserInfoAndLoginBo from src.ez_lan_manager.types.SessionStorage import SessionStorage class DesktopNavigation(Component): - def __post_init__(self) -> None: - self.session[SessionStorage].subscribe_to_logged_in_or_out_event(self.__class__.__name__, self.refresh_cb) - - async def refresh_cb(self) -> None: - await self.force_refresh() - def build(self) -> Component: lan_info = self.session[ConfigurationService].get_lan_info() return Card( Column( Text(lan_info.name, align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.hud_color, font_size=2.5)), Text(f"Edition {lan_info.iteration}", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=1.2), margin_top=0.3, margin_bottom=2), - UserInfoAndLoginBox(refresh_cb=self.refresh_cb), + UserInfoAndLoginBox(), DesktopNavigationButton("News", "./news"), Spacer(min_height=1), DesktopNavigationButton(f"Über {lan_info.name} {lan_info.iteration}", "./overview"), diff --git a/src/ez_lan_manager/components/ShoppingCartAndOrders.py b/src/ez_lan_manager/components/ShoppingCartAndOrders.py index 1639626..b07d10a 100644 --- a/src/ez_lan_manager/components/ShoppingCartAndOrders.py +++ b/src/ez_lan_manager/components/ShoppingCartAndOrders.py @@ -1,3 +1,5 @@ +from typing import Optional + import rio from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer @@ -11,9 +13,11 @@ from src.ez_lan_manager.types.SessionStorage import SessionStorage class ShoppingCartAndOrders(Component): show_cart: bool = True + orders: list[CateringOrder] = [] async def switch(self) -> None: self.show_cart = not self.show_cart + self.orders = await self.session[CateringService].get_orders_for_user(self.session[SessionStorage].user_id) async def on_remove_item(self, list_id: int) -> None: catering_service = self.session[CateringService] @@ -36,7 +40,7 @@ class ShoppingCartAndOrders(Component): if not user_id: return cart = catering_service.get_cart(user_id) - cart.append(catering_service.get_menu_item_by_id(article_id)) + cart.append(await catering_service.get_menu_item_by_id(article_id)) catering_service.save_cart(user_id, cart) await self.force_refresh() @@ -99,14 +103,13 @@ class ShoppingCartAndOrders(Component): ) ) else: - orders = catering_service.get_orders_for_user(user_id) orders_container = ScrollContainer( content=Column( *[CateringOrderItem( order_id=order_item.order_id, order_datetime=order_item.order_date, order_status=order_item.status, - ) for order_item in orders], + ) for order_item in self.orders], Spacer(grow_y=True) ), min_height=8, diff --git a/src/ez_lan_manager/components/UserInfoAndLoginBox.py b/src/ez_lan_manager/components/UserInfoAndLoginBox.py index 769cd2d..9feccd1 100644 --- a/src/ez_lan_manager/components/UserInfoAndLoginBox.py +++ b/src/ez_lan_manager/components/UserInfoAndLoginBox.py @@ -1,15 +1,17 @@ import logging from random import choice -from typing import Callable +from typing import Optional -from rio import Component, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput, Link +from rio import Component, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput, Link, event from src.ez_lan_manager import UserService from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton from src.ez_lan_manager.services.AccountingService import AccountingService -from src.ez_lan_manager.services.DatabaseService import NoDatabaseConnectionError, DatabaseService from src.ez_lan_manager.services.TicketingService import TicketingService from src.ez_lan_manager.services.SeatingService import SeatingService +from src.ez_lan_manager.types.Seat import Seat +from src.ez_lan_manager.types.Ticket import Ticket +from src.ez_lan_manager.types.User import User from src.ez_lan_manager.types.SessionStorage import SessionStorage logger = logging.getLogger(__name__.split(".")[-1]) @@ -39,9 +41,20 @@ class StatusButton(Component): class UserInfoAndLoginBox(Component): - refresh_cb: Callable TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) show_login: bool = True + user: Optional[User] = None + user_balance: Optional[int] = 0 + user_ticket: Optional[Ticket] = None + user_seat: Optional[Seat] = None + + @event.on_populate + async def async_init(self) -> None: + if self.session[SessionStorage].user_id: + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) + self.user_balance = await self.session[AccountingService].get_balance(self.user.user_id) + self.user_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id) + self.user_seat = await self.session[SeatingService].get_user_seat(self.user.user_id) @staticmethod def get_greeting() -> str: @@ -64,13 +77,13 @@ class UserInfoAndLoginBox(Component): async def _on_login_pressed(self) -> None: user_name = self.user_name_input.text.lower() - if self.session[UserService].is_login_valid(user_name, self.password_input.text): + if await self.session[UserService].is_login_valid(user_name, self.password_input.text): self.user_name_input.is_valid = True self.password_input.is_valid = True self.login_button.is_loading = False - await self.session[SessionStorage].set_user_id(self.session[UserService].get_user(user_name).user_id) + await self.session[SessionStorage].set_user_id((await self.session[UserService].get_user(user_name)).user_id) + await self.async_init() self.show_login = False - await self.refresh_cb() else: self.user_name_input.is_valid = False self.password_input.is_valid = False @@ -114,7 +127,7 @@ class UserInfoAndLoginBox(Component): on_press=lambda: self.session.navigate_to("./forgot-password") ) - if self.show_login and self.session[SessionStorage].user_id is None: + if self.user is None and self.session[SessionStorage].user_id is None: return Rectangle( content=Column( self.user_name_input, @@ -139,25 +152,31 @@ class UserInfoAndLoginBox(Component): margin_top=0.3, margin_bottom=2 ) + elif self.user is None and self.session[SessionStorage].user_id is not None: + return Rectangle( + content=Column(), + fill=Color.TRANSPARENT, + min_height=8, + min_width=12, + align_x=0.5, + margin_top=0.3, + margin_bottom=2 + ) else: - user = self.session[UserService].get_user(self.session[SessionStorage].user_id) - if user is None: - logger.warning("User could not be found, this should not have happend.") - a_s = self.session[AccountingService] return Rectangle( content=Column( Text(f"{self.get_greeting()},", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9), justify="center"), - Text(f"{user.user_name}", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=1.2), 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.session[TicketingService].get_user_ticket(user.user_id) is not None), + enabled=self.user_ticket is not None), StatusButton(label="SITZPLATZ", target_url="./seating", - enabled=self.session[SeatingService].get_user_seat(user.user_id) is not None), + enabled=self.user_seat is not None), proportions=(50, 50), grow_y=False ), UserInfoBoxButton("Profil bearbeiten", "./edit-profile"), - UserInfoBoxButton(f"Guthaben: {a_s.make_euro_string_from_int(a_s.get_balance(user.user_id))}", "./account"), + UserInfoBoxButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_int(self.user_balance)}", "./account"), Button( content=Text("Ausloggen", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.6)), shape="rectangle", diff --git a/src/ez_lan_manager/pages/Account.py b/src/ez_lan_manager/pages/Account.py index 5f3adbf..0006531 100644 --- a/src/ez_lan_manager/pages/Account.py +++ b/src/ez_lan_manager/pages/Account.py @@ -1,22 +1,47 @@ -from rio import Column, Component, event, Text, TextStyle, Button, Color, Spacer, Revealer, Row +from asyncio import sleep +from typing import Optional + +from rio import Column, Component, event, Text, TextStyle, Button, Color, Spacer, Revealer, Row, ProgressCircle from src.ez_lan_manager import ConfigurationService, UserService, AccountingService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.Transaction import Transaction +from src.ez_lan_manager.types.User import User class AccountPage(Component): + user: Optional[User] = None + balance: Optional[int] = None + transaction_history: list[Transaction] = list() + @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Guthabenkonto") + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) + self.balance = await self.session[AccountingService].get_balance(self.user.user_id) + self.transaction_history = await self.session[AccountingService].get_transaction_history(self.user.user_id) async def _on_banking_info_press(self): self.banking_info_revealer.is_open = not self.banking_info_revealer.is_open def build(self) -> Component: - user = self.session[UserService].get_user(self.session[SessionStorage].user_id) - a_s = self.session[AccountingService] + if not self.user and not self.balance: + return BasePage( + content=Column( + MainViewContentBox( + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=2, + margin_bottom=2 + ) + ), + align_y = 0, + ) + ) + self.banking_info_revealer = Revealer( header=None, content=Column( @@ -45,7 +70,7 @@ class AccountPage(Component): align_x=0.2 ), Text( - f"AUFLADUNG - {user.user_id} - {user.user_name}", + f"AUFLADUNG - {self.user.user_id} - {self.user.user_name}", style=TextStyle( fill=self.session.theme.neutral_color ), @@ -73,7 +98,7 @@ class AccountPage(Component): ) ) - for transaction in sorted(self.session[AccountingService].get_transaction_history(user.user_id), key=lambda t: t.transaction_date, reverse=True): + for transaction in sorted(self.transaction_history, key=lambda t: t.transaction_date, reverse=True): transaction_history.add( Row( Text( @@ -89,7 +114,7 @@ class AccountPage(Component): align_x=0 ), Text( - f"{'-' if transaction.is_debit else '+'}{a_s.make_euro_string_from_int(transaction.value)}", + f"{'-' if transaction.is_debit else '+'}{AccountingService.make_euro_string_from_int(transaction.value)}", style=TextStyle( fill=self.session.theme.danger_color if transaction.is_debit else self.session.theme.success_color, font_size=0.8 @@ -106,7 +131,7 @@ class AccountPage(Component): content=Column( MainViewContentBox( content=Text( - f"Kontostand: {a_s.make_euro_string_from_int(a_s.get_balance(user.user_id))}", + f"Kontostand: {AccountingService.make_euro_string_from_int(self.balance)}", style=TextStyle( fill=self.session.theme.background_color, font_size=1.2 diff --git a/src/ez_lan_manager/pages/BasePage.py b/src/ez_lan_manager/pages/BasePage.py index a89efb4..f179ad3 100644 --- a/src/ez_lan_manager/pages/BasePage.py +++ b/src/ez_lan_manager/pages/BasePage.py @@ -4,7 +4,7 @@ from typing import * # type: ignore from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text -from src.ez_lan_manager import ConfigurationService, DatabaseService +from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.DesktopNavigation import DesktopNavigation class BasePage(Component): @@ -14,11 +14,6 @@ class BasePage(Component): async def on_window_size_change(self): await self.force_refresh() - @event.on_populate - async def check_db_connection(self): - if not self.session[DatabaseService].is_connected: - self.session.navigate_to("./db-error") - def build(self) -> Component: if self.content is None: content = Spacer() diff --git a/src/ez_lan_manager/pages/CateringPage.py b/src/ez_lan_manager/pages/CateringPage.py index 09af45f..ee87e56 100644 --- a/src/ez_lan_manager/pages/CateringPage.py +++ b/src/ez_lan_manager/pages/CateringPage.py @@ -1,16 +1,19 @@ -from rio import Column, Component, event, TextStyle, Text, Spacer, Revealer, SwitcherBar, SwitcherBarChangeEvent +from typing import Optional + +from rio import Column, Component, event, TextStyle, Text, Spacer, Revealer, SwitcherBar, SwitcherBarChangeEvent, ProgressCircle from src.ez_lan_manager import ConfigurationService, CateringService from src.ez_lan_manager.components.CateringSelectionItem import CateringSelectionItem from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.components.ShoppingCartAndOrders import ShoppingCartAndOrders from src.ez_lan_manager.pages import BasePage -from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory +from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory, CateringMenuItem from src.ez_lan_manager.types.SessionStorage import SessionStorage class CateringPage(Component): show_cart = True + all_menu_items: Optional[list[CateringMenuItem]] = None def __post_init__(self) -> None: self.session[SessionStorage].subscribe_to_logged_in_or_out_event(self.__class__.__name__, self.on_user_logged_in_status_changed) @@ -18,6 +21,8 @@ class CateringPage(Component): @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Catering") + self.all_menu_items = await self.session[CateringService].get_menu() + async def on_user_logged_in_status_changed(self) -> None: await self.force_refresh() @@ -25,9 +30,13 @@ class CateringPage(Component): async def on_switcher_bar_changed(self, _: SwitcherBarChangeEvent) -> None: await self.shopping_cart_and_orders.switch() + @staticmethod + def get_menu_items_by_category(all_menu_items: list[CateringMenuItem], category: Optional[CateringMenuItemCategory]) -> list[CateringMenuItem]: + return list(filter(lambda item: item.category == category, all_menu_items)) + + def build(self) -> Component: user_id = self.session[SessionStorage].user_id - catering_service = self.session[CateringService] self.shopping_cart_and_orders = ShoppingCartAndOrders() switcher_bar = SwitcherBar( values=["cart", "orders"], @@ -58,12 +67,14 @@ class CateringPage(Component): ) ) if user_id else Spacer() - return BasePage( - content=Column( - # SHOPPING CART - shopping_cart_and_orders_container, - # ITEM SELECTION - MainViewContentBox( + menu = [MainViewContentBox( + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=2, + margin_bottom=2 + ) + )] if not self.all_menu_items else [MainViewContentBox( Revealer( header="Snacks", content=Column( @@ -75,7 +86,7 @@ class CateringPage(Component): is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.SNACK))], + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.SNACK))], ), header_style=TextStyle( fill=self.session.theme.background_color, @@ -97,7 +108,7 @@ class CateringPage(Component): is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.BREAKFAST))], + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BREAKFAST))], ), header_style=TextStyle( fill=self.session.theme.background_color, @@ -119,7 +130,7 @@ class CateringPage(Component): is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.MAIN_COURSE))], + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.MAIN_COURSE))], ), header_style=TextStyle( fill=self.session.theme.background_color, @@ -141,7 +152,7 @@ class CateringPage(Component): is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.DESSERT))], + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.DESSERT))], ), header_style=TextStyle( fill=self.session.theme.background_color, @@ -163,7 +174,7 @@ class CateringPage(Component): is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC))], + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC))], ), header_style=TextStyle( fill=self.session.theme.background_color, @@ -185,7 +196,7 @@ class CateringPage(Component): is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.BEVERAGE_ALCOHOLIC))], + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC))], ), header_style=TextStyle( fill=self.session.theme.background_color, @@ -207,7 +218,7 @@ class CateringPage(Component): is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.BEVERAGE_COCKTAIL))], + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_COCKTAIL))], ), header_style=TextStyle( fill=self.session.theme.background_color, @@ -229,7 +240,7 @@ class CateringPage(Component): is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.BEVERAGE_SHOT))], + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_SHOT))], ), header_style=TextStyle( fill=self.session.theme.background_color, @@ -251,7 +262,7 @@ class CateringPage(Component): is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(catering_service.get_menu(CateringMenuItemCategory.NON_FOOD))], + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.NON_FOOD))], ), header_style=TextStyle( fill=self.session.theme.background_color, @@ -260,7 +271,14 @@ class CateringPage(Component): margin=1, align_y=0.5 ) - ), + )] + + return BasePage( + content=Column( + # SHOPPING CART + shopping_cart_and_orders_container, + # ITEM SELECTION + *menu, align_y=0 ) ) diff --git a/src/ez_lan_manager/pages/ContactPage.py b/src/ez_lan_manager/pages/ContactPage.py index 24b454d..6fdb7d9 100644 --- a/src/ez_lan_manager/pages/ContactPage.py +++ b/src/ez_lan_manager/pages/ContactPage.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from typing import Optional from rio import Text, Column, TextStyle, Component, event, TextInput, MultiLineTextInput, Row, Button @@ -7,6 +8,7 @@ from src.ez_lan_manager.components.AnimatedText import AnimatedText from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.User import User class ContactPage(Component): @@ -14,10 +16,15 @@ class ContactPage(Component): # Using list to bypass this behavior last_message_sent: list[datetime] = [datetime(day=1, month=1, year=2000)] display_printing: list[bool] = [False] + user: Optional[User] = None @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Kontakt") + if self.session[SessionStorage].user_id is not None: + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) + else: + self.user = None async def on_send_pressed(self) -> None: error_msg = "" @@ -51,11 +58,6 @@ class ContactPage(Component): await self.animated_text.display_text(True, "Nachricht erfolgreich gesendet!") def build(self) -> Component: - if self.session[SessionStorage].user_id is not None: - user = self.session[UserService].get_user(self.session[SessionStorage].user_id) - else: - user = None - self.animated_text = AnimatedText( margin_top = 2, margin_bottom = 1, @@ -64,7 +66,7 @@ class ContactPage(Component): self.email_input = TextInput( label="E-Mail Adresse", - text="" if not user else user.user_mail, + text="" if not self.user else self.user.user_mail, margin_left=1, margin_right=1, margin_bottom=1, diff --git a/src/ez_lan_manager/pages/DbErrorPage.py b/src/ez_lan_manager/pages/DbErrorPage.py index 97c50f0..f3da2a3 100644 --- a/src/ez_lan_manager/pages/DbErrorPage.py +++ b/src/ez_lan_manager/pages/DbErrorPage.py @@ -15,12 +15,12 @@ class DbErrorPage(Component): async def on_window_size_change(self) -> None: await self.force_refresh() - @event.on_mount - async def retry_db_connect(self) -> None: - await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Fehler") - while not self.session[DatabaseService].is_connected: - await sleep(2) - self.session.navigate_to("./") + # @event.on_mount + # async def retry_db_connect(self) -> None: + # await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Fehler") + # while not self.session[DatabaseService].is_connected: + # await sleep(2) + # self.session.navigate_to("./") def build(self) -> Component: content = Card( diff --git a/src/ez_lan_manager/pages/EditProfile.py b/src/ez_lan_manager/pages/EditProfile.py index 774a7be..4543337 100644 --- a/src/ez_lan_manager/pages/EditProfile.py +++ b/src/ez_lan_manager/pages/EditProfile.py @@ -3,7 +3,8 @@ from hashlib import sha256 from typing import Optional from from_root import from_root -from rio import Column, Component, event, Text, TextStyle, Button, Color, Row, TextInput, Image, TextInputChangeEvent, NoFileSelectedError +from rio import Column, Component, event, Text, TextStyle, Button, Color, Row, TextInput, Image, TextInputChangeEvent, NoFileSelectedError, \ + ProgressCircle from email_validator import validate_email, EmailNotValidError from src.ez_lan_manager import ConfigurationService, UserService @@ -15,6 +16,8 @@ from src.ez_lan_manager.types.User import User class EditProfilePage(Component): + user: Optional[User] = None + pfp: Optional[bytes] = None @staticmethod def optional_date_to_str(d: Optional[date]) -> str: if not d: @@ -24,6 +27,8 @@ class EditProfilePage(Component): @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Profil bearbeiten") + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) + self.pfp = await self.session[UserService].get_profile_picture(self.user.user_id) def on_email_changed(self, change_event: TextInputChangeEvent) -> None: try: @@ -58,7 +63,7 @@ class EditProfilePage(Component): return image_data = await new_pfp.read_bytes() - self.session[UserService].set_profile_picture(self.session[SessionStorage].user_id, image_data) + await self.session[UserService].set_profile_picture(self.session[SessionStorage].user_id, image_data) self.pfp_image_container.image = image_data await self.animated_text.display_text(True, "Gespeichert!") @@ -72,7 +77,7 @@ class EditProfilePage(Component): await self.animated_text.display_text(False, "Passwörter nicht gleich!") return - user: User = self.session[UserService].get_user(self.session[SessionStorage].user_id) + user: User = await self.session[UserService].get_user(self.session[SessionStorage].user_id) user.user_mail = self.email_input.text if len(self.birthday_input.text) == 0: @@ -86,12 +91,24 @@ class EditProfilePage(Component): if len(self.new_pw_1_input.text.strip()) > 0: user.user_password = sha256(self.new_pw_1_input.text.encode(encoding="utf-8")).hexdigest() - self.session[UserService].update_user(user) + await self.session[UserService].update_user(user) await self.animated_text.display_text(True, "Gespeichert!") def build(self) -> Component: - user = self.session[UserService].get_user(self.session[SessionStorage].user_id) - pfp = self.session[UserService].get_profile_picture(user.user_id) + if not self.user: + return BasePage( + content=Column( + MainViewContentBox( + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=2, + margin_bottom=2 + ) + ), + align_y = 0 + ) + ) self.animated_text = AnimatedText( margin_top=2, @@ -101,7 +118,7 @@ class EditProfilePage(Component): self.email_input = TextInput( label="E-Mail Adresse", - text=user.user_mail, + text=self.user.user_mail, margin_left=1, margin_right=1, margin_bottom=1, @@ -110,20 +127,20 @@ class EditProfilePage(Component): ) self.first_name_input = TextInput( label="Vorname", - text=user.user_first_name, + text=self.user.user_first_name, margin_left=1, margin_right=1, grow_x=True ) self.last_name_input = TextInput( label="Nachname", - text=user.user_last_name, + text=self.user.user_last_name, margin_right=1, grow_x=True ) self.birthday_input = TextInput( label="Geburtstag (TT.MM.JJJJ)", - text=self.optional_date_to_str(user.user_birth_day), + text=self.optional_date_to_str(self.user.user_birth_day), margin_left=1, margin_right=1, margin_bottom=1, @@ -150,7 +167,7 @@ class EditProfilePage(Component): ) self.pfp_image_container = Image( - from_root("src/ez_lan_manager/assets/img/anon_pfp.png") if pfp is None else pfp, + from_root("src/ez_lan_manager/assets/img/anon_pfp.png") if self.pfp is None else self.pfp, align_x=0.5, min_width=10, min_height=10, @@ -176,8 +193,8 @@ class EditProfilePage(Component): on_press=self.upload_new_pfp ), Row( - TextInput(label="Deine User-ID", text=user.user_id, is_sensitive=False, margin_left=1, grow_x=False), - TextInput(label="Dein Nickname", text=user.user_name, is_sensitive=False, margin_left=1, margin_right=1, grow_x=True), + TextInput(label="Deine User-ID", text=self.user.user_id, is_sensitive=False, margin_left=1, grow_x=False), + TextInput(label="Dein Nickname", text=self.user.user_name, is_sensitive=False, margin_left=1, margin_right=1, grow_x=True), margin_bottom=1 ), self.email_input, diff --git a/src/ez_lan_manager/pages/ForgotPassword.py b/src/ez_lan_manager/pages/ForgotPassword.py index 7dc0212..eafba10 100644 --- a/src/ez_lan_manager/pages/ForgotPassword.py +++ b/src/ez_lan_manager/pages/ForgotPassword.py @@ -24,11 +24,11 @@ class ForgotPasswordPage(Component): lan_info = self.session[ConfigurationService].get_lan_info() user_service = self.session[UserService] mailing_service = self.session[MailingService] - user = user_service.get_user(self.email_input.text.strip()) + user = await user_service.get_user(self.email_input.text.strip()) if user is not None: new_password = "".join(choices(user_service.ALLOWED_USER_NAME_SYMBOLS, k=16)) user.user_password = sha256(new_password.encode(encoding="utf-8")).hexdigest() - user_service.update_user(user) + await user_service.update_user(user) await mailing_service.send_email( subject=f"Dein neues Passwort für {lan_info.name}", body=f"Du hast für den EZ-LAN Manager der {lan_info.name} ein neues Passwort angefragt. " diff --git a/src/ez_lan_manager/pages/GuestsPage.py b/src/ez_lan_manager/pages/GuestsPage.py index 363a556..bb35ce1 100644 --- a/src/ez_lan_manager/pages/GuestsPage.py +++ b/src/ez_lan_manager/pages/GuestsPage.py @@ -5,37 +5,50 @@ from rio import Column, Component, event, TextStyle, Text, Button, Row, TextInpu from src.ez_lan_manager import ConfigurationService, UserService, TicketingService, SeatingService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.types.Seat import Seat from src.ez_lan_manager.types.User import User class GuestsPage(Component): table_elements: list[Button] = [] users_with_tickets: list[User] = [] + users_with_seats: dict[User, Seat] = {} user_filter: Optional[str] = None - - def __post_init__(self) -> None: - user_service = self.session[UserService] - all_users = user_service.get_all_users() - ticketing_service = self.session[TicketingService] - self.users_with_tickets = list(filter(lambda user: ticketing_service.get_user_ticket(user.user_id) is not None, all_users)) - @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Teilnehmer") + user_service = self.session[UserService] + all_users = await user_service.get_all_users() + ticketing_service = self.session[TicketingService] + seating_service = self.session[SeatingService] + u_w_t = [] + u_w_s = {} + for user in all_users: + ticket = await ticketing_service.get_user_ticket(user.user_id) + seat = await seating_service.get_user_seat(user.user_id) + if ticket is not None: + u_w_t.append(user) + if seat is not None: + u_w_s[user] = seat + + self.users_with_tickets = u_w_t + self.users_with_seats = u_w_s def on_searchbar_content_change(self, change_event: TextInputChangeEvent) -> None: self.user_filter = change_event.text def build(self) -> Component: - seating_service = self.session[SeatingService] if self.user_filter: users = [user for user in self.users_with_tickets if self.user_filter.lower() in user.user_name or self.user_filter.lower() in str(user.user_id)] else: users = self.users_with_tickets self.table_elements.clear() for idx, user in enumerate(users): - seat = seating_service.get_user_seat(user.user_id) + try: + seat = self.users_with_seats[user] + except KeyError: + seat = None self.table_elements.append( Button( content=Row(Text(text=f"{user.user_id:0>4}", align_x=0, margin_right=1), Text(text=user.user_name, grow_x=True, wrap="ellipsize"), Text(text="-" if seat is None else seat.seat_id, align_x=1)), diff --git a/src/ez_lan_manager/pages/NewsPage.py b/src/ez_lan_manager/pages/NewsPage.py index d8c73d5..1d83807 100644 --- a/src/ez_lan_manager/pages/NewsPage.py +++ b/src/ez_lan_manager/pages/NewsPage.py @@ -12,7 +12,7 @@ class NewsPage(Component): @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Neuigkeiten") - self.news_posts = self.session[NewsService].get_news()[:8] + self.news_posts = (await self.session[NewsService].get_news())[:8] def build(self) -> Component: posts = [NewsPost( diff --git a/src/ez_lan_manager/pages/RegisterPage.py b/src/ez_lan_manager/pages/RegisterPage.py index d03e459..1ee6d66 100644 --- a/src/ez_lan_manager/pages/RegisterPage.py +++ b/src/ez_lan_manager/pages/RegisterPage.py @@ -62,13 +62,13 @@ class RegisterPage(Component): mailing_service = self.session[MailingService] lan_info = self.session[ConfigurationService].get_lan_info() - if user_service.get_user(self.email_input.text) is not None or user_service.get_user(self.user_name_input.text) is not None: + if await user_service.get_user(self.email_input.text) is not None or await user_service.get_user(self.user_name_input.text) is not None: await self.animated_text.display_text(False, "Benutzername oder E-Mail bereits regestriert!") self.submit_button.is_loading = False return try: - new_user = user_service.create_user(self.user_name_input.text, self.email_input.text, self.pw_1.text) + new_user = await user_service.create_user(self.user_name_input.text, self.email_input.text, self.pw_1.text) if not new_user: raise RuntimeError("User could not be created") except Exception as e: diff --git a/src/ez_lan_manager/services/AccountingService.py b/src/ez_lan_manager/services/AccountingService.py index e3bec1e..120ac85 100644 --- a/src/ez_lan_manager/services/AccountingService.py +++ b/src/ez_lan_manager/services/AccountingService.py @@ -13,8 +13,8 @@ class AccountingService: def __init__(self, db_service: DatabaseService) -> None: self._db_service = db_service - def add_balance(self, user_id: int, balance_to_add: int, reference: str) -> int: - self._db_service.add_transaction(Transaction( + async def add_balance(self, user_id: int, balance_to_add: int, reference: str) -> int: + await self._db_service.add_transaction(Transaction( user_id=user_id, value=balance_to_add, is_debit=False, @@ -22,13 +22,13 @@ class AccountingService: 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}") - return self.get_balance(user_id) + return await self.get_balance(user_id) - def remove_balance(self, user_id: int, balance_to_remove: int, reference: str) -> int: - current_balance = self.get_balance(user_id) + async def remove_balance(self, user_id: int, balance_to_remove: int, reference: str) -> int: + current_balance = await self.get_balance(user_id) if (current_balance - balance_to_remove) < 0: raise InsufficientFundsError - self._db_service.add_transaction(Transaction( + await self._db_service.add_transaction(Transaction( user_id=user_id, value=balance_to_remove, is_debit=True, @@ -36,19 +36,19 @@ class AccountingService: 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}") - return self.get_balance(user_id) + return await self.get_balance(user_id) - def get_balance(self, user_id: int) -> int: + async def get_balance(self, user_id: int) -> int: balance_buffer = 0 - for transaction in self._db_service.get_all_transactions_for_user(user_id): + for transaction in await self._db_service.get_all_transactions_for_user(user_id): if transaction.is_debit: balance_buffer -= transaction.value else: balance_buffer += transaction.value return balance_buffer - def get_transaction_history(self, user_id: int) -> list[Transaction]: - return self._db_service.get_all_transactions_for_user(user_id) + async def get_transaction_history(self, user_id: int) -> list[Transaction]: + return await self._db_service.get_all_transactions_for_user(user_id) @staticmethod def make_euro_string_from_int(cent_int: int) -> str: diff --git a/src/ez_lan_manager/services/CateringService.py b/src/ez_lan_manager/services/CateringService.py index 33effae..5c4853a 100644 --- a/src/ez_lan_manager/services/CateringService.py +++ b/src/ez_lan_manager/services/CateringService.py @@ -23,93 +23,93 @@ class CateringService: # ORDERS - 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") - user = self._user_service.get_user(user_id) + user = await self._user_service.get_user(user_id) if not user: raise CateringError("User does not exist") total_price = sum([item.price * quantity for item, quantity in menu_items.items()]) - if self._accounting_service.get_balance(user_id) < total_price: + if await self._accounting_service.get_balance(user_id) < total_price: raise CateringError("Insufficient funds") - order = self._db_service.add_new_order(menu_items, user_id, is_delivery) + order = await self._db_service.add_new_order(menu_items, user_id, is_delivery) if order: - self._accounting_service.remove_balance(user_id, total_price, f"CATERING - {order.order_id}") + 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)}") return order - def update_order_status(self, order_id: int, new_status: CateringOrderStatus) -> bool: + async def update_order_status(self, order_id: int, new_status: CateringOrderStatus) -> bool: if new_status == CateringOrderStatus.CANCELED: # Cancelled orders need to be refunded raise CateringError("Orders cannot be canceled this way, use CateringService.cancel_order") - return self._db_service.change_order_status(order_id, new_status) + return await self._db_service.change_order_status(order_id, new_status) - def get_orders(self) -> list[CateringOrder]: - return self._db_service.get_orders() + async def get_orders(self) -> list[CateringOrder]: + return await self._db_service.get_orders() - def get_orders_for_user(self, user_id: int) -> list[CateringOrder]: - return self._db_service.get_orders(user_id=user_id) + async def get_orders_for_user(self, user_id: int) -> list[CateringOrder]: + return await self._db_service.get_orders(user_id=user_id) - def get_orders_by_status(self, status: CateringOrderStatus) -> list[CateringOrder]: - return self._db_service.get_orders(status=status) + async def get_orders_by_status(self, status: CateringOrderStatus) -> list[CateringOrder]: + return await self._db_service.get_orders(status=status) - def cancel_order(self, order: CateringOrder) -> bool: + async def cancel_order(self, order: CateringOrder) -> bool: if self._db_service.change_order_status(order.order_id, CateringOrderStatus.CANCELED): - 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 # MENU ITEMS - def get_menu(self, category: Optional[CateringMenuItemCategory] = None) -> list[CateringMenuItem]: - items = self._db_service.get_menu_items() + async def get_menu(self, category: Optional[CateringMenuItemCategory] = None) -> list[CateringMenuItem]: + items = await self._db_service.get_menu_items() if not category: return items return list(filter(lambda item: item.category == category, items)) - def get_menu_item_by_id(self, menu_item_id: int) -> CateringMenuItem: - item = self._db_service.get_menu_item(menu_item_id) + async def get_menu_item_by_id(self, menu_item_id: int) -> CateringMenuItem: + item = await self._db_service.get_menu_item(menu_item_id) if not item: raise CateringError("Menu item not found") return item - def add_menu_item(self, name: str, info: str, price: int, category: CateringMenuItemCategory, is_disabled: bool = False) -> CateringMenuItem: - if new_item := self._db_service.add_menu_item(name, info, price, category, is_disabled): + async def add_menu_item(self, name: str, info: str, price: int, category: CateringMenuItemCategory, is_disabled: bool = False) -> CateringMenuItem: + if new_item := await self._db_service.add_menu_item(name, info, price, category, is_disabled): return new_item raise CateringError(f"Could not add item '{name}' to the menu.") - def remove_menu_item(self, menu_item_id: int) -> bool: - return self._db_service.delete_menu_item(menu_item_id) + async def remove_menu_item(self, menu_item_id: int) -> bool: + return await self._db_service.delete_menu_item(menu_item_id) - def change_menu_item(self, updated_item: CateringMenuItem) -> bool: - return self._db_service.update_menu_item(updated_item) + async def change_menu_item(self, updated_item: CateringMenuItem) -> bool: + return await self._db_service.update_menu_item(updated_item) - def disable_menu_item(self, menu_item_id: int) -> bool: + async def disable_menu_item(self, menu_item_id: int) -> bool: try: - item = self.get_menu_item_by_id(menu_item_id) + item = await self.get_menu_item_by_id(menu_item_id) except CateringError: return False item.is_disabled = True - return self._db_service.update_menu_item(item) + return await self._db_service.update_menu_item(item) - def enable_menu_item(self, menu_item_id: int) -> bool: + async def enable_menu_item(self, menu_item_id: int) -> bool: try: - item = self.get_menu_item_by_id(menu_item_id) + item = await self.get_menu_item_by_id(menu_item_id) except CateringError: return False item.is_disabled = False - return self._db_service.update_menu_item(item) + return await self._db_service.update_menu_item(item) - def disable_menu_items_by_category(self, category: CateringMenuItemCategory) -> bool: - items = self.get_menu(category=category) + async def disable_menu_items_by_category(self, category: CateringMenuItemCategory) -> bool: + items = await self.get_menu(category=category) return all([self.disable_menu_item(item.item_id) for item in items]) - def enable_menu_items_by_category(self, category: CateringMenuItemCategory) -> bool: - items = self.get_menu(category=category) + async def enable_menu_items_by_category(self, category: CateringMenuItemCategory) -> bool: + items = await self.get_menu(category=category) return all([self.enable_menu_item(item.item_id) for item in items]) # CART diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index bbcd4ba..3bca5c4 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -1,11 +1,9 @@ import logging -from time import sleep from datetime import date, datetime -from typing import Optional, Coroutine +from typing import Optional -import mariadb -from mariadb import Cursor +import aiomysql from src.ez_lan_manager.types.CateringOrder import CateringOrder from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItem, CateringMenuItemCategory @@ -29,56 +27,27 @@ class DatabaseService: MAX_CONNECTION_RETRIES = 5 def __init__(self, database_config: DatabaseConfiguration) -> None: self._database_config = database_config + self._connection_pool: Optional[aiomysql.Pool] = None + + async def init_db_pool(self) -> bool: logger.info( f"Connecting to database '{self._database_config.db_name}' on " f"{self._database_config.db_user}@{self._database_config.db_host}:{self._database_config.db_port}" ) - self._connection: Optional[mariadb.Connection] = None - self._reestablishment_lock = False - self.establish_new_connection() - - @property - def is_connected(self) -> bool: try: - self._connection.ping() - except Exception: - try: - self.establish_new_connection() - return True - except NoDatabaseConnectionError: - return False + self._connection_pool = await aiomysql.create_pool( + host=self._database_config.db_host, + port=self._database_config.db_port, + user=self._database_config.db_user, + password=self._database_config.db_password, + db=self._database_config.db_name, + minsize=1, + maxsize=20 + ) + except aiomysql.OperationalError: + return False return True - def establish_new_connection(self) -> None: - if self._reestablishment_lock: - return - self._reestablishment_lock = True - - if isinstance(self._connection, mariadb.Connection): - self._connection.close() - self._connection = None - - for _ in range(DatabaseService.MAX_CONNECTION_RETRIES): - try: - self._connection = mariadb.connect( - user=self._database_config.db_user, - password=self._database_config.db_password, - host=self._database_config.db_host, - port=self._database_config.db_port, - database=self._database_config.db_name - ) - except mariadb.Error: - sleep(0.4) - continue - self._reestablishment_lock = False - return - self._reestablishment_lock = False - raise NoDatabaseConnectionError - - - def _get_cursor(self) -> Cursor: - return self._connection.cursor() - @staticmethod def _map_db_result_to_user(data: tuple) -> User: return User( @@ -96,554 +65,640 @@ class DatabaseService: last_updated_at=data[11] ) - def get_user_by_name(self, user_name: str) -> Optional[User]: - cursor = self._get_cursor() - cursor.execute("SELECT * FROM users WHERE user_name=?", (user_name,)) - self._connection.commit() - result = cursor.fetchone() - if not result: - return - return self._map_db_result_to_user(result) + async def get_user_by_name(self, user_name: str) -> Optional[User]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + await cursor.execute("SELECT * FROM users WHERE user_name=%s", (user_name,)) + result = await cursor.fetchone() + if not result: + return + return self._map_db_result_to_user(result) - def get_user_by_id(self, user_id: int) -> Optional[User]: - cursor = self._get_cursor() - cursor.execute("SELECT * FROM users WHERE user_id=?", (user_id,)) - self._connection.commit() - result = cursor.fetchone() - if not result: - return - return self._map_db_result_to_user(result) - def get_user_by_mail(self, user_mail: str) -> Optional[User]: - cursor = self._get_cursor() - cursor.execute("SELECT * FROM users WHERE user_mail=?", (user_mail.lower(),)) - self._connection.commit() - result = cursor.fetchone() - if not result: - return - return self._map_db_result_to_user(result) + async def get_user_by_id(self, user_id: int) -> Optional[User]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + await cursor.execute("SELECT * FROM users WHERE user_id=%s", (user_id,)) + result = await cursor.fetchone() + if not result: + return + return self._map_db_result_to_user(result) - def create_user(self, user_name: str, user_mail: str, password_hash: str) -> User: - cursor = self._get_cursor() - try: - cursor.execute( - "INSERT INTO users (user_name, user_mail, user_password) " - "VALUES (?, ?, ?)", (user_name, user_mail.lower(), password_hash) - ) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.create_user(user_name, user_mail, password_hash) - except mariadb.IntegrityError as e: - logger.warning(f"Aborted duplication entry: {e}") - raise DuplicationError + async def get_user_by_mail(self, user_mail: str) -> Optional[User]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + await cursor.execute("SELECT * FROM users WHERE user_mail=%s", (user_mail.lower(),)) + result = await cursor.fetchone() + if not result: + return + return self._map_db_result_to_user(result) - return self.get_user_by_name(user_name) + async def create_user(self, user_name: str, user_mail: str, password_hash: str) -> User: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "INSERT INTO users (user_name, user_mail, user_password) " + "VALUES (%s, %s, %s)", (user_name, user_mail.lower(), password_hash) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.create_user(user_name, user_mail, password_hash) + except aiomysql.IntegrityError as e: + logger.warning(f"Aborted duplication entry: {e}") + raise DuplicationError - def update_user(self, user: User) -> User: - cursor = self._get_cursor() - try: - cursor.execute( - "UPDATE users SET user_name=?, user_mail=?, user_password=?, user_first_name=?, user_last_name=?, user_birth_date=?, " - "is_active=?, is_team_member=?, is_admin=? WHERE (user_id=?)", (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) - ) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.update_user(user) - except mariadb.IntegrityError as e: - logger.warning(f"Aborted duplication entry: {e}") - raise DuplicationError - return user + return await self.get_user_by_name(user_name) + + - def add_transaction(self, transaction: Transaction) -> Optional[Transaction]: - cursor = self._get_cursor() - try: - cursor.execute( - "INSERT INTO transactions (user_id, value, is_debit, transaction_date, transaction_reference) " - "VALUES (?, ?, ?, ?, ?)", - (transaction.user_id, transaction.value, transaction.is_debit, transaction.transaction_date, transaction.reference) - ) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.add_transaction(transaction) - except Exception as e: - logger.warning(f"Error adding Transaction: {e}") - return + async def update_user(self, user: User) -> User: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "UPDATE users SET user_name=%s, user_mail=%s, user_password=%s, user_first_name=%s, user_last_name=%s, user_birth_date=%s, " + "is_active=%s, is_team_member=%s, is_admin=%s WHERE (user_id=%s)", (user.user_name, user.user_mail.lower(), user.user_password, + user.user_first_name, user.user_last_name, user.user_birth_day, + user.is_active, user.is_team_member, user.is_admin, + user.user_id) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.update_user(user) + except aiomysql.IntegrityError as e: + logger.warning(f"Aborted duplication entry: {e}") + raise DuplicationError + return user - return transaction + async def add_transaction(self, transaction: Transaction) -> Optional[Transaction]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "INSERT INTO transactions (user_id, value, is_debit, transaction_date, transaction_reference) " + "VALUES (%s, %s, %s, %s, %s)", + (transaction.user_id, transaction.value, transaction.is_debit, transaction.transaction_date, transaction.reference) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.add_transaction(transaction) + except Exception as e: + logger.warning(f"Error adding Transaction: {e}") + return - def get_all_transactions_for_user(self, user_id: int) -> list[Transaction]: - transactions = [] + return transaction - cursor = self._get_cursor() - try: - cursor.execute("SELECT * FROM transactions WHERE user_id=?", (user_id,)) - self._connection.commit() - result = cursor.fetchall() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_all_transactions_for_user(user_id) - except mariadb.Error as e: - logger.error(f"Error getting all transactions for user: {e}") - return [] + async def get_all_transactions_for_user(self, user_id: int) -> list[Transaction]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + transactions = [] + try: + await cursor.execute("SELECT * FROM transactions WHERE user_id=%s", (user_id,)) + await conn.commit() + result = await cursor.fetchall() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_all_transactions_for_user(user_id) + except aiomysql.Error as e: + logger.error(f"Error getting all transactions for user: {e}") + return [] - for transaction_raw in result: - transactions.append(Transaction( - user_id=user_id, - value=int(transaction_raw[2]), - is_debit=bool(transaction_raw[3]), - transaction_date=transaction_raw[4], - reference=transaction_raw[5] - )) - return transactions + for transaction_raw in result: + transactions.append(Transaction( + user_id=user_id, + value=int(transaction_raw[2]), + is_debit=bool(transaction_raw[3]), + transaction_date=transaction_raw[4], + reference=transaction_raw[5] + )) + return transactions - def add_news(self, news: News) -> None: - cursor = self._get_cursor() - try: - cursor.execute( - "INSERT INTO news (news_content, news_title, news_subtitle, news_author, news_date) " - "VALUES (?, ?, ?, ?, ?)", - (news.content, news.title, news.subtitle, news.author.user_id, news.news_date) - ) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.add_news(news) - except Exception as e: - logger.warning(f"Error adding Transaction: {e}") - def get_news(self, dt_start: date, dt_end: date) -> list[News]: - results = [] - cursor = self._get_cursor() - try: - cursor.execute("SELECT * FROM news INNER JOIN users ON news.news_author = users.user_id WHERE news_date BETWEEN ? AND ?;", (dt_start, dt_end)) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_news(dt_start, dt_end) - except Exception as e: - logger.warning(f"Error fetching news: {e}") - return [] + async def add_news(self, news: News) -> None: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "INSERT INTO news (news_content, news_title, news_subtitle, news_author, news_date) " + "VALUES (%s, %s, %s, %s, %s)", + (news.content, news.title, news.subtitle, news.author.user_id, news.news_date) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.add_news(news) + except Exception as e: + logger.warning(f"Error adding Transaction: {e}") - for news_raw in cursor.fetchall(): - user = self._map_db_result_to_user(news_raw[6:]) - results.append(News( - news_id=news_raw[0], - title=news_raw[2], - subtitle=news_raw[3], - author=user, - content=news_raw[1], - news_date=news_raw[5] - )) - return results + async def get_news(self, dt_start: date, dt_end: date) -> list[News]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + results = [] + try: + await cursor.execute("SELECT * FROM news INNER JOIN users ON news.news_author = users.user_id WHERE news_date BETWEEN %s AND %s;", (dt_start, dt_end)) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_news(dt_start, dt_end) + except Exception as e: + logger.warning(f"Error fetching news: {e}") + return [] - def get_tickets(self) -> list[Ticket]: - results = [] - cursor = self._get_cursor() - try: - cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id;", ()) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_tickets() - except Exception as e: - logger.warning(f"Error fetching tickets: {e}") - return [] + for news_raw in await cursor.fetchall(): + user = self._map_db_result_to_user(news_raw[6:]) + results.append(News( + news_id=news_raw[0], + title=news_raw[2], + subtitle=news_raw[3], + author=user, + content=news_raw[1], + news_date=news_raw[5] + )) - for ticket_raw in cursor.fetchall(): - user = self._map_db_result_to_user(ticket_raw[3:]) - results.append(Ticket( - ticket_id=ticket_raw[0], - category=ticket_raw[1], - purchase_date=ticket_raw[3], - owner=user - )) + return results - return results - def get_ticket_for_user(self, user_id: int) -> Optional[Ticket]: - cursor = self._get_cursor() - try: - cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id WHERE user_id=?;", (user_id, )) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_ticket_for_user(user_id) - except Exception as e: - logger.warning(f"Error fetching ticket for user: {e}") - return - result = cursor.fetchone() - if not result: - return + async def get_tickets(self) -> list[Ticket]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + results = [] + try: + await cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id;", ()) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_tickets() + except Exception as e: + logger.warning(f"Error fetching tickets: {e}") + return [] - user = self._map_db_result_to_user(result[3:]) - return Ticket( - ticket_id=result[0], - category=result[1], - purchase_date=result[3], - owner=user - ) + for ticket_raw in await cursor.fetchall(): + user = self._map_db_result_to_user(ticket_raw[3:]) + results.append(Ticket( + ticket_id=ticket_raw[0], + category=ticket_raw[1], + purchase_date=ticket_raw[3], + owner=user + )) - def generate_ticket_for_user(self, user_id: int, category: str) -> Optional[Ticket]: - cursor = self._get_cursor() - try: - cursor.execute("INSERT INTO tickets (ticket_category, user) VALUES (?, ?)", (category, user_id)) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.generate_ticket_for_user(user_id, category) - except Exception as e: - logger.warning(f"Error generating ticket for user: {e}") - return + return results - return self.get_ticket_for_user(user_id) - def change_ticket_owner(self, ticket_id: int, new_owner_id: int) -> bool: - cursor = self._get_cursor() - try: - cursor.execute("UPDATE tickets SET user = ? WHERE ticket_id = ?;", (new_owner_id, ticket_id)) - affected_rows = cursor.rowcount - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.change_ticket_owner(ticket_id, new_owner_id) - except Exception as e: - logger.warning(f"Error transferring ticket to user: {e}") - return False - return bool(affected_rows) + async def get_ticket_for_user(self, user_id: int) -> Optional[Ticket]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute("SELECT * FROM tickets INNER JOIN users ON tickets.user = users.user_id WHERE user_id=%s;", (user_id, )) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_ticket_for_user(user_id) + except Exception as e: + logger.warning(f"Error fetching ticket for user: {e}") + return - def delete_ticket(self, ticket_id: int) -> bool: - cursor = self._get_cursor() - try: - cursor.execute("DELETE FROM tickets WHERE ticket_id = ?;", (ticket_id, )) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.change_ticket_owner(ticket_id) - except Exception as e: - logger.warning(f"Error deleting ticket: {e}") - return False - return True + result = await cursor.fetchone() + if not result: + return - def generate_fresh_seats_table(self, seats: list[tuple[str, str]]) -> None: + user = self._map_db_result_to_user(result[3:]) + return Ticket( + ticket_id=result[0], + category=result[1], + purchase_date=result[3], + owner=user + ) + + async def generate_ticket_for_user(self, user_id: int, category: str) -> Optional[Ticket]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute("INSERT INTO tickets (ticket_category, user) VALUES (%s, %s)", (category, user_id)) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.generate_ticket_for_user(user_id, category) + except Exception as e: + logger.warning(f"Error generating ticket for user: {e}") + return + + return await self.get_ticket_for_user(user_id) + + + async def change_ticket_owner(self, ticket_id: int, new_owner_id: int) -> bool: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute("UPDATE tickets SET user = %s WHERE ticket_id = %s;", (new_owner_id, ticket_id)) + affected_rows = cursor.rowcount + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.change_ticket_owner(ticket_id, new_owner_id) + except Exception as e: + logger.warning(f"Error transferring ticket to user: {e}") + return False + return affected_rows > 0 + + async def delete_ticket(self, ticket_id: int) -> bool: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute("DELETE FROM tickets WHERE ticket_id = %s;", (ticket_id, )) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.change_ticket_owner(ticket_id) + except Exception as e: + logger.warning(f"Error deleting ticket: {e}") + return False + return True + + async def generate_fresh_seats_table(self, seats: list[tuple[str, str]]) -> None: """ WARNING: THIS WILL DELETE ALL EXISTING DATA! DO NOT USE ON PRODUCTION DATABASE! """ - cursor = self._get_cursor() - try: - cursor.execute("TRUNCATE seats;") - for seat in seats: - cursor.execute("INSERT INTO seats (seat_id, seat_category) VALUES (?, ?);", (seat[0], seat[1])) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.generate_fresh_seats_table(seats) - except Exception as e: - logger.warning(f"Error generating fresh seats table: {e}") - return + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute("TRUNCATE seats;") + for seat in seats: + await cursor.execute("INSERT INTO seats (seat_id, seat_category) VALUES (%s, %s);", (seat[0], seat[1])) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.generate_fresh_seats_table(seats) + except Exception as e: + logger.warning(f"Error generating fresh seats table: {e}") + return - def get_seating_info(self) -> list[Seat]: - results = [] - cursor = self._get_cursor() - try: - cursor.execute("SELECT seats.*, users.* FROM seats LEFT JOIN users ON seats.user = users.user_id;") - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_seating_info() - except Exception as e: - logger.warning(f"Error getting seats table: {e}") - return results + async def get_seating_info(self) -> list[Seat]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + results = [] + try: + await cursor.execute("SELECT seats.*, users.* FROM seats LEFT JOIN users ON seats.user = users.user_id;") + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_seating_info() + except Exception as e: + logger.warning(f"Error getting seats table: {e}") + return results + for seat_raw in await cursor.fetchall(): + if seat_raw[3] is None: # Empty seat + results.append(Seat(seat_raw[0], bool(seat_raw[1]), seat_raw[2], None)) + else: + user = self._map_db_result_to_user(seat_raw[4:]) + results.append(Seat(seat_raw[0], bool(seat_raw[1]), seat_raw[2], user)) - for seat_raw in cursor.fetchall(): - if seat_raw[3] is None: # Empty seat - results.append(Seat(seat_raw[0], bool(seat_raw[1]), seat_raw[2], None)) - else: - user = self._map_db_result_to_user(seat_raw[4:]) - results.append(Seat(seat_raw[0], bool(seat_raw[1]), seat_raw[2], user)) + return results - return results + async def seat_user(self, seat_id: str, user_id: int) -> bool: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute("UPDATE seats SET user = %s WHERE seat_id = %s;", (user_id, seat_id)) + affected_rows = cursor.rowcount + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.seat_user(seat_id, user_id) + except Exception as e: + logger.warning(f"Error seating user: {e}") + return False + return affected_rows > 0 - def seat_user(self, seat_id: str, user_id: int) -> bool: - cursor = self._get_cursor() - try: - cursor.execute("UPDATE seats SET user = ? WHERE seat_id = ?;", (user_id, seat_id)) - affected_rows = cursor.rowcount - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.seat_user(seat_id, user_id) - except Exception as e: - logger.warning(f"Error seating user: {e}") - return False - return bool(affected_rows) + async def get_menu_items(self) -> list[CateringMenuItem]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + results = [] + try: + await cursor.execute("SELECT * FROM catering_menu_items;") + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_menu_items() + except Exception as e: + logger.warning(f"Error fetching menu items: {e}") + return results - def get_menu_items(self) -> list[CateringMenuItem]: - results = [] - cursor = self._get_cursor() - try: - cursor.execute("SELECT * FROM catering_menu_items;") - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_menu_items() - except Exception as e: - logger.warning(f"Error fetching menu items: {e}") - return results + for menu_item_raw in await cursor.fetchall(): + results.append(CateringMenuItem( + item_id=menu_item_raw[0], + name=menu_item_raw[1], + additional_info=menu_item_raw[2], + price=menu_item_raw[3], + category=CateringMenuItemCategory(menu_item_raw[4]), + is_disabled=bool(menu_item_raw[5]) + )) - for menu_item_raw in cursor.fetchall(): - results.append(CateringMenuItem( - item_id=menu_item_raw[0], - name=menu_item_raw[1], - additional_info=menu_item_raw[2], - price=menu_item_raw[3], - category=CateringMenuItemCategory(menu_item_raw[4]), - is_disabled=bool(menu_item_raw[5]) - )) + return results - return results + async def get_menu_item(self, menu_item_id) -> Optional[CateringMenuItem]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute("SELECT * FROM catering_menu_items WHERE catering_menu_item_id = %s;", (menu_item_id, )) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_menu_item(menu_item_id) + except Exception as e: + logger.warning(f"Error fetching menu items: {e}") + return - def get_menu_item(self, menu_item_id) -> Optional[CateringMenuItem]: - cursor = self._get_cursor() - try: - cursor.execute("SELECT * FROM catering_menu_items WHERE catering_menu_item_id = ?;", (menu_item_id, )) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_menu_item(menu_item_id) - except Exception as e: - logger.warning(f"Error fetching menu items: {e}") - return + raw_data = await cursor.fetchone() + if raw_data is None: + return + return CateringMenuItem( + item_id=raw_data[0], + name=raw_data[1], + additional_info=raw_data[2], + price=raw_data[3], + category=CateringMenuItemCategory(raw_data[4]), + is_disabled=bool(raw_data[5]) + ) - raw_data = cursor.fetchone() - if raw_data is None: - return - return CateringMenuItem( - item_id=raw_data[0], - name=raw_data[1], - additional_info=raw_data[2], - price=raw_data[3], - category=CateringMenuItemCategory(raw_data[4]), - is_disabled=bool(raw_data[5]) - ) + async def add_menu_item(self, name: str, info: str, price: int, category: CateringMenuItemCategory, is_disabled: bool = False) -> Optional[CateringMenuItem]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "INSERT INTO catering_menu_items (name, additional_info, price, category, is_disabled) VALUES (%s, %s, %s, %s, %s);", + (name, info, price, category.value, is_disabled) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.add_menu_item(name, info, price, category, is_disabled) + except Exception as e: + logger.warning(f"Error adding menu item: {e}") + return - def add_menu_item(self, name: str, info: str, price: int, category: CateringMenuItemCategory, is_disabled: bool = False) -> Optional[CateringMenuItem]: - cursor = self._get_cursor() - try: - cursor.execute( - "INSERT INTO catering_menu_items (name, additional_info, price, category, is_disabled) VALUES (?, ?, ?, ?, ?);", - (name, info, price, category.value, is_disabled) - ) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.add_menu_item(name, info, price, category, is_disabled) - except Exception as e: - logger.warning(f"Error adding menu item: {e}") - return - - return CateringMenuItem( - item_id=cursor.lastrowid, - name=name, - additional_info=info, - price=price, - category=category, - is_disabled=is_disabled - ) - - def delete_menu_item(self, menu_item_id: int) -> bool: - cursor = self._get_cursor() - try: - cursor.execute("DELETE FROM catering_menu_items WHERE catering_menu_item_id = ?;", (menu_item_id,)) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.delete_menu_item(menu_item_id) - except Exception as e: - logger.warning(f"Error deleting menu item: {e}") - return False - return bool(cursor.affected_rows) - - def update_menu_item(self, updated_item: CateringMenuItem) -> bool: - cursor = self._get_cursor() - try: - cursor.execute( - "UPDATE catering_menu_items SET name = ?, additional_info = ?, price = ?, category = ?, is_disabled = ? WHERE catering_menu_item_id = ?;", - (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 - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.update_menu_item(updated_item) - except Exception as e: - logger.warning(f"Error updating menu item: {e}") - return False - return bool(affected_rows) - - def add_new_order(self, menu_items: CateringMenuItemsWithAmount, user_id: int, is_delivery: bool) -> Optional[CateringOrder]: - now = datetime.now() - cursor = self._get_cursor() - try: - cursor.execute( - "INSERT INTO orders (status, user, is_delivery, order_date) VALUES (?, ?, ?, ?);", - (CateringOrderStatus.RECEIVED.value, user_id, is_delivery, now) - ) - order_id = cursor.lastrowid - for menu_item, quantity in menu_items.items(): - cursor.execute( - "INSERT INTO order_catering_menu_item (order_id, catering_menu_item_id, quantity) VALUES (?, ?, ?);", - (order_id, menu_item.item_id, quantity) + return CateringMenuItem( + item_id=cursor.lastrowid, + name=name, + additional_info=info, + price=price, + category=category, + is_disabled=is_disabled ) - self._connection.commit() - return CateringOrder( - order_id=order_id, - order_date=now, - status=CateringOrderStatus.RECEIVED, - items=menu_items, - customer=self.get_user_by_id(user_id), - is_delivery=is_delivery - ) - except mariadb.InterfaceError: - self.establish_new_connection() - return self.add_new_order(menu_items, user_id, is_delivery) - except Exception as e: - logger.warning(f"Error placing order: {e}") - return - def change_order_status(self, order_id: int, status: CateringOrderStatus) -> bool: - cursor = self._get_cursor() - try: - cursor.execute( - "UPDATE orders SET status = ? WHERE order_id = ?;", - (status.value, order_id) - ) - affected_rows = cursor.rowcount - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.change_order_status(order_id, status) - except Exception as e: - logger.warning(f"Error updating menu item: {e}") - return False - return bool(affected_rows) + async def delete_menu_item(self, menu_item_id: int) -> bool: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute("DELETE FROM catering_menu_items WHERE catering_menu_item_id = %s;", (menu_item_id,)) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.delete_menu_item(menu_item_id) + except Exception as e: + logger.warning(f"Error deleting menu item: {e}") + return False + return cursor.affected_rows > 0 - def get_orders(self, user_id: Optional[int] = None, status: Optional[CateringOrderStatus] = None) -> list[CateringOrder]: - fetched_orders = [] - query = "SELECT * FROM orders LEFT JOIN users ON orders.user = users.user_id" - if user_id is not None and status is None: - query += f" WHERE user = {user_id};" - elif status is not None and user_id is None: - query += f" WHERE status = '{status.value}';" - elif status is not None and user_id is not None: - query += f" WHERE user = {user_id} AND status = '{status.value}';" - else: - query += ";" - cursor = self._get_cursor() - try: - cursor.execute(query) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_orders(user_id, status) - except Exception as e: - logger.warning(f"Error getting orders: {e}") - return fetched_orders + async def update_menu_item(self, updated_item: CateringMenuItem) -> bool: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "UPDATE catering_menu_items SET name = %s, additional_info = %s, price = %s, category = %s, is_disabled = %s WHERE catering_menu_item_id = %s;", + (updated_item.name, updated_item.additional_info, updated_item.price, updated_item.category.value, updated_item.is_disabled, updated_item.item_id) + ) + affected_rows = cursor.rowcount + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.update_menu_item(updated_item) + except Exception as e: + logger.warning(f"Error updating menu item: {e}") + return False + return affected_rows > 0 - for raw_order in cursor.fetchall(): - fetched_orders.append( - CateringOrder( - order_id=raw_order[0], - status=CateringOrderStatus(raw_order[1]), - customer=self._map_db_result_to_user(raw_order[5:]), - items=self.get_menu_items_for_order(raw_order[0]), - is_delivery=bool(raw_order[4]), - order_date=raw_order[3], - ) - ) + async def add_new_order(self, menu_items: CateringMenuItemsWithAmount, user_id: int, is_delivery: bool) -> Optional[CateringOrder]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + now = datetime.now() + try: + await cursor.execute( + "INSERT INTO orders (status, user, is_delivery, order_date) VALUES (%s, %s, %s, %s);", + (CateringOrderStatus.RECEIVED.value, user_id, is_delivery, now) + ) + order_id = cursor.lastrowid + for menu_item, quantity in menu_items.items(): + await cursor.execute( + "INSERT INTO order_catering_menu_item (order_id, catering_menu_item_id, quantity) VALUES (%s, %s, %s);", + (order_id, menu_item.item_id, quantity) + ) + await conn.commit() + return CateringOrder( + order_id=order_id, + order_date=now, + status=CateringOrderStatus.RECEIVED, + items=menu_items, + customer=await self.get_user_by_id(user_id), + is_delivery=is_delivery + ) + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.add_new_order(menu_items, user_id, is_delivery) + except Exception as e: + logger.warning(f"Error placing order: {e}") + return - return fetched_orders + async def change_order_status(self, order_id: int, status: CateringOrderStatus) -> bool: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "UPDATE orders SET status = %s WHERE order_id = %s;", + (status.value, order_id) + ) + affected_rows = cursor.rowcount + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.change_order_status(order_id, status) + except Exception as e: + logger.warning(f"Error updating menu item: {e}") + return False + return affected_rows > 0 - def get_menu_items_for_order(self, order_id: int) -> CateringMenuItemsWithAmount: - cursor = self._get_cursor() - result = {} - try: - cursor.execute( - "SELECT * FROM order_catering_menu_item " - "LEFT JOIN catering_menu_items ON order_catering_menu_item.catering_menu_item_id = catering_menu_items.catering_menu_item_id " - "WHERE order_id = ?;", - (order_id, ) - ) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_menu_items_for_order(order_id) - except Exception as e: - logger.warning(f"Error getting order items: {e}") - return result + async def get_orders(self, user_id: Optional[int] = None, status: Optional[CateringOrderStatus] = None) -> list[CateringOrder]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + fetched_orders = [] + query = "SELECT * FROM orders LEFT JOIN users ON orders.user = users.user_id" + if user_id is not None and status is None: + query += f" WHERE user = {user_id};" + elif status is not None and user_id is None: + query += f" WHERE status = '{status.value}';" + elif status is not None and user_id is not None: + query += f" WHERE user = {user_id} AND status = '{status.value}';" + else: + query += ";" + try: + await cursor.execute(query) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_orders(user_id, status) + except Exception as e: + logger.warning(f"Error getting orders: {e}") + return fetched_orders - for order_catering_menu_item_raw in cursor.fetchall(): - result[CateringMenuItem( - item_id=order_catering_menu_item_raw[1], - name=order_catering_menu_item_raw[4], - additional_info=order_catering_menu_item_raw[5], - price=order_catering_menu_item_raw[6], - category=CateringMenuItemCategory(order_catering_menu_item_raw[7]), - is_disabled=bool(order_catering_menu_item_raw[8]) - )] = order_catering_menu_item_raw[2] + for raw_order in await cursor.fetchall(): + fetched_orders.append( + CateringOrder( + order_id=raw_order[0], + status=CateringOrderStatus(raw_order[1]), + customer=self._map_db_result_to_user(raw_order[5:]), + items=await self.get_menu_items_for_order(raw_order[0]), + is_delivery=bool(raw_order[4]), + order_date=raw_order[3], + ) + ) - return result + return fetched_orders - def set_user_profile_picture(self, user_id: int, picture_data: bytes) -> None: - cursor = self._get_cursor() - try: - cursor.execute( - "INSERT INTO user_profile_picture (user_id, picture) VALUES (?, ?) ON DUPLICATE KEY UPDATE picture = VALUES(picture)", - (user_id, picture_data) - ) - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.set_user_profile_picture(user_id, picture_data) - except Exception as e: - logger.warning(f"Error setting user profile picture: {e}") + async def get_menu_items_for_order(self, order_id: int) -> CateringMenuItemsWithAmount: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + result = {} + try: + await cursor.execute( + "SELECT * FROM order_catering_menu_item " + "LEFT JOIN catering_menu_items ON order_catering_menu_item.catering_menu_item_id = catering_menu_items.catering_menu_item_id " + "WHERE order_id = %s;", + (order_id, ) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_menu_items_for_order(order_id) + except Exception as e: + logger.warning(f"Error getting order items: {e}") + return result - def get_user_profile_picture(self, user_id: int) -> Optional[bytes]: - cursor = self._get_cursor() - try: - cursor.execute("SELECT (picture) FROM user_profile_picture WHERE user_id = ?", (user_id, )) - self._connection.commit() - r = cursor.fetchone() - if r is None: - return - return r[0] - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_user_profile_picture(user_id) - except Exception as e: - logger.warning(f"Error setting user profile picture: {e}") - return None + for order_catering_menu_item_raw in await cursor.fetchall(): + result[CateringMenuItem( + item_id=order_catering_menu_item_raw[1], + name=order_catering_menu_item_raw[4], + additional_info=order_catering_menu_item_raw[5], + price=order_catering_menu_item_raw[6], + category=CateringMenuItemCategory(order_catering_menu_item_raw[7]), + is_disabled=bool(order_catering_menu_item_raw[8]) + )] = order_catering_menu_item_raw[2] - def get_all_users(self) -> list[User]: - results = [] - cursor = self._get_cursor() - try: - cursor.execute("SELECT * FROM users;") - self._connection.commit() - except mariadb.InterfaceError: - self.establish_new_connection() - return self.get_all_users() - except Exception as e: - logger.warning(f"Error getting all users: {e}") - return results + return result - for user_raw in cursor.fetchall(): - results.append(self._map_db_result_to_user(user_raw)) + async def set_user_profile_picture(self, user_id: int, picture_data: bytes) -> None: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "INSERT INTO user_profile_picture (user_id, picture) VALUES (%s, %s) ON DUPLICATE KEY UPDATE picture = VALUES(picture)", + (user_id, picture_data) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.set_user_profile_picture(user_id, picture_data) + except Exception as e: + logger.warning(f"Error setting user profile picture: {e}") + + async def get_user_profile_picture(self, user_id: int) -> Optional[bytes]: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute("SELECT (picture) FROM user_profile_picture WHERE user_id = %s", (user_id, )) + await conn.commit() + r = await cursor.fetchone() + if r is None: + return + return r[0] + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_user_profile_picture(user_id) + except Exception as e: + logger.warning(f"Error setting user profile picture: {e}") + return None + + async def get_all_users(self) -> list[User]: + + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + results = [] + try: + await cursor.execute("SELECT * FROM users;") + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.get_all_users() + except Exception as e: + logger.warning(f"Error getting all users: {e}") + return results + + for user_raw in await cursor.fetchall(): + results.append(self._map_db_result_to_user(user_raw)) return results diff --git a/src/ez_lan_manager/services/NewsService.py b/src/ez_lan_manager/services/NewsService.py index 14ab77b..6c829a5 100644 --- a/src/ez_lan_manager/services/NewsService.py +++ b/src/ez_lan_manager/services/NewsService.py @@ -1,5 +1,5 @@ import logging -from datetime import date, datetime +from datetime import date from typing import Optional from src.ez_lan_manager.services.DatabaseService import DatabaseService @@ -11,21 +11,22 @@ class NewsService: def __init__(self, db_service: DatabaseService) -> None: self._db_service = db_service - def add_news(self, news: News) -> None: + async def add_news(self, news: News) -> None: if news.news_id is not None: logger.warning("Can not add news with ID, ignoring...") return - self._db_service.add_news(news) + await self._db_service.add_news(news) - def get_news(self, dt_start: Optional[date] = None, dt_end: Optional[date] = None) -> list[News]: + async def get_news(self, dt_start: Optional[date] = None, dt_end: Optional[date] = None) -> list[News]: if not dt_end: dt_end = date.today() if not dt_start: dt_start = date(1900, 1, 1) - return self._db_service.get_news(dt_start, dt_end) + return await self._db_service.get_news(dt_start, dt_end) - def get_latest_news(self) -> Optional[News]: + async def get_latest_news(self) -> Optional[News]: try: - return self.get_news(None, date.today())[0] + all_news = await self.get_news(None, date.today()) + return all_news[0] except IndexError: logger.debug("There are no news to fetch") diff --git a/src/ez_lan_manager/services/SeatingService.py b/src/ez_lan_manager/services/SeatingService.py index 6279df0..ee80b3e 100644 --- a/src/ez_lan_manager/services/SeatingService.py +++ b/src/ez_lan_manager/services/SeatingService.py @@ -36,27 +36,27 @@ class SeatingService: ElementTree.parse(self._seating_configuration.base_svg_path).write(self._seating_plan, encoding="unicode") - def get_seating(self) -> list[Seat]: - return self._db_service.get_seating_info() + async def get_seating(self) -> list[Seat]: + return await self._db_service.get_seating_info() - def get_seat(self, seat_id: str, cached_data: Optional[list[Seat]] = None) -> Optional[Seat]: - all_seats = self.get_seating() if not cached_data else cached_data + async def get_seat(self, seat_id: str, cached_data: Optional[list[Seat]] = None) -> Optional[Seat]: + all_seats = await self.get_seating() if not cached_data else cached_data for seat in all_seats: if seat.seat_id == seat_id: return seat - def get_user_seat(self, user_id: int) -> Optional[Seat]: - all_seats = self.get_seating() + async def get_user_seat(self, user_id: int) -> Optional[Seat]: + all_seats = await self.get_seating() for seat in all_seats: if seat.user and seat.user.user_id == user_id: return seat - def seat_user(self, user_id: int, seat_id: str) -> None: - user_ticket = self._ticketing_service.get_user_ticket(user_id) + async def seat_user(self, user_id: int, seat_id: str) -> None: + user_ticket = await self._ticketing_service.get_user_ticket(user_id) if not user_ticket: raise NoTicketError - seat = self.get_seat(seat_id) + seat = await self.get_seat(seat_id) if not seat: raise SeatNotFoundError @@ -66,10 +66,10 @@ class SeatingService: if seat.user is not None: raise SeatAlreadyTakenError - self._db_service.seat_user(seat_id, user_id) - self.update_svg_with_seating_status() + await self._db_service.seat_user(seat_id, user_id) + await self.update_svg_with_seating_status() - def generate_new_seating_table(self, seating_plan_fp: Path, no_confirm: bool = False) -> None: + async def generate_new_seating_table(self, seating_plan_fp: Path, no_confirm: bool = False) -> None: if not no_confirm: confirm = input("WARNING: THIS ACTION WILL DELETE ALL SEATING DATA! TYPE 'AGREE' TO CONTINUE: ") if confirm != "AGREE": @@ -95,10 +95,10 @@ class SeatingService: except TypeError: continue - self._db_service.generate_fresh_seats_table(sorted(seat_ids, key=lambda sd: sd[0])) - self.update_svg_with_seating_status() + await self._db_service.generate_fresh_seats_table(sorted(seat_ids, key=lambda sd: sd[0])) + await self.update_svg_with_seating_status() - def update_svg_with_seating_status(self) -> None: + async def update_svg_with_seating_status(self) -> None: et = ElementTree.parse(self._seating_configuration.base_svg_path) root = et.getroot() namespace = {'svg': root.tag.split('}')[0].strip('{')} if '}' in root.tag else {} @@ -113,13 +113,13 @@ class SeatingService: rect_g_pairs.append((last_rect, elem)) last_rect = None - all_seats = self.get_seating() + all_seats = await self.get_seating() for rect, g in rect_g_pairs: seat_id = self.get_seat_id_from_element(g, namespace) if not seat_id: continue - seat = self.get_seat(seat_id, cached_data=all_seats) + seat = await self.get_seat(seat_id, cached_data=all_seats) if not seat.is_blocked and seat.user is None: rect.set("fill", "rgb(102, 255, 51)") elif not seat.is_blocked and seat.user is not None: diff --git a/src/ez_lan_manager/services/TicketingService.py b/src/ez_lan_manager/services/TicketingService.py index 13975cb..3502ff2 100644 --- a/src/ez_lan_manager/services/TicketingService.py +++ b/src/ez_lan_manager/services/TicketingService.py @@ -21,30 +21,30 @@ class TicketingService: self._db_service = db_service self._accounting_service = accounting_service - def get_total_tickets(self) -> int: + async def get_total_tickets(self) -> int: return sum([self._lan_info.ticket_info.get_available_tickets(c) for c in self._lan_info.ticket_info.categories]) - def get_available_tickets(self) -> dict[str, int]: + async def get_available_tickets(self) -> dict[str, int]: result = self._lan_info.ticket_info.total_available_tickets - all_tickets = self._db_service.get_tickets() + all_tickets = await self._db_service.get_tickets() for ticket in all_tickets: result[ticket.category] -= 1 return result - def purchase_ticket(self, user_id: int, category: str) -> Ticket: - if category not in self._lan_info.ticket_info.categories or self.get_available_tickets()[category] < 1: + async def purchase_ticket(self, user_id: int, category: str) -> Ticket: + if category not in self._lan_info.ticket_info.categories or (await self.get_available_tickets())[category] < 1: raise TicketNotAvailableError(category) - user_balance = self._accounting_service.get_balance(user_id) + user_balance = await self._accounting_service.get_balance(user_id) if self._lan_info.ticket_info.get_price(category) > user_balance: raise InsufficientFundsError if self.get_user_ticket(user_id): raise UserAlreadyHasTicketError - if new_ticket := self._db_service.generate_ticket_for_user(user_id, category): - self._accounting_service.remove_balance( + if new_ticket := await self._db_service.generate_ticket_for_user(user_id, category): + await self._accounting_service.remove_balance( user_id, self._lan_info.ticket_info.get_price(new_ticket.category), f"TICKET {new_ticket.ticket_id}" @@ -54,20 +54,20 @@ class TicketingService: raise RuntimeError("An unknown error occurred while purchasing ticket") - def refund_ticket(self, user_id: int) -> bool: - user_ticket = self.get_user_ticket(user_id) + async def refund_ticket(self, user_id: int) -> bool: + user_ticket = await self.get_user_ticket(user_id) if not user_ticket: return False if self._db_service.delete_ticket(user_ticket.ticket_id): - self._accounting_service.add_balance(user_id, self._lan_info.ticket_info.get_price(user_ticket.category), f"TICKET REFUND {user_ticket.ticket_id}") + await self._accounting_service.add_balance(user_id, self._lan_info.ticket_info.get_price(user_ticket.category), f"TICKET REFUND {user_ticket.ticket_id}") logger.debug(f"User {user_id} refunded ticket {user_ticket.ticket_id}") return True return False - def transfer_ticket(self, ticket_id: int, user_id: int) -> bool: - return self._db_service.change_ticket_owner(ticket_id, user_id) + async def transfer_ticket(self, ticket_id: int, user_id: int) -> bool: + return await self._db_service.change_ticket_owner(ticket_id, user_id) - def get_user_ticket(self, user_id: int) -> Optional[Ticket]: - return self._db_service.get_ticket_for_user(user_id) + async def get_user_ticket(self, user_id: int) -> Optional[Ticket]: + return await self._db_service.get_ticket_for_user(user_id) diff --git a/src/ez_lan_manager/services/UserService.py b/src/ez_lan_manager/services/UserService.py index d24f213..2120ae4 100644 --- a/src/ez_lan_manager/services/UserService.py +++ b/src/ez_lan_manager/services/UserService.py @@ -17,26 +17,26 @@ class UserService: def __init__(self, db_service: DatabaseService) -> None: self._db_service = db_service - def get_all_users(self) -> list[User]: - return self._db_service.get_all_users() + async def get_all_users(self) -> list[User]: + return await self._db_service.get_all_users() - def get_user(self, accessor: Optional[Union[str, int]]) -> Optional[User]: + async def get_user(self, accessor: Optional[Union[str, int]]) -> Optional[User]: if accessor is None: return if isinstance(accessor, int): - return self._db_service.get_user_by_id(accessor) + return await self._db_service.get_user_by_id(accessor) accessor = accessor.lower() if "@" in accessor: - return self._db_service.get_user_by_mail(accessor) - return self._db_service.get_user_by_name(accessor) + return await self._db_service.get_user_by_mail(accessor) + return await self._db_service.get_user_by_name(accessor) - def set_profile_picture(self, user_id: int, picture: bytes) -> None: - self._db_service.set_user_profile_picture(user_id, picture) + async def set_profile_picture(self, user_id: int, picture: bytes) -> None: + await self._db_service.set_user_profile_picture(user_id, picture) - def get_profile_picture(self, user_id: int) -> bytes: - return self._db_service.get_user_profile_picture(user_id) + async def get_profile_picture(self, user_id: int) -> bytes: + return await self._db_service.get_user_profile_picture(user_id) - def create_user(self, user_name: str, user_mail: str, password_clear_text: str) -> User: + async def create_user(self, user_name: str, user_mail: str, password_clear_text: str) -> User: disallowed_char = self._check_for_disallowed_char(user_name) if disallowed_char: raise NameNotAllowedError(disallowed_char) @@ -44,17 +44,17 @@ class UserService: user_name = user_name.lower() hashed_pw = sha256(password_clear_text.encode(encoding="utf-8")).hexdigest() - return self._db_service.create_user(user_name, user_mail, hashed_pw) + return await self._db_service.create_user(user_name, user_mail, hashed_pw) - def update_user(self, user: User) -> User: + async def update_user(self, user: User) -> User: disallowed_char = self._check_for_disallowed_char(user.user_name) if disallowed_char: raise NameNotAllowedError(disallowed_char) user.user_name = user.user_name.lower() - return self._db_service.update_user(user) + return await self._db_service.update_user(user) - def is_login_valid(self, user_name_or_mail: str, password_clear_text: str) -> bool: - user = self.get_user(user_name_or_mail) + async def is_login_valid(self, user_name_or_mail: str, password_clear_text: str) -> bool: + user = await self.get_user(user_name_or_mail) if not user: return False return user.user_password == sha256(password_clear_text.encode(encoding="utf-8")).hexdigest() diff --git a/src/ez_lan_manager/types/User.py b/src/ez_lan_manager/types/User.py index 164abac..a397962 100644 --- a/src/ez_lan_manager/types/User.py +++ b/src/ez_lan_manager/types/User.py @@ -17,3 +17,6 @@ class User: is_admin: bool created_at: datetime last_updated_at: datetime + + def __hash__(self) -> int: + return hash(f"{self.user_id}{self.user_name}{self.user_mail}") -- 2.45.2 From 1ca7db64278cb46df36e80f49fcf6dff820396d8 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 3 Sep 2024 17:12:36 +0200 Subject: [PATCH 61/85] fix bugs, implement placing orders --- .../components/ShoppingCartAndOrders.py | 66 +++++++++++++++++-- src/ez_lan_manager/pages/CateringPage.py | 29 ++++---- .../services/CateringService.py | 17 +++-- src/ez_lan_manager/types/CateringOrder.py | 5 +- 4 files changed, 93 insertions(+), 24 deletions(-) diff --git a/src/ez_lan_manager/components/ShoppingCartAndOrders.py b/src/ez_lan_manager/components/ShoppingCartAndOrders.py index b07d10a..afd7f60 100644 --- a/src/ez_lan_manager/components/ShoppingCartAndOrders.py +++ b/src/ez_lan_manager/components/ShoppingCartAndOrders.py @@ -1,19 +1,24 @@ -from typing import Optional +from asyncio import sleep, create_task import rio -from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer +from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer, Popup, PopupOpenOrCloseEvent from src.ez_lan_manager.components.CateringCartItem import CateringCartItem from src.ez_lan_manager.components.CateringOrderItem import CateringOrderItem from src.ez_lan_manager.services.AccountingService import AccountingService -from src.ez_lan_manager.services.CateringService import CateringService -from src.ez_lan_manager.types.CateringOrder import CateringOrder +from src.ez_lan_manager.services.CateringService import CateringService, CateringError, CateringErrorType +from src.ez_lan_manager.types.CateringOrder import CateringOrder, CateringMenuItemsWithAmount from src.ez_lan_manager.types.SessionStorage import SessionStorage +POPUP_CLOSE_TIMEOUT_SECONDS = 3 class ShoppingCartAndOrders(Component): show_cart: bool = True orders: list[CateringOrder] = [] + order_button_loading: bool = False + popup_message: str = "" + popup_is_shown: bool = False + popup_is_error: bool = True async def switch(self) -> None: self.show_cart = not self.show_cart @@ -40,15 +45,53 @@ class ShoppingCartAndOrders(Component): if not user_id: return cart = catering_service.get_cart(user_id) - cart.append(await catering_service.get_menu_item_by_id(article_id)) + item_to_add = await catering_service.get_menu_item_by_id(article_id) + cart.append(item_to_add) catering_service.save_cart(user_id, cart) await self.force_refresh() + async def show_popup(self, text: str, is_error: bool) -> None: + self.popup_is_error = is_error + self.popup_message = text + self.popup_is_shown = True + await self.force_refresh() + await sleep(POPUP_CLOSE_TIMEOUT_SECONDS) + self.popup_is_shown = False + await self.force_refresh() + + async def on_order_pressed(self) -> None: + self.order_button_loading = True + await self.force_refresh() + + user_id = self.session[SessionStorage].user_id + cart = self.session[CateringService].get_cart(user_id) + if len(cart) < 1: + _ = create_task(self.show_popup("Warenkorb leer", True)) + else: + items_with_amounts: CateringMenuItemsWithAmount = {} + for item in cart: + try: + items_with_amounts[item] += 1 + except KeyError: + items_with_amounts[item] = 1 + try: + await self.session[CateringService].place_order(items_with_amounts, user_id) + except CateringError as catering_error: + if catering_error.error_type == CateringErrorType.INCLUDES_DISABLED_ITEM: + _ = create_task(self.show_popup("Warenkorb enthält gesperrte Artikel", True)) + elif catering_error.error_type == CateringErrorType.INSUFFICIENT_FUNDS: + _ = create_task(self.show_popup("Guthaben nicht ausreichend", True)) + else: + _ = create_task(self.show_popup("Unbekannter Fehler", True)) + self.session[CateringService].save_cart(self.session[SessionStorage].user_id, []) + self.order_button_loading = False + _ = create_task(self.show_popup("Bestellung erfolgreich aufgegeben!", False)) + def build(self) -> rio.Component: user_id = self.session[SessionStorage].user_id catering_service = self.session[CateringService] + cart = catering_service.get_cart(user_id) if self.show_cart: - cart = catering_service.get_cart(user_id) cart_container = ScrollContainer( content=Column( *[CateringCartItem( @@ -66,6 +109,13 @@ class ShoppingCartAndOrders(Component): ) return Column( cart_container, + Popup( + anchor=cart_container, + content=Text(self.popup_message, style=TextStyle(fill=self.session.theme.danger_color if self.popup_is_error else self.session.theme.success_color), wrap=True, margin=2, justify="center", min_width=20), + is_open=self.popup_is_shown, + position="center", + color=self.session.theme.primary_color + ), Row( Text( text=f"Preis: {AccountingService.make_euro_string_from_int(sum(cart_item.price for cart_item in cart))}", @@ -98,7 +148,9 @@ class ShoppingCartAndOrders(Component): margin_left=0, shape="rectangle", style="major", - color="primary" + color="primary", + on_press=self.on_order_pressed, + is_loading=self.order_button_loading ) ) ) diff --git a/src/ez_lan_manager/pages/CateringPage.py b/src/ez_lan_manager/pages/CateringPage.py index ee87e56..f76bcd2 100644 --- a/src/ez_lan_manager/pages/CateringPage.py +++ b/src/ez_lan_manager/pages/CateringPage.py @@ -14,6 +14,7 @@ from src.ez_lan_manager.types.SessionStorage import SessionStorage class CateringPage(Component): show_cart = True all_menu_items: Optional[list[CateringMenuItem]] = None + shopping_cart_and_orders: list[ShoppingCartAndOrders] = [] def __post_init__(self) -> None: self.session[SessionStorage].subscribe_to_logged_in_or_out_event(self.__class__.__name__, self.on_user_logged_in_status_changed) @@ -28,7 +29,7 @@ class CateringPage(Component): await self.force_refresh() async def on_switcher_bar_changed(self, _: SwitcherBarChangeEvent) -> None: - await self.shopping_cart_and_orders.switch() + await self.shopping_cart_and_orders[0].switch() @staticmethod def get_menu_items_by_category(all_menu_items: list[CateringMenuItem], category: Optional[CateringMenuItemCategory]) -> list[CateringMenuItem]: @@ -37,7 +38,11 @@ class CateringPage(Component): def build(self) -> Component: user_id = self.session[SessionStorage].user_id - self.shopping_cart_and_orders = ShoppingCartAndOrders() + if len(self.shopping_cart_and_orders) == 0: + self.shopping_cart_and_orders.append(ShoppingCartAndOrders()) + if len(self.shopping_cart_and_orders) > 1: + self.shopping_cart_and_orders.clear() + self.shopping_cart_and_orders.append(ShoppingCartAndOrders()) switcher_bar = SwitcherBar( values=["cart", "orders"], names=["Warenkorb", "Bestellungen"], @@ -63,7 +68,7 @@ class CateringPage(Component): align_x=0.5 ), switcher_bar, - self.shopping_cart_and_orders + self.shopping_cart_and_orders[0] ) ) if user_id else Spacer() @@ -82,7 +87,7 @@ class CateringPage(Component): article_name=catering_menu_item.name, article_price=catering_menu_item.price, article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders.on_add_item, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 @@ -104,7 +109,7 @@ class CateringPage(Component): article_name=catering_menu_item.name, article_price=catering_menu_item.price, article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders.on_add_item, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 @@ -126,7 +131,7 @@ class CateringPage(Component): article_name=catering_menu_item.name, article_price=catering_menu_item.price, article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders.on_add_item, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 @@ -148,7 +153,7 @@ class CateringPage(Component): article_name=catering_menu_item.name, article_price=catering_menu_item.price, article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders.on_add_item, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 @@ -170,7 +175,7 @@ class CateringPage(Component): article_name=catering_menu_item.name, article_price=catering_menu_item.price, article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders.on_add_item, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 @@ -192,7 +197,7 @@ class CateringPage(Component): article_name=catering_menu_item.name, article_price=catering_menu_item.price, article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders.on_add_item, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 @@ -214,7 +219,7 @@ class CateringPage(Component): article_name=catering_menu_item.name, article_price=catering_menu_item.price, article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders.on_add_item, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 @@ -236,7 +241,7 @@ class CateringPage(Component): article_name=catering_menu_item.name, article_price=catering_menu_item.price, article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders.on_add_item, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 @@ -258,7 +263,7 @@ class CateringPage(Component): article_name=catering_menu_item.name, article_price=catering_menu_item.price, article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders.on_add_item, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, additional_info=catering_menu_item.additional_info, is_grey=idx % 2 == 0 diff --git a/src/ez_lan_manager/services/CateringService.py b/src/ez_lan_manager/services/CateringService.py index 5c4853a..25e6a72 100644 --- a/src/ez_lan_manager/services/CateringService.py +++ b/src/ez_lan_manager/services/CateringService.py @@ -1,4 +1,5 @@ import logging +from enum import Enum from typing import Optional from src.ez_lan_manager.services.AccountingService import AccountingService @@ -9,9 +10,15 @@ 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) -> None: + def __init__(self, message: str, error_type: CateringErrorType = CateringErrorType.GENERIC) -> None: self.message = message + self.error_type = error_type class CateringService: @@ -26,7 +33,7 @@ class CateringService: 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") + raise CateringError("Order includes disabled items", CateringErrorType.INCLUDES_DISABLED_ITEM) user = await self._user_service.get_user(user_id) if not user: @@ -34,12 +41,13 @@ class CateringService: total_price = sum([item.price * quantity for item, quantity in menu_items.items()]) if await self._accounting_service.get_balance(user_id) < total_price: - raise CateringError("Insufficient funds") + raise CateringError("Insufficient funds", CateringErrorType.INSUFFICIENT_FUNDS) order = await self._db_service.add_new_order(menu_items, user_id, is_delivery) if order: await self._accounting_service.remove_balance(user_id, total_price, f"CATERING - {order.order_id}") logger.info(f"User '{order.customer.user_name}' (ID:{order.customer.user_id}) ordered from catering for {self._accounting_service.make_euro_string_from_int(total_price)}") + # await self.cancel_order(order) # ToDo: Check if commented out before commit. Un-comment to auto-cancel every placed order return order async def update_order_status(self, order_id: int, new_status: CateringOrderStatus) -> bool: @@ -58,7 +66,8 @@ class CateringService: return await self._db_service.get_orders(status=status) async def cancel_order(self, order: CateringOrder) -> bool: - if self._db_service.change_order_status(order.order_id, CateringOrderStatus.CANCELED): + change_result = await self._db_service.change_order_status(order.order_id, CateringOrderStatus.CANCELED) + if change_result: await self._accounting_service.add_balance(order.customer.user_id, order.price, f"CATERING REFUND - {order.order_id}") return True return False diff --git a/src/ez_lan_manager/types/CateringOrder.py b/src/ez_lan_manager/types/CateringOrder.py index 54aad54..1901e21 100644 --- a/src/ez_lan_manager/types/CateringOrder.py +++ b/src/ez_lan_manager/types/CateringOrder.py @@ -27,4 +27,7 @@ class CateringOrder: @property def price(self) -> int: - return sum([item.price for item in self.items.keys()]) + total = 0 + for item, amount in self.items.items(): + total += (item.price * amount) + return total -- 2.45.2 From eb7d94d46c9e129b333ba4cb94b17a4b3a6a86ea Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Tue, 3 Sep 2024 17:33:37 +0200 Subject: [PATCH 62/85] refactor DB health-check --- src/ez_lan_manager/pages/BasePage.py | 8 +++++++- src/ez_lan_manager/pages/DbErrorPage.py | 12 ++++++------ src/ez_lan_manager/services/DatabaseService.py | 14 +++++++++++++- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/ez_lan_manager/pages/BasePage.py b/src/ez_lan_manager/pages/BasePage.py index f179ad3..1004b20 100644 --- a/src/ez_lan_manager/pages/BasePage.py +++ b/src/ez_lan_manager/pages/BasePage.py @@ -4,12 +4,18 @@ from typing import * # type: ignore from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text -from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager import ConfigurationService, DatabaseService from src.ez_lan_manager.components.DesktopNavigation import DesktopNavigation class BasePage(Component): content: Component + @event.periodic(5) + async def check_db_conn(self) -> None: + is_healthy = await self.session[DatabaseService].is_healthy() + if not is_healthy: + self.session.navigate_to("./db-error") + @event.on_window_size_change async def on_window_size_change(self): await self.force_refresh() diff --git a/src/ez_lan_manager/pages/DbErrorPage.py b/src/ez_lan_manager/pages/DbErrorPage.py index f3da2a3..2f5a125 100644 --- a/src/ez_lan_manager/pages/DbErrorPage.py +++ b/src/ez_lan_manager/pages/DbErrorPage.py @@ -15,12 +15,12 @@ class DbErrorPage(Component): async def on_window_size_change(self) -> None: await self.force_refresh() - # @event.on_mount - # async def retry_db_connect(self) -> None: - # await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Fehler") - # while not self.session[DatabaseService].is_connected: - # await sleep(2) - # self.session.navigate_to("./") + @event.on_mount + async def retry_db_connect(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Fehler") + while not await self.session[DatabaseService].is_healthy(): + await sleep(2) + self.session.navigate_to("./") def build(self) -> Component: content = Card( diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index 3bca5c4..d32cc95 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -29,6 +29,17 @@ class DatabaseService: self._database_config = database_config self._connection_pool: Optional[aiomysql.Pool] = None + async def is_healthy(self) -> bool: + try: + async with self._connection_pool.acquire() as conn: + async with conn.cursor() as _: + return True + except aiomysql.OperationalError: + return False + except Exception as e: + logger.error(f"Failed to acquire a connection: {e}") + return False + async def init_db_pool(self) -> bool: logger.info( f"Connecting to database '{self._database_config.db_name}' on " @@ -42,7 +53,7 @@ class DatabaseService: password=self._database_config.db_password, db=self._database_config.db_name, minsize=1, - maxsize=20 + maxsize=40 ) except aiomysql.OperationalError: return False @@ -215,6 +226,7 @@ class DatabaseService: except aiomysql.InterfaceError: pool_init_result = await self.init_db_pool() if not pool_init_result: + print(self._connection_pool) raise NoDatabaseConnectionError return await self.get_news(dt_start, dt_end) except Exception as e: -- 2.45.2 From 5ed1230fdea66e30641dd49d5164ea77b89f2dae Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 4 Sep 2024 10:40:57 +0200 Subject: [PATCH 63/85] refactor ticket info config and service --- config/config.example.toml | 19 ++++++-- src/ez_lan_manager/__init__.py | 2 +- .../services/ConfigurationService.py | 25 +++++++---- .../services/TicketingService.py | 43 +++++++++++++------ .../types/ConfigurationTypes.py | 27 +++--------- 5 files changed, 66 insertions(+), 50 deletions(-) diff --git a/config/config.example.toml b/config/config.example.toml index 2b1bf40..b4be508 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -1,9 +1,6 @@ [lan] name="EZ LAN" iteration="0.5" - default_category="NORMAL" - tickets={ "LUXUS" = 40, "NORMAL" = 10 } - prices={ "LUXUS" = 3000, "NORMAL" = 2500 } # Eurocent date_from="2024-10-30 15:00:00" date_till="2024-11-01 12:00:00" organizer_mail="tech@example.com" @@ -25,4 +22,18 @@ [seating] base_svg_path="" -dev_mode_active=false # Supresses E-Mail sending +[tickets] + [tickets."NORMAL"] + total_tickets=30 + price=2500 # Eurocent + description="Normales Ticket" + is_default=true + + [tickets."LUXUS"] + total_tickets=10 + price=4000 # Eurocent + description="Luxus Ticket" + is_default=false + +[misc] + dev_mode_active=true # Supresses E-Mail sending diff --git a/src/ez_lan_manager/__init__.py b/src/ez_lan_manager/__init__.py index 88c136e..6c6712a 100644 --- a/src/ez_lan_manager/__init__.py +++ b/src/ez_lan_manager/__init__.py @@ -23,7 +23,7 @@ def init_services() -> tuple[AccountingService, CateringService, ConfigurationSe accounting_service = AccountingService(db_service) news_service = NewsService(db_service) mailing_service = MailingService(configuration_service) - ticketing_service = TicketingService(configuration_service.get_lan_info(), db_service, accounting_service) + ticketing_service = TicketingService(configuration_service.get_ticket_info(), db_service, accounting_service) seating_service = SeatingService(configuration_service.get_seating_configuration(), configuration_service.get_lan_info(), db_service, ticketing_service) catering_service = CateringService(db_service, accounting_service, user_service) diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py index 0c731e6..f14f52a 100644 --- a/src/ez_lan_manager/services/ConfigurationService.py +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -6,7 +6,7 @@ import tomllib from from_root import from_root -from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration, MailingServiceConfiguration, LanInfo, TicketInfo, SeatingConfiguration +from src.ez_lan_manager.types.ConfigurationTypes import DatabaseConfiguration, MailingServiceConfiguration, LanInfo, SeatingConfiguration, TicketInfo logger = logging.getLogger(__name__.split(".")[-1]) @@ -58,16 +58,9 @@ class ConfigurationService: def get_lan_info(self) -> LanInfo: try: lan_info = self._config["lan"] - ticket_info = TicketInfo( - default_category=lan_info["default_category"], - categories=list(lan_info["tickets"].keys()), - _prices=lan_info["prices"], - _available_tickets=lan_info["tickets"] - ) return LanInfo( name=lan_info["name"], iteration=lan_info["iteration"], - ticket_info=ticket_info, date_from=datetime.strptime(lan_info["date_from"], "%Y-%m-%d %H:%M:%S"), date_till=datetime.strptime(lan_info["date_till"], "%Y-%m-%d %H:%M:%S"), organizer_mail=lan_info["organizer_mail"] @@ -90,10 +83,24 @@ class ConfigurationService: logger.fatal("Error loading seating configuration, exiting...") sys.exit(1) + def get_ticket_info(self) -> tuple[TicketInfo, ...]: + try: + return tuple([TicketInfo( + category=value, + total_tickets=self._config["tickets"][value]["total_tickets"], + price=self._config["tickets"][value]["price"], + description=self._config["tickets"][value]["description"], + is_default=self._config["tickets"][value]["is_default"] + ) for value in self._config["tickets"]]) + except KeyError as e: + logger.debug(e) + logger.fatal("Error loading seating configuration, exiting...") + sys.exit(1) + @property def APP_VERSION(self) -> str: return self._version @property def DEV_MODE_ACTIVE(self) -> bool: - return self._config["dev_mode_active"] + return self._config["misc"]["dev_mode_active"] diff --git a/src/ez_lan_manager/services/TicketingService.py b/src/ez_lan_manager/services/TicketingService.py index 3502ff2..d597e9b 100644 --- a/src/ez_lan_manager/services/TicketingService.py +++ b/src/ez_lan_manager/services/TicketingService.py @@ -3,7 +3,7 @@ from typing import Optional from src.ez_lan_manager.services.AccountingService import AccountingService, InsufficientFundsError from src.ez_lan_manager.services.DatabaseService import DatabaseService -from src.ez_lan_manager.types.ConfigurationTypes import LanInfo +from src.ez_lan_manager.types.ConfigurationTypes import TicketInfo from src.ez_lan_manager.types.Ticket import Ticket logger = logging.getLogger(__name__.split(".")[-1]) @@ -16,37 +16,51 @@ class UserAlreadyHasTicketError(Exception): pass class TicketingService: - def __init__(self, lan_info: LanInfo, db_service: DatabaseService, accounting_service: AccountingService) -> None: - self._lan_info = lan_info + 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 - async def get_total_tickets(self) -> int: - return sum([self._lan_info.ticket_info.get_available_tickets(c) for c in self._lan_info.ticket_info.categories]) + def get_ticket_info_by_category(self, category: str) -> Optional[TicketInfo]: + return next(filter(lambda t: t.category == category, self._ticket_infos), None) + + def get_total_tickets(self) -> int: + return sum([t_i.total_tickets for t_i in self._ticket_infos]) + + async def get_available_tickets_for_category(self, category: str) -> int: + ticket_info = self.get_ticket_info_by_category(category) + if not ticket_info or ticket_info.total_tickets < 1: + return 0 + result = ticket_info.total_tickets - async def get_available_tickets(self) -> dict[str, int]: - result = self._lan_info.ticket_info.total_available_tickets all_tickets = await self._db_service.get_tickets() for ticket in all_tickets: - result[ticket.category] -= 1 + if ticket.category == category: + result -= 1 return result async def purchase_ticket(self, user_id: int, category: str) -> Ticket: - if category not in self._lan_info.ticket_info.categories or (await self.get_available_tickets())[category] < 1: + all_categories = [t_i.category for t_i in self._ticket_infos] + if category not in all_categories or (await self.get_available_tickets_for_category(category)) < 1: raise TicketNotAvailableError(category) user_balance = await self._accounting_service.get_balance(user_id) - if self._lan_info.ticket_info.get_price(category) > user_balance: + + ticket_info = self.get_ticket_info_by_category(category) + if not ticket_info: + raise TicketNotAvailableError(category) + + if ticket_info.price > user_balance: raise InsufficientFundsError - if self.get_user_ticket(user_id): + if await self.get_user_ticket(user_id): raise UserAlreadyHasTicketError if new_ticket := await self._db_service.generate_ticket_for_user(user_id, category): await self._accounting_service.remove_balance( user_id, - self._lan_info.ticket_info.get_price(new_ticket.category), + ticket_info.price, f"TICKET {new_ticket.ticket_id}" ) logger.debug(f"User {user_id} purchased ticket {new_ticket.ticket_id}") @@ -59,8 +73,9 @@ class TicketingService: if not user_ticket: return False - if self._db_service.delete_ticket(user_ticket.ticket_id): - await self._accounting_service.add_balance(user_id, self._lan_info.ticket_info.get_price(user_ticket.category), f"TICKET REFUND {user_ticket.ticket_id}") + ticket_info = self.get_ticket_info_by_category(user_ticket.category) + if await self._db_service.delete_ticket(user_ticket.ticket_id): + await self._accounting_service.add_balance(user_id, ticket_info.price, f"TICKET REFUND {user_ticket.ticket_id}") logger.debug(f"User {user_id} refunded ticket {user_ticket.ticket_id}") return True diff --git a/src/ez_lan_manager/types/ConfigurationTypes.py b/src/ez_lan_manager/types/ConfigurationTypes.py index e1d06d2..9fb073a 100644 --- a/src/ez_lan_manager/types/ConfigurationTypes.py +++ b/src/ez_lan_manager/types/ConfigurationTypes.py @@ -1,4 +1,3 @@ -from copy import copy from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -17,26 +16,11 @@ class DatabaseConfiguration: @dataclass(frozen=True) class TicketInfo: - default_category: str - categories: list[str] - _prices: dict[str, int] - _available_tickets: dict[str, int] - - def get_price(self, category: str) -> int: - try: - return self._prices[category] - except KeyError: - raise NoSuchCategoryError - - def get_available_tickets(self, category: str) -> int: - try: - return self._available_tickets[category] - except KeyError: - raise NoSuchCategoryError - - @property - def total_available_tickets(self): - return copy(self._available_tickets) + category: str + total_tickets: int + price: int + description: str + is_default: bool @dataclass(frozen=True) class MailingServiceConfiguration: @@ -50,7 +34,6 @@ class MailingServiceConfiguration: class LanInfo: name: str iteration: str - ticket_info: TicketInfo date_from: datetime date_till: datetime organizer_mail: str -- 2.45.2 From e20ce6b78b6b908b78e89548e54cc002937627d3 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 4 Sep 2024 12:44:11 +0200 Subject: [PATCH 64/85] add TicketBuying Feature --- config/config.example.toml | 6 +- src/EzLanManager.py | 4 +- .../components/TicketBuyCard.py | 88 ++++++++++++ src/ez_lan_manager/pages/BasePage.py | 2 +- src/ez_lan_manager/pages/BuyTicketPage.py | 127 ++++++++++++++++++ src/ez_lan_manager/pages/SeatingPlanPage.py | 24 ++++ src/ez_lan_manager/pages/__init__.py | 2 + .../services/ConfigurationService.py | 1 + .../types/ConfigurationTypes.py | 1 + 9 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 src/ez_lan_manager/components/TicketBuyCard.py create mode 100644 src/ez_lan_manager/pages/BuyTicketPage.py create mode 100644 src/ez_lan_manager/pages/SeatingPlanPage.py diff --git a/config/config.example.toml b/config/config.example.toml index b4be508..656526c 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -25,14 +25,16 @@ [tickets] [tickets."NORMAL"] total_tickets=30 - price=2500 # Eurocent + price=2500 description="Normales Ticket" + additional_info="Berechtigt zur Nutzung eines regulären Platzes für die gesamte Dauer der LAN" is_default=true [tickets."LUXUS"] total_tickets=10 - price=4000 # Eurocent + price=3500 description="Luxus Ticket" + additional_info="Berechtigt zur Nutzung eines verbesserten Platzes. Dieser ist mit einer höheren Internet-Bandbreite und einem Sitzkissen ausgestattet." is_default=false [misc] diff --git a/src/EzLanManager.py b/src/EzLanManager.py index dbad871..50d7842 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -64,12 +64,12 @@ if __name__ == "__main__": Page( name="BuyTicket", page_url="buy_ticket", - build=lambda: pages.PlaceholderPage(placeholder_name="Tickets kaufen"), + build=pages.BuyTicketPage, ), Page( name="SeatingPlan", page_url="seating", - build=lambda: pages.PlaceholderPage(placeholder_name="Sitzplan"), + build=pages.SeatingPlanPage, ), Page( name="Catering", diff --git a/src/ez_lan_manager/components/TicketBuyCard.py b/src/ez_lan_manager/components/TicketBuyCard.py new file mode 100644 index 0000000..ca5d276 --- /dev/null +++ b/src/ez_lan_manager/components/TicketBuyCard.py @@ -0,0 +1,88 @@ +from functools import partial +from typing import Callable, Optional + +import rio +from rio import Component, Card, Column, Text, Row, Button, TextStyle, ProgressBar, event, Spacer + +from src.ez_lan_manager import TicketingService +from src.ez_lan_manager.services.AccountingService import AccountingService +from src.ez_lan_manager.types.Ticket import Ticket + + +class TicketBuyCard(Component): + description: str + additional_info: str + price: int + category: str + pressed_cb: Callable + is_enabled: bool + total_tickets: int + user_ticket: Optional[Ticket] + available_tickets: int = 0 + + @event.on_populate + async def async_init(self) -> None: + self.available_tickets = await self.session[TicketingService].get_available_tickets_for_category(self.category) + + def build(self) -> rio.Component: + ticket_description_style = TextStyle( + fill=self.session.theme.neutral_color, + font_size=1.2, + ) + ticket_additional_info_style = TextStyle( + fill=self.session.theme.neutral_color, + font_size=0.8 + ) + ticket_owned_style = TextStyle( + fill=self.session.theme.success_color, + font_size=0.8 + ) + + try: + progress = self.available_tickets / self.total_tickets + except ZeroDivisionError: + progress = 0 + progress_bar = ProgressBar( + progress=progress, + color=self.session.theme.success_color if progress > 0.25 else self.session.theme.danger_color, + margin_right=1, + grow_x=True + ) + + tickets_side_text = Text( + f"{self.available_tickets}/{self.total_tickets}", + align_x=1 + ) + + return Card( + Column( + Text(self.description, margin_left=1, margin_top=1, style=ticket_description_style), + Text("Du besitzt dieses Ticket!", margin_left=1, margin_top=1, style=ticket_owned_style) if self.user_ticket is not None and self.user_ticket.category == self.category else Spacer(), + Text(self.additional_info, margin_left=1, margin_top=1, style=ticket_additional_info_style, wrap=True), + Row( + progress_bar, + tickets_side_text, + margin_top=1, + margin_left=1, + margin_right=1 + ), + Row( + Text(f"{AccountingService.make_euro_string_from_int(self.price)}", margin_left=1, margin_top=1, grow_x=True), + Button( + Text("Kaufen", align_x=0.5, margin=0.4), + margin_right=1, + margin_top=1, + style="major", + shape="rounded", + on_press=partial(self.pressed_cb, self.category), + is_sensitive=self.is_enabled + ), + margin_bottom=1 + ) + ), + margin_left=3, + margin_right=3, + margin_bottom=1, + color=self.session.theme.hud_color, + corner_radius=0.2 + ) \ No newline at end of file diff --git a/src/ez_lan_manager/pages/BasePage.py b/src/ez_lan_manager/pages/BasePage.py index 1004b20..ea4e1fd 100644 --- a/src/ez_lan_manager/pages/BasePage.py +++ b/src/ez_lan_manager/pages/BasePage.py @@ -10,7 +10,7 @@ from src.ez_lan_manager.components.DesktopNavigation import DesktopNavigation class BasePage(Component): content: Component - @event.periodic(5) + @event.periodic(60) async def check_db_conn(self) -> None: is_healthy = await self.session[DatabaseService].is_healthy() if not is_healthy: diff --git a/src/ez_lan_manager/pages/BuyTicketPage.py b/src/ez_lan_manager/pages/BuyTicketPage.py new file mode 100644 index 0000000..285f332 --- /dev/null +++ b/src/ez_lan_manager/pages/BuyTicketPage.py @@ -0,0 +1,127 @@ +from asyncio import sleep +from functools import partial +from typing import Optional + +from rio import Text, Column, TextStyle, Component, event, TextInput, MultiLineTextInput, Row, Button, Card, Popup + +from src.ez_lan_manager import ConfigurationService, UserService, MailingService, AccountingService, TicketingService +from src.ez_lan_manager.components.AnimatedText import AnimatedText +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.components.TicketBuyCard import TicketBuyCard +from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.services.AccountingService import InsufficientFundsError +from src.ez_lan_manager.services.TicketingService import TicketNotAvailableError, UserAlreadyHasTicketError +from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.Ticket import Ticket +from src.ez_lan_manager.types.User import User + + +class BuyTicketPage(Component): + user: Optional[User] = None + user_ticket: Optional[Ticket] = None + is_popup_open: bool = False + popup_message: str = "" + is_popup_success: bool = False + is_buying_enabled: bool = False + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Ticket kaufen") + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) + if self.user is None: # No user logged in + self.is_buying_enabled = False + else: # User is logged in + possible_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id) + self.user_ticket = possible_ticket + if possible_ticket is not None: # User already has a ticket + self.is_buying_enabled = False + else: + self.is_buying_enabled = True + + async def on_buy_pressed(self, category: str) -> None: + if not self.user: + return + self.is_buying_enabled = False + await self.force_refresh() + + try: + t_s = self.session[TicketingService] + ticket = await t_s.purchase_ticket(self.user.user_id, category) + self.popup_message = f"Ticket erfolgreich gekauft. Deine Ticket-ID lautet: {ticket.ticket_id}." + self.is_popup_success = True + except TicketNotAvailableError: + self.popup_message = "Das ausgewählte Ticket ist nicht verfügbar." + self.is_popup_success = False + except InsufficientFundsError: + self.popup_message = "Dein Guthaben reicht nicht aus um dieses Ticket zu kaufen." + self.is_popup_success = False + except UserAlreadyHasTicketError: + self.popup_message = (f"Du besitzt bereits ein Ticket. Um dein aktuelles Ticket zu stornieren, kontaktiere bitte den Support unter " + f"{self.session[ConfigurationService].get_lan_info().organizer_mail}.") + self.is_popup_success = False + except RuntimeError: + self.popup_message = "Ein unbekannter Fehler ist aufgetreten." + self.is_popup_success = False + self.is_popup_open = True + await self.on_populate() + + + async def on_popup_close_pressed(self) -> None: + self.is_popup_open = False + self.popup_message = "" + + + def build(self) -> Component: + ticket_infos = self.session[ConfigurationService].get_ticket_info() + header = Text( + "Tickets & Preise", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ) + + return BasePage( + content=Column( + MainViewContentBox( + Column( + header, + Popup( + anchor=header, + content=Column( + Text( + self.popup_message, + style=TextStyle(font_size=1.1, fill=self.session.theme.success_color if self.is_popup_success else self.session.theme.danger_color), + wrap=True, + grow_y=True, + margin=1 + ), + Button("Bestätigen", shape="rounded", grow_y=False, on_press=self.on_popup_close_pressed), + min_width=34, + min_height=10 + ), + is_open=self.is_popup_open, + position="bottom", + margin=1, + corner_radius=0.2, + color=self.session.theme.primary_color + ), + *[TicketBuyCard( + description=t.description, + additional_info=t.additional_info, + price=t.price, + category=t.category, + pressed_cb=self.on_buy_pressed, + is_enabled=self.is_buying_enabled, + total_tickets=t.total_tickets, + user_ticket=self.user_ticket + ) for t in ticket_infos] + ), + ), + align_y=0 + ), + grow_x=True + ) diff --git a/src/ez_lan_manager/pages/SeatingPlanPage.py b/src/ez_lan_manager/pages/SeatingPlanPage.py new file mode 100644 index 0000000..c95a4e1 --- /dev/null +++ b/src/ez_lan_manager/pages/SeatingPlanPage.py @@ -0,0 +1,24 @@ +from rio import Text, Column, TextStyle, Component, event, TextInput, MultiLineTextInput, Row, Button + +from src.ez_lan_manager import ConfigurationService, UserService, MailingService +from src.ez_lan_manager.components.AnimatedText import AnimatedText +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.User import User + + +class SeatingPlanPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Sitzplan") + + def build(self) -> Component: + return BasePage( + content=Column( + MainViewContentBox(), + MainViewContentBox(), + align_y=0 + ), + grow_x=True + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 1950e63..92d6210 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -13,3 +13,5 @@ from .TournamentsPage import TournamentsPage from .GuestsPage import GuestsPage from .CateringPage import CateringPage from .DbErrorPage import DbErrorPage +from .SeatingPlanPage import SeatingPlanPage +from .BuyTicketPage import BuyTicketPage diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py index f14f52a..c648d5b 100644 --- a/src/ez_lan_manager/services/ConfigurationService.py +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -90,6 +90,7 @@ class ConfigurationService: total_tickets=self._config["tickets"][value]["total_tickets"], price=self._config["tickets"][value]["price"], description=self._config["tickets"][value]["description"], + additional_info=self._config["tickets"][value]["additional_info"], is_default=self._config["tickets"][value]["is_default"] ) for value in self._config["tickets"]]) except KeyError as e: diff --git a/src/ez_lan_manager/types/ConfigurationTypes.py b/src/ez_lan_manager/types/ConfigurationTypes.py index 9fb073a..d54e07d 100644 --- a/src/ez_lan_manager/types/ConfigurationTypes.py +++ b/src/ez_lan_manager/types/ConfigurationTypes.py @@ -20,6 +20,7 @@ class TicketInfo: total_tickets: int price: int description: str + additional_info: str is_default: bool @dataclass(frozen=True) -- 2.45.2 From c09071748644e1e40b331629769ada3f5b60d74d Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 4 Sep 2024 16:04:39 +0200 Subject: [PATCH 65/85] WIP: Seating Plan --- src/ez_lan_manager/components/SeatingPlan.py | 124 ++++++++++++++++++ .../components/SeatingPlanPixels.py | 82 ++++++++++++ src/ez_lan_manager/pages/SeatingPlanPage.py | 15 +-- 3 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 src/ez_lan_manager/components/SeatingPlan.py create mode 100644 src/ez_lan_manager/components/SeatingPlanPixels.py diff --git a/src/ez_lan_manager/components/SeatingPlan.py b/src/ez_lan_manager/components/SeatingPlan.py new file mode 100644 index 0000000..54fe842 --- /dev/null +++ b/src/ez_lan_manager/components/SeatingPlan.py @@ -0,0 +1,124 @@ +from rio import Component, Rectangle, Grid + +from src.ez_lan_manager.components.SeatingPlanPixels import SeatPixel, WallPixel, InvisiblePixel, TextPixel + +MAX_GRID_WIDTH_PIXELS = 34 +MAX_GRID_HEIGHT_PIXELS = 45 + + +class SeatingPlan(Component): + """ + This seating plan is for the community center "Bottenhorn" + """ + def build(self) -> Component: + grid = Grid() + # Outlines + for column_id in range(0, MAX_GRID_WIDTH_PIXELS): + grid.add(InvisiblePixel(), row=0, column=column_id) + for y in range(0, 13): + grid.add(WallPixel(), row=y, column=0) + for y in range(13, 19): + grid.add(InvisiblePixel(), row=y, column=0) + for y in range(19, MAX_GRID_HEIGHT_PIXELS): + grid.add(WallPixel(), row=y, column=0) + + # Block A + block_a_margin_left = 12 + block_a_margin_top = 1 + (grid + .add(SeatPixel("A01"), row=block_a_margin_top, column=block_a_margin_left, width=2, height=3) + .add(SeatPixel("A02"), row=block_a_margin_top + 4, column=block_a_margin_left, width=2, height=3) + .add(SeatPixel("A03"), row=block_a_margin_top + 8, column=block_a_margin_left, width=2, height=3) + .add(SeatPixel("A10"), row=block_a_margin_top, column=block_a_margin_left + 3, width=2, height=3) + .add(SeatPixel("A11"), row=block_a_margin_top + 4, column=block_a_margin_left + 3, width=2, height=3) + .add(SeatPixel("A12"), row=block_a_margin_top + 8, column=block_a_margin_left + 3, width=2, height=3) + ) + + # Block B + block_b_margin_left = 20 + block_b_margin_top = 1 + (grid + .add(SeatPixel("B01"), row=block_b_margin_top, column=block_b_margin_left, width=2, height=3) + .add(SeatPixel("B02"), row=block_b_margin_top + 4, column=block_b_margin_left, width=2, height=3) + .add(SeatPixel("B03"), row=block_b_margin_top + 8, column=block_b_margin_left, width=2, height=3) + .add(SeatPixel("B10"), row=block_b_margin_top, column=block_b_margin_left + 3, width=2, height=3) + .add(SeatPixel("B11"), row=block_b_margin_top + 4, column=block_b_margin_left + 3, width=2, height=3) + .add(SeatPixel("B12"), row=block_b_margin_top + 8, column=block_b_margin_left + 3, width=2, height=3) + ) + + # Block C + block_c_margin_left = 28 + block_c_margin_top = 1 + (grid + .add(SeatPixel("C01"), row=block_c_margin_top, column=block_c_margin_left, width=2, height=3) + .add(SeatPixel("C02"), row=block_c_margin_top + 4, column=block_c_margin_left, width=2, height=3) + .add(SeatPixel("C03"), row=block_c_margin_top + 8, column=block_c_margin_left, width=2, height=3) + .add(SeatPixel("C10"), row=block_c_margin_top, column=block_c_margin_left + 3, width=2, height=3) + .add(SeatPixel("C11"), row=block_c_margin_top + 4, column=block_c_margin_left + 3, width=2, height=3) + .add(SeatPixel("C12"), row=block_c_margin_top + 8, column=block_c_margin_left + 3, width=2, height=3) + ) + + # Block D + block_d_margin_left = 20 + block_d_margin_top = 20 + (grid + .add(SeatPixel("D01"), row=block_d_margin_top, column=block_d_margin_left, width=2, height=3) + .add(SeatPixel("D02"), row=block_d_margin_top + 4, column=block_d_margin_left, width=2, height=3) + .add(SeatPixel("D03"), row=block_d_margin_top + 8, column=block_d_margin_left, width=2, height=3) + .add(SeatPixel("D10"), row=block_d_margin_top, column=block_d_margin_left + 3, width=2, height=3) + .add(SeatPixel("D11"), row=block_d_margin_top + 4, column=block_d_margin_left + 3, width=2, height=3) + .add(SeatPixel("D12"), row=block_d_margin_top + 8, column=block_d_margin_left + 3, width=2, height=3) + ) + + # Block E + block_e_margin_left = 28 + block_e_margin_top = 20 + (grid + .add(SeatPixel("E01"), row=block_e_margin_top, column=block_e_margin_left, width=2, height=3) + .add(SeatPixel("E02"), row=block_e_margin_top + 4, column=block_e_margin_left, width=2, height=3) + .add(SeatPixel("E03"), row=block_e_margin_top + 8, column=block_e_margin_left, width=2, height=3) + .add(SeatPixel("E10"), row=block_e_margin_top, column=block_e_margin_left + 3, width=2, height=3) + .add(SeatPixel("E11"), row=block_e_margin_top + 4, column=block_e_margin_left + 3, width=2, height=3) + .add(SeatPixel("E12"), row=block_e_margin_top + 8, column=block_e_margin_left + 3, width=2, height=3) + ) + + # Middle Wall + for y in range(0, 13): + grid.add(WallPixel(), row=y, column=10) + for y in range(19, MAX_GRID_HEIGHT_PIXELS): + grid.add(WallPixel(), row=y, column=10) + + # Stage + for x in range(11, MAX_GRID_WIDTH_PIXELS): + grid.add(WallPixel(), row=35, column=x) + grid.add(TextPixel(text="Bühne"), row=36, column=11, width=24, height=9) + + # Drinks + grid.add(TextPixel(text="G\ne\nt\nr\nä\nn\nk\ne"), row=21, column=11, width=3, height=11) + + # Sleeping + grid.add(TextPixel(icon_name="material/bed"), row=1, column=1, width=4, height=11) + + # Toilet + grid.add(TextPixel(icon_name="material/wc"), row=1, column=7, width=3, height=4) + + # Entry/Helpdesk + grid.add(TextPixel(text="Einlass\n &Orga"), row=19, column=3, width=7, height=5) + + # Wall below Entry/Helpdesk + for y in range(24, MAX_GRID_HEIGHT_PIXELS): + grid.add(WallPixel(), row=y, column=3) + + # Entry Arrow + grid.add(TextPixel(icon_name="material/east", no_outline=True), row=15, column=1, width=2, height=2) + + return Rectangle( + content=grid, + grow_x=True, + grow_y=True, + stroke_color=self.session.theme.neutral_color, + stroke_width=0.1, + fill=self.session.theme.primary_color, + margin=0.5 + ) + diff --git a/src/ez_lan_manager/components/SeatingPlanPixels.py b/src/ez_lan_manager/components/SeatingPlanPixels.py new file mode 100644 index 0000000..d5e7994 --- /dev/null +++ b/src/ez_lan_manager/components/SeatingPlanPixels.py @@ -0,0 +1,82 @@ +from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color +from typing import Optional + + +class SeatPixel(Component): + seat_id: str + + def build(self) -> Component: + return Rectangle( + content=Text(self.seat_id, style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5), + min_width=1, + min_height=1, + fill=self.session.theme.success_color, + hover_stroke_width = 0.1, + grow_x=True, + grow_y=True, + hover_fill=self.session.theme.hud_color, + transition_time=0.4 + ) + +class TextPixel(Component): + text: Optional[str] = None + icon_name: Optional[str] = None + no_outline: bool = False + + def build(self) -> Component: + if self.text is not None: + content = Text(self.text, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1), align_x=0.5) + elif self.icon_name is not None: + content = Icon(self.icon_name, fill=self.session.theme.neutral_color) + else: + content = None + return Rectangle( + content=content, + min_width=1, + min_height=1, + fill=self.session.theme.primary_color, + stroke_width=0.0 if self.no_outline else 0.1, + stroke_color=self.session.theme.neutral_color, + hover_stroke_width = None if self.no_outline else 0.1, + grow_x=True, + grow_y=True, + hover_fill=None if self.no_outline else self.session.theme.hud_color, + transition_time=0.4 + ) + +class WallPixel(Component): + def build(self) -> Component: + return Rectangle( + min_width=1, + min_height=1, + fill=Color.from_hex("434343"), + grow_x=True, + grow_y=True, + ) + +class DebugPixel(Component): + def build(self) -> Component: + return Rectangle( + content=Spacer(), + min_width=1, + min_height=1, + fill=self.session.theme.success_color, + hover_stroke_color = self.session.theme.hud_color, + hover_stroke_width = 0.1, + grow_x=True, + grow_y=True, + hover_fill=self.session.theme.secondary_color, + transition_time=0.1 + ) + +class InvisiblePixel(Component): + def build(self) -> Component: + return Rectangle( + content=Spacer(), + min_width=1, + min_height=1, + fill=self.session.theme.primary_color, + hover_stroke_width=0.0, + grow_x=True, + grow_y=True + ) \ No newline at end of file diff --git a/src/ez_lan_manager/pages/SeatingPlanPage.py b/src/ez_lan_manager/pages/SeatingPlanPage.py index c95a4e1..c63f176 100644 --- a/src/ez_lan_manager/pages/SeatingPlanPage.py +++ b/src/ez_lan_manager/pages/SeatingPlanPage.py @@ -1,12 +1,9 @@ -from rio import Text, Column, TextStyle, Component, event, TextInput, MultiLineTextInput, Row, Button +from rio import Text, Column, TextStyle, Component, event -from src.ez_lan_manager import ConfigurationService, UserService, MailingService -from src.ez_lan_manager.components.AnimatedText import AnimatedText +from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.components.SeatingPlan import SeatingPlan from src.ez_lan_manager.pages import BasePage -from src.ez_lan_manager.types.SessionStorage import SessionStorage -from src.ez_lan_manager.types.User import User - class SeatingPlanPage(Component): @event.on_populate @@ -16,8 +13,10 @@ class SeatingPlanPage(Component): def build(self) -> Component: return BasePage( content=Column( - MainViewContentBox(), - MainViewContentBox(), + MainViewContentBox(Text("Sitzplatz Infobox", margin=1, style=TextStyle(fill=self.session.theme.neutral_color))), + MainViewContentBox( + SeatingPlan() + ), align_y=0 ), grow_x=True -- 2.45.2 From e1f08f4c2322a5458cd88e2e259fc80fb03be127 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 4 Sep 2024 23:54:28 +0200 Subject: [PATCH 66/85] remove xml/svg based seating --- config/config.example.toml | 5 +- config/seating_plan.example.drawio | 256 ----- config/seating_plan_base.example.svg | 973 ------------------ .../services/ConfigurationService.py | 7 +- src/ez_lan_manager/services/SeatingService.py | 97 +- .../types/ConfigurationTypes.py | 2 +- 6 files changed, 7 insertions(+), 1333 deletions(-) delete mode 100644 config/seating_plan.example.drawio delete mode 100644 config/seating_plan_base.example.svg diff --git a/config/config.example.toml b/config/config.example.toml index 656526c..199279c 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -20,7 +20,10 @@ password="" [seating] - base_svg_path="" + # SeatID -> Category + A01 = "NORMAL" + A02 = "NORMAL" + C01 = "LUXUS" [tickets] [tickets."NORMAL"] diff --git a/config/seating_plan.example.drawio b/config/seating_plan.example.drawio deleted file mode 100644 index 00d993c..0000000 --- a/config/seating_plan.example.drawio +++ /dev/null @@ -1,256 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/config/seating_plan_base.example.svg b/config/seating_plan_base.example.svg deleted file mode 100644 index 2a5410f..0000000 --- a/config/seating_plan_base.example.svg +++ /dev/null @@ -1,973 +0,0 @@ - - - - - - - - -
-
-
- - Bürgerhaus Bottenhorn - -
-
-
-
- - Bürgerhaus Bottenhorn - -
-
- - - - - - - - - - - - - -
-
-
- - Schlarfsaal - -
-
-
-
- - Schlarfsaal - -
-
- - - - - - - -
-
-
- - Einlass
und
Orga -
-
-
-
-
-
-
- - Einlass... - -
-
- - - - -
-
-
- - Toiletten - -
-
-
-
- - Toiletten - -
-
- - - - - - - - - - -
-
-
- - Bühne - -
-
-
-
- - Bühne - -
-
- - - - -
-
-
- B01 -
-
-
-
- - B01 - -
-
- - - - -
-
-
- B11 -
-
-
-
- - B11 - -
-
- - - - -
-
-
- B03 -
-
-
-
- - B03 - -
-
- - - - -
-
-
- B10 -
-
-
-
- - B10 - -
-
- - - - -
-
-
- B12 -
-
-
-
- - B12 - -
-
- - - - -
-
-
- B02 -
-
-
-
- - B02 - -
-
- - - - -
-
-
- C01 -
-
-
-
- - C01 - -
-
- - - - -
-
-
- C11 -
-
-
-
- - C11 - -
-
- - - - -
-
-
- C03 -
-
-
-
- - C03 - -
-
- - - - -
-
-
- C10 -
-
-
-
- - C10 - -
-
- - - - -
-
-
- C12 -
-
-
-
- - C12 - -
-
- - - - -
-
-
- C02 -
-
-
-
- - C02 - -
-
- - - - -
-
-
- E13 -
-
-
-
- - E13 - -
-
- - - - -
-
-
- E10 -
-
-
-
- - E10 - -
-
- - - - -
-
-
- E12 -
-
-
-
- - E12 - -
-
- - - - -
-
-
- E11 -
-
-
-
- - E11 - -
-
- - - - -
-
-
- E01 -
-
-
-
- - E01 - -
-
- - - - -
-
-
- E02 -
-
-
-
- - E02 - -
-
- - - - -
-
-
- E03 -
-
-
-
- - E03 - -
-
- - - - -
-
-
- E04 -
-
-
-
- - E04 - -
-
- - - - -
-
-
- D13 -
-
-
-
- - D13 - -
-
- - - - -
-
-
- D10 -
-
-
-
- - D10 - -
-
- - - - -
-
-
- D12 -
-
-
-
- - D12 - -
-
- - - - -
-
-
- D11 -
-
-
-
- - D11 - -
-
- - - - -
-
-
- D01 -
-
-
-
- - D01 - -
-
- - - - -
-
-
- D02 -
-
-
-
- - D02 - -
-
- - - - -
-
-
- D03 -
-
-
-
- - D03 - -
-
- - - - -
-
-
- D04 -
-
-
-
- - D04 - -
-
- - - - -
-
-
- A01 -
-
-
-
- - A01 - -
-
- - - - -
-
-
- A11 -
-
-
-
- - A11 - -
-
- - - - -
-
-
- A03 -
-
-
-
- - A03 - -
-
- - - - -
-
-
- A10 -
-
-
-
- - A10 - -
-
- - - - -
-
-
- A12 -
-
-
-
- - A12 - -
-
- - - - -
-
-
- A02 -
-
-
-
- - A02 - -
-
- - - - -
-
-
- - Getränke - -
-
-
-
- - Getränke - -
-
- - - - -
-
-
- Neben-Ausgang -
-
-
-
- - Neben-Ausgang - -
-
- - - - -
-
-
- Eingang -
-
-
-
- - Eingang - -
-
-
- - - - Text is not SVG - cannot display - - -
\ No newline at end of file diff --git a/src/ez_lan_manager/services/ConfigurationService.py b/src/ez_lan_manager/services/ConfigurationService.py index c648d5b..b5719c7 100644 --- a/src/ez_lan_manager/services/ConfigurationService.py +++ b/src/ez_lan_manager/services/ConfigurationService.py @@ -71,13 +71,8 @@ class ConfigurationService: def get_seating_configuration(self) -> SeatingConfiguration: try: - seating_config = self._config["seating"] - base_svg_file_path = from_root(seating_config["base_svg_path"]) - if not base_svg_file_path.exists(): - logger.fatal(f"Specified seating plan SVG file was not found at {base_svg_file_path}! Exiting...") - sys.exit(1) return SeatingConfiguration( - base_svg_path=base_svg_file_path + seats=self._config["seating"] ) except KeyError: logger.fatal("Error loading seating configuration, exiting...") diff --git a/src/ez_lan_manager/services/SeatingService.py b/src/ez_lan_manager/services/SeatingService.py index ee80b3e..d323c36 100644 --- a/src/ez_lan_manager/services/SeatingService.py +++ b/src/ez_lan_manager/services/SeatingService.py @@ -1,11 +1,6 @@ import logging -import re -from io import StringIO -from pathlib import Path from typing import Optional -from xml.etree import ElementTree -from from_root import from_root from src.ez_lan_manager.services.DatabaseService import DatabaseService from src.ez_lan_manager.services.TicketingService import TicketingService @@ -32,9 +27,6 @@ class SeatingService: self._lan_info = lan_info self._db_service = db_service self._ticketing_service = ticketing_service - self._seating_plan = StringIO() - ElementTree.parse(self._seating_configuration.base_svg_path).write(self._seating_plan, encoding="unicode") - async def get_seating(self) -> list[Seat]: return await self._db_service.get_seating_info() @@ -67,92 +59,5 @@ class SeatingService: raise SeatAlreadyTakenError await self._db_service.seat_user(seat_id, user_id) - await self.update_svg_with_seating_status() - async def generate_new_seating_table(self, seating_plan_fp: Path, no_confirm: bool = False) -> None: - if not no_confirm: - confirm = input("WARNING: THIS ACTION WILL DELETE ALL SEATING DATA! TYPE 'AGREE' TO CONTINUE: ") - if confirm != "AGREE": - logging.info("Seating table generation aborted...") - return - - et = ElementTree.parse(seating_plan_fp) - seat_ids = [] - for child in et.getroot().findall(".//mxCell"): - possible_seat_identifier = child.get("value") - try: - if re.match(r"^\w\d{1,3}$", possible_seat_identifier): - seat_ids.append((possible_seat_identifier, self._lan_info.ticket_info.default_category)) - except TypeError: - continue - - for child in et.getroot().findall(".//object"): - possible_seat_identifier = child.get("label") - try: - if re.match(r"^\w\d{1,3}$", possible_seat_identifier): - category = child.get("category") - seat_ids.append((possible_seat_identifier, category)) - except TypeError: - continue - - await self._db_service.generate_fresh_seats_table(sorted(seat_ids, key=lambda sd: sd[0])) - await self.update_svg_with_seating_status() - - async def update_svg_with_seating_status(self) -> None: - et = ElementTree.parse(self._seating_configuration.base_svg_path) - root = et.getroot() - namespace = {'svg': root.tag.split('}')[0].strip('{')} if '}' in root.tag else {} - rect_g_pairs = [] - last_rect = None - - for elem in root.iter(): - if elem.tag == f"{{{namespace.get('svg')}}}rect": - last_rect = elem - elif elem.tag == f"{{{namespace.get('svg')}}}g": - if last_rect is not None: - rect_g_pairs.append((last_rect, elem)) - last_rect = None - - all_seats = await self.get_seating() - - for rect, g in rect_g_pairs: - seat_id = self.get_seat_id_from_element(g, namespace) - if not seat_id: - continue - seat = await self.get_seat(seat_id, cached_data=all_seats) - if not seat.is_blocked and seat.user is None: - rect.set("fill", "rgb(102, 255, 51)") - elif not seat.is_blocked and seat.user is not None: - rect.set("fill", "rgb(204, 0, 0)") - else: - rect.set("fill", "rgb(190,190,190)") - # @ToDo: Set URL's properly - rect.set('onclick', f"window.open('https://httpbin.org/get?seat_id={seat_id}', '_blank')") - g.set('onclick', f"window.open('https://httpbin.org/get?seat_id={seat_id}', '_blank')") - - # Debug output - et.write(from_root("debug_seating_plan.svg")) - - self._seating_plan = StringIO() - et.write(self._seating_plan, encoding='unicode') - - - @staticmethod - def get_seat_id_from_element(element: ElementTree.Element, namespace: dict) -> Optional[str]: - seat_id = None - for child in element.iter(): - if child.tag == f"{{{namespace.get('svg')}}}text": - # Extract identifier from element - seat_id = child.text.strip() if child.text else None - elif child.tag.endswith('div') and child.text: - # Extract identifier from /
- seat_id = child.text.strip() - - if seat_id: # Break if we've already found the identifier - break - try: - if re.match(r"^\w\d{1,3}$", seat_id): - return seat_id - except TypeError: - pass - return + # ToDo: Make function that creates database table `seats` from config diff --git a/src/ez_lan_manager/types/ConfigurationTypes.py b/src/ez_lan_manager/types/ConfigurationTypes.py index d54e07d..c888180 100644 --- a/src/ez_lan_manager/types/ConfigurationTypes.py +++ b/src/ez_lan_manager/types/ConfigurationTypes.py @@ -41,4 +41,4 @@ class LanInfo: @dataclass(frozen=True) class SeatingConfiguration: - base_svg_path: Path + seats: dict[str, str] -- 2.45.2 From acf458d629a42b7715f6bee6bad4fd30789b1642 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 5 Sep 2024 00:00:17 +0200 Subject: [PATCH 67/85] update dome db creator to use async --- .../helpers/create_demo_database_content.py | 168 +++++++++--------- 1 file changed, 86 insertions(+), 82 deletions(-) diff --git a/src/ez_lan_manager/helpers/create_demo_database_content.py b/src/ez_lan_manager/helpers/create_demo_database_content.py index 491c8a4..7f7aa65 100644 --- a/src/ez_lan_manager/helpers/create_demo_database_content.py +++ b/src/ez_lan_manager/helpers/create_demo_database_content.py @@ -1,10 +1,9 @@ # USE THIS ON AN EMPTY DATABASE TO GENERATE DEMO DATA +import asyncio from datetime import date import sys -from from_root import from_root - from src.ez_lan_manager import init_services from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory from src.ez_lan_manager.types.News import News @@ -17,8 +16,9 @@ DEMO_USERS = [ { "user_name": "thomas", "user_mail": "thomas@demomail.com", "password_clear_text": "thomas" } # Teamler + Admin ] -if __name__ == "__main__": +async def run() -> None: services = init_services() + await services[3].init_db_pool() catering_service = services[1] user_service = services[8] accounting_service = services[0] @@ -27,122 +27,122 @@ if __name__ == "__main__": news_service = services[5] if input("Generate seating table? (y/N): ").lower() == "y": - seating_service.generate_new_seating_table(from_root("config/seating_plan.example.drawio")) + sys.exit("This part of the script is currently being reworked... :(") if not input("Generate users? (Y/n): ").lower() == "n": # MANFRED - manfred = 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 = user_service.create_user(DEMO_USERS[1]["user_name"], DEMO_USERS[1]["user_mail"], DEMO_USERS[1]["password_clear_text"]) - accounting_service.add_balance(gustav.user_id, 100000, "DEMO EINZAHLUNG") - ticket_service.purchase_ticket(gustav.user_id, "NORMAL") + gustav = await user_service.create_user(DEMO_USERS[1]["user_name"], DEMO_USERS[1]["user_mail"], DEMO_USERS[1]["password_clear_text"]) + await accounting_service.add_balance(gustav.user_id, 100000, "DEMO EINZAHLUNG") + await ticket_service.purchase_ticket(gustav.user_id, "NORMAL") # JASON - jason = user_service.create_user(DEMO_USERS[2]["user_name"], DEMO_USERS[2]["user_mail"], DEMO_USERS[2]["password_clear_text"]) - accounting_service.add_balance(jason.user_id, 100000, "DEMO EINZAHLUNG") - ticket_service.purchase_ticket(jason.user_id, "NORMAL") - seating_service.seat_user(30, "D10") + jason = await user_service.create_user(DEMO_USERS[2]["user_name"], DEMO_USERS[2]["user_mail"], DEMO_USERS[2]["password_clear_text"]) + await accounting_service.add_balance(jason.user_id, 100000, "DEMO EINZAHLUNG") + await ticket_service.purchase_ticket(jason.user_id, "NORMAL") + await seating_service.seat_user(30, "D10") # LISA - lisa = user_service.create_user(DEMO_USERS[3]["user_name"], DEMO_USERS[3]["user_mail"], DEMO_USERS[3]["password_clear_text"]) - 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, 100000, "DEMO EINZAHLUNG") lisa.is_team_member = True - user_service.update_user(lisa) + await user_service.update_user(lisa) # THOMAS - thomas = user_service.create_user(DEMO_USERS[4]["user_name"], DEMO_USERS[4]["user_mail"], DEMO_USERS[4]["password_clear_text"]) - 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, 100000, "DEMO EINZAHLUNG") thomas.is_team_member = True thomas.is_admin = True - user_service.update_user(thomas) + await user_service.update_user(thomas) if not input("Generate catering menu? (Y/n): ").lower() == "n": # MAIN_COURSE - catering_service.add_menu_item("Schnitzel Wiener Art", "mit Pommes", 1050, CateringMenuItemCategory.MAIN_COURSE) - catering_service.add_menu_item("Jäger Schnitzel mit Champignonrahm Sauce", "mit Pommes", 1150, CateringMenuItemCategory.MAIN_COURSE) - catering_service.add_menu_item("Tortellini in Käsesauce mit Fleischfüllung", "", 1050, CateringMenuItemCategory.MAIN_COURSE) - 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", 1050, CateringMenuItemCategory.MAIN_COURSE) + await catering_service.add_menu_item("Jäger Schnitzel mit Champignonrahm Sauce", "mit Pommes", 1150, CateringMenuItemCategory.MAIN_COURSE) + await catering_service.add_menu_item("Tortellini in Käsesauce mit Fleischfüllung", "", 1050, CateringMenuItemCategory.MAIN_COURSE) + await catering_service.add_menu_item("Tortellini in Käsesauce ohne Fleischfüllung", "Vegetarisch", 1050, CateringMenuItemCategory.MAIN_COURSE) # SNACK - catering_service.add_menu_item("Käse Schinken Wrap", "", 500, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Puten Paprika Wrap", "", 700, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Tomate Mozzarella Wrap", "", 600, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Portion Pommes", "", 400, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Rinds-Currywurst", "", 450, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Rinds-Currywurst mit Pommes", "", 650, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Nudelsalat", "", 450, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Nudelsalat mit Bockwurst", "", 600, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Kartoffelsalat", "", 450, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Kartoffelsalat mit Bockwurst", "", 600, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Sandwichtoast - Schinken", "", 180, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Sandwichtoast - Käse", "", 180, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Sandwichtoast - Schinken/Käse", "", 210, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Sandwichtoast - Salami", "", 180, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Sandwichtoast - Salami/Käse", "", 210, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Chips - Western Style", "", 130, CateringMenuItemCategory.SNACK) - catering_service.add_menu_item("Nachos - Salted", "", 130, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Käse Schinken Wrap", "", 500, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Puten Paprika Wrap", "", 700, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Tomate Mozzarella Wrap", "", 600, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Portion Pommes", "", 400, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Rinds-Currywurst", "", 450, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Rinds-Currywurst mit Pommes", "", 650, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Nudelsalat", "", 450, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Nudelsalat mit Bockwurst", "", 600, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Kartoffelsalat", "", 450, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Kartoffelsalat mit Bockwurst", "", 600, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Sandwichtoast - Schinken", "", 180, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Sandwichtoast - Käse", "", 180, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Sandwichtoast - Schinken/Käse", "", 210, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Sandwichtoast - Salami", "", 180, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Sandwichtoast - Salami/Käse", "", 210, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Chips - Western Style", "", 130, CateringMenuItemCategory.SNACK) + await catering_service.add_menu_item("Nachos - Salted", "", 130, CateringMenuItemCategory.SNACK) # DESSERT - catering_service.add_menu_item("Panna Cotta mit Erdbeersauce", "", 700, CateringMenuItemCategory.DESSERT) - catering_service.add_menu_item("Panna Cotta mit Blaubeersauce", "", 700, CateringMenuItemCategory.DESSERT) - catering_service.add_menu_item("Mousse au Chocolat", "", 700, CateringMenuItemCategory.DESSERT) + await catering_service.add_menu_item("Panna Cotta mit Erdbeersauce", "", 700, CateringMenuItemCategory.DESSERT) + await catering_service.add_menu_item("Panna Cotta mit Blaubeersauce", "", 700, CateringMenuItemCategory.DESSERT) + await catering_service.add_menu_item("Mousse au Chocolat", "", 700, CateringMenuItemCategory.DESSERT) # BREAKFAST - catering_service.add_menu_item("Fruit Loops", "", 150, CateringMenuItemCategory.BREAKFAST) - catering_service.add_menu_item("Smacks", "", 150, CateringMenuItemCategory.BREAKFAST) - catering_service.add_menu_item("Knuspermüsli", "Schoko", 200, CateringMenuItemCategory.BREAKFAST) - catering_service.add_menu_item("Cini Minis", "", 150, CateringMenuItemCategory.BREAKFAST) - catering_service.add_menu_item("Brötchen - Schinken", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) - catering_service.add_menu_item("Brötchen - Käse", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) - catering_service.add_menu_item("Brötchen - Schinken/Käse", "mit Margarine", 140, CateringMenuItemCategory.BREAKFAST) - catering_service.add_menu_item("Brötchen - Salami", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) - catering_service.add_menu_item("Brötchen - Salami/Käse", "mit Margarine", 140, CateringMenuItemCategory.BREAKFAST) - catering_service.add_menu_item("Brötchen - Nutella", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) + await catering_service.add_menu_item("Fruit Loops", "", 150, CateringMenuItemCategory.BREAKFAST) + await catering_service.add_menu_item("Smacks", "", 150, CateringMenuItemCategory.BREAKFAST) + await catering_service.add_menu_item("Knuspermüsli", "Schoko", 200, CateringMenuItemCategory.BREAKFAST) + await catering_service.add_menu_item("Cini Minis", "", 150, CateringMenuItemCategory.BREAKFAST) + await catering_service.add_menu_item("Brötchen - Schinken", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) + await catering_service.add_menu_item("Brötchen - Käse", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) + await catering_service.add_menu_item("Brötchen - Schinken/Käse", "mit Margarine", 140, CateringMenuItemCategory.BREAKFAST) + await catering_service.add_menu_item("Brötchen - Salami", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) + await catering_service.add_menu_item("Brötchen - Salami/Käse", "mit Margarine", 140, CateringMenuItemCategory.BREAKFAST) + await catering_service.add_menu_item("Brötchen - Nutella", "mit Margarine", 120, CateringMenuItemCategory.BREAKFAST) # BEVERAGE_NON_ALCOHOLIC - catering_service.add_menu_item("Wasser - Still", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) - catering_service.add_menu_item("Wasser - Medium", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) - catering_service.add_menu_item("Wasser - Spritzig", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) - catering_service.add_menu_item("Coca-Cola", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) - catering_service.add_menu_item("Coca-Cola Zero", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) - catering_service.add_menu_item("Fanta", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) - catering_service.add_menu_item("Sprite", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) - catering_service.add_menu_item("Spezi", "von Paulaner, 0,5L Flasche", 150, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) - catering_service.add_menu_item("Red Bull", "", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) - catering_service.add_menu_item("Energy", "Hausmarke", 150, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + await catering_service.add_menu_item("Wasser - Still", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + await catering_service.add_menu_item("Wasser - Medium", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + await catering_service.add_menu_item("Wasser - Spritzig", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + await catering_service.add_menu_item("Coca-Cola", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + await catering_service.add_menu_item("Coca-Cola Zero", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + await catering_service.add_menu_item("Fanta", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + await catering_service.add_menu_item("Sprite", "1L Flasche", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + await catering_service.add_menu_item("Spezi", "von Paulaner, 0,5L Flasche", 150, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + await catering_service.add_menu_item("Red Bull", "", 200, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) + await catering_service.add_menu_item("Energy", "Hausmarke", 150, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC) # BEVERAGE_ALCOHOLIC - catering_service.add_menu_item("Pils", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) - catering_service.add_menu_item("Radler", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) - catering_service.add_menu_item("Diesel", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) - catering_service.add_menu_item("Apfelwein Pur", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) - catering_service.add_menu_item("Apfelwein Sauer", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) - catering_service.add_menu_item("Apfelwein Cola", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + await catering_service.add_menu_item("Pils", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + await catering_service.add_menu_item("Radler", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + await catering_service.add_menu_item("Diesel", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + await catering_service.add_menu_item("Apfelwein Pur", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + await catering_service.add_menu_item("Apfelwein Sauer", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) + await catering_service.add_menu_item("Apfelwein Cola", "0,33L Flasche", 190, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC) # BEVERAGE_COCKTAIL - catering_service.add_menu_item("Vodka Energy", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) - catering_service.add_menu_item("Vodka O-Saft", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) - catering_service.add_menu_item("Whiskey Cola", "mit Bourbon", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) - catering_service.add_menu_item("Jägermeister Energy", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) - catering_service.add_menu_item("Sex on the Beach", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL) - catering_service.add_menu_item("Long Island Ice Tea", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL) - catering_service.add_menu_item("Caipirinha", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + await catering_service.add_menu_item("Vodka Energy", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + await catering_service.add_menu_item("Vodka O-Saft", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + await catering_service.add_menu_item("Whiskey Cola", "mit Bourbon", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + await catering_service.add_menu_item("Jägermeister Energy", "", 400, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + await catering_service.add_menu_item("Sex on the Beach", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + await catering_service.add_menu_item("Long Island Ice Tea", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL) + await catering_service.add_menu_item("Caipirinha", "", 550, CateringMenuItemCategory.BEVERAGE_COCKTAIL) # BEVERAGE_SHOT - catering_service.add_menu_item("Jägermeister", "", 200, CateringMenuItemCategory.BEVERAGE_SHOT) - catering_service.add_menu_item("Tequila", "", 200, CateringMenuItemCategory.BEVERAGE_SHOT) - catering_service.add_menu_item("PfEZzi", "Getunter Pfefferminz-Schnaps", 199, CateringMenuItemCategory.BEVERAGE_SHOT) + await catering_service.add_menu_item("Jägermeister", "", 200, CateringMenuItemCategory.BEVERAGE_SHOT) + await catering_service.add_menu_item("Tequila", "", 200, CateringMenuItemCategory.BEVERAGE_SHOT) + await catering_service.add_menu_item("PfEZzi", "Getunter Pfefferminz-Schnaps", 199, CateringMenuItemCategory.BEVERAGE_SHOT) # NON_FOOD - catering_service.add_menu_item("Zigaretten", "Elixyr", 800, CateringMenuItemCategory.NON_FOOD) - catering_service.add_menu_item("Mentholfilter", "passend für Elixyr", 120, CateringMenuItemCategory.NON_FOOD) + await catering_service.add_menu_item("Zigaretten", "Elixyr", 800, CateringMenuItemCategory.NON_FOOD) + await catering_service.add_menu_item("Mentholfilter", "passend für Elixyr", 120, CateringMenuItemCategory.NON_FOOD) if not input("Generate default new post? (Y/n): ").lower() == "n": loops = 0 user = None while loops < 1000: - user = user_service.get_user(loops) + user = await user_service.get_user(loops) if user is not None: break loops += 1 @@ -150,10 +150,14 @@ if __name__ == "__main__": if user is None: sys.exit("Database does not contain users! Exiting...") - news_service.add_news(News( + await news_service.add_news(News( title="Der EZ LAN Manager", subtitle="Eine Software des EZ GG e.V.", content="Dies ist eine WIP-Version des EZ LAN Managers. Diese Software soll uns helfen in Zukunft die LAN Parties des EZ GG e.V.'s zu organisieren. Wer Fehler findet darf sie behalten. (Oder er meldet sie)", author=user, news_date=date.today() )) + +if __name__ == "__main__": + with asyncio.Runner() as loop: + loop.run(run()) -- 2.45.2 From c7c7cc7964aaf8a4b66f6b005a66664cb5ec0505 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 5 Sep 2024 01:07:51 +0200 Subject: [PATCH 68/85] add seating plan v2 (missing booking) --- src/ez_lan_manager/assets/img/anon_pfp.png | Bin 0 -> 102500 bytes src/ez_lan_manager/components/SeatingPlan.py | 73 ++++++++++-------- .../components/SeatingPlanInfoBox.py | 26 +++++++ .../components/SeatingPlanPixels.py | 48 ++++++++---- src/ez_lan_manager/pages/SeatingPlanPage.py | 39 +++++++++- 5 files changed, 136 insertions(+), 50 deletions(-) create mode 100644 src/ez_lan_manager/components/SeatingPlanInfoBox.py diff --git a/src/ez_lan_manager/assets/img/anon_pfp.png b/src/ez_lan_manager/assets/img/anon_pfp.png index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..265d8b5385935d8d609105677d26372694329700 100644 GIT binary patch literal 102500 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 Seat: + return next(filter(lambda seat: seat.seat_id == seat_id, self.seating_info)) + """ This seating plan is for the community center "Bottenhorn" """ @@ -26,60 +35,60 @@ class SeatingPlan(Component): block_a_margin_left = 12 block_a_margin_top = 1 (grid - .add(SeatPixel("A01"), row=block_a_margin_top, column=block_a_margin_left, width=2, height=3) - .add(SeatPixel("A02"), row=block_a_margin_top + 4, column=block_a_margin_left, width=2, height=3) - .add(SeatPixel("A03"), row=block_a_margin_top + 8, column=block_a_margin_left, width=2, height=3) - .add(SeatPixel("A10"), row=block_a_margin_top, column=block_a_margin_left + 3, width=2, height=3) - .add(SeatPixel("A11"), row=block_a_margin_top + 4, column=block_a_margin_left + 3, width=2, height=3) - .add(SeatPixel("A12"), row=block_a_margin_top + 8, column=block_a_margin_left + 3, width=2, height=3) + .add(SeatPixel("A01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A01")), row=block_a_margin_top, column=block_a_margin_left, width=2, height=3) + .add(SeatPixel("A02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A02")), row=block_a_margin_top + 4, column=block_a_margin_left, width=2, height=3) + .add(SeatPixel("A03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A03")), row=block_a_margin_top + 8, column=block_a_margin_left, width=2, height=3) + .add(SeatPixel("A10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A10")), row=block_a_margin_top, column=block_a_margin_left + 3, width=2, height=3) + .add(SeatPixel("A11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A11")), row=block_a_margin_top + 4, column=block_a_margin_left + 3, width=2, height=3) + .add(SeatPixel("A12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("A12")), row=block_a_margin_top + 8, column=block_a_margin_left + 3, width=2, height=3) ) # Block B block_b_margin_left = 20 block_b_margin_top = 1 (grid - .add(SeatPixel("B01"), row=block_b_margin_top, column=block_b_margin_left, width=2, height=3) - .add(SeatPixel("B02"), row=block_b_margin_top + 4, column=block_b_margin_left, width=2, height=3) - .add(SeatPixel("B03"), row=block_b_margin_top + 8, column=block_b_margin_left, width=2, height=3) - .add(SeatPixel("B10"), row=block_b_margin_top, column=block_b_margin_left + 3, width=2, height=3) - .add(SeatPixel("B11"), row=block_b_margin_top + 4, column=block_b_margin_left + 3, width=2, height=3) - .add(SeatPixel("B12"), row=block_b_margin_top + 8, column=block_b_margin_left + 3, width=2, height=3) + .add(SeatPixel("B01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B01")), row=block_b_margin_top, column=block_b_margin_left, width=2, height=3) + .add(SeatPixel("B02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B02")), row=block_b_margin_top + 4, column=block_b_margin_left, width=2, height=3) + .add(SeatPixel("B03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B03")), row=block_b_margin_top + 8, column=block_b_margin_left, width=2, height=3) + .add(SeatPixel("B10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B10")), row=block_b_margin_top, column=block_b_margin_left + 3, width=2, height=3) + .add(SeatPixel("B11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B11")), row=block_b_margin_top + 4, column=block_b_margin_left + 3, width=2, height=3) + .add(SeatPixel("B12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("B12")), row=block_b_margin_top + 8, column=block_b_margin_left + 3, width=2, height=3) ) # Block C block_c_margin_left = 28 block_c_margin_top = 1 (grid - .add(SeatPixel("C01"), row=block_c_margin_top, column=block_c_margin_left, width=2, height=3) - .add(SeatPixel("C02"), row=block_c_margin_top + 4, column=block_c_margin_left, width=2, height=3) - .add(SeatPixel("C03"), row=block_c_margin_top + 8, column=block_c_margin_left, width=2, height=3) - .add(SeatPixel("C10"), row=block_c_margin_top, column=block_c_margin_left + 3, width=2, height=3) - .add(SeatPixel("C11"), row=block_c_margin_top + 4, column=block_c_margin_left + 3, width=2, height=3) - .add(SeatPixel("C12"), row=block_c_margin_top + 8, column=block_c_margin_left + 3, width=2, height=3) + .add(SeatPixel("C01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C01")), row=block_c_margin_top, column=block_c_margin_left, width=2, height=3) + .add(SeatPixel("C02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C02")), row=block_c_margin_top + 4, column=block_c_margin_left, width=2, height=3) + .add(SeatPixel("C03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C03")), row=block_c_margin_top + 8, column=block_c_margin_left, width=2, height=3) + .add(SeatPixel("C10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C10")), row=block_c_margin_top, column=block_c_margin_left + 3, width=2, height=3) + .add(SeatPixel("C11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C11")), row=block_c_margin_top + 4, column=block_c_margin_left + 3, width=2, height=3) + .add(SeatPixel("C12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("C12")), row=block_c_margin_top + 8, column=block_c_margin_left + 3, width=2, height=3) ) # Block D block_d_margin_left = 20 block_d_margin_top = 20 (grid - .add(SeatPixel("D01"), row=block_d_margin_top, column=block_d_margin_left, width=2, height=3) - .add(SeatPixel("D02"), row=block_d_margin_top + 4, column=block_d_margin_left, width=2, height=3) - .add(SeatPixel("D03"), row=block_d_margin_top + 8, column=block_d_margin_left, width=2, height=3) - .add(SeatPixel("D10"), row=block_d_margin_top, column=block_d_margin_left + 3, width=2, height=3) - .add(SeatPixel("D11"), row=block_d_margin_top + 4, column=block_d_margin_left + 3, width=2, height=3) - .add(SeatPixel("D12"), row=block_d_margin_top + 8, column=block_d_margin_left + 3, width=2, height=3) + .add(SeatPixel("D01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D01")), row=block_d_margin_top, column=block_d_margin_left, width=2, height=3) + .add(SeatPixel("D02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D02")), row=block_d_margin_top + 4, column=block_d_margin_left, width=2, height=3) + .add(SeatPixel("D03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D03")), row=block_d_margin_top + 8, column=block_d_margin_left, width=2, height=3) + .add(SeatPixel("D10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D10")), row=block_d_margin_top, column=block_d_margin_left + 3, width=2, height=3) + .add(SeatPixel("D11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D11")), row=block_d_margin_top + 4, column=block_d_margin_left + 3, width=2, height=3) + .add(SeatPixel("D12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("D12")), row=block_d_margin_top + 8, column=block_d_margin_left + 3, width=2, height=3) ) # Block E block_e_margin_left = 28 block_e_margin_top = 20 (grid - .add(SeatPixel("E01"), row=block_e_margin_top, column=block_e_margin_left, width=2, height=3) - .add(SeatPixel("E02"), row=block_e_margin_top + 4, column=block_e_margin_left, width=2, height=3) - .add(SeatPixel("E03"), row=block_e_margin_top + 8, column=block_e_margin_left, width=2, height=3) - .add(SeatPixel("E10"), row=block_e_margin_top, column=block_e_margin_left + 3, width=2, height=3) - .add(SeatPixel("E11"), row=block_e_margin_top + 4, column=block_e_margin_left + 3, width=2, height=3) - .add(SeatPixel("E12"), row=block_e_margin_top + 8, column=block_e_margin_left + 3, width=2, height=3) + .add(SeatPixel("E01", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E01")), row=block_e_margin_top, column=block_e_margin_left, width=2, height=3) + .add(SeatPixel("E02", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E02")), row=block_e_margin_top + 4, column=block_e_margin_left, width=2, height=3) + .add(SeatPixel("E03", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E03")), row=block_e_margin_top + 8, column=block_e_margin_left, width=2, height=3) + .add(SeatPixel("E10", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E10")), row=block_e_margin_top, column=block_e_margin_left + 3, width=2, height=3) + .add(SeatPixel("E11", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E11")), row=block_e_margin_top + 4, column=block_e_margin_left + 3, width=2, height=3) + .add(SeatPixel("E12", on_press_cb=self.seat_clicked_cb, seat=self.get_seat("E12")), row=block_e_margin_top + 8, column=block_e_margin_left + 3, width=2, height=3) ) # Middle Wall @@ -100,7 +109,9 @@ class SeatingPlan(Component): grid.add(TextPixel(icon_name="material/bed"), row=1, column=1, width=4, height=11) # Toilet - grid.add(TextPixel(icon_name="material/wc"), row=1, column=7, width=3, height=4) + grid.add(TextPixel(icon_name="material/floor", no_outline=True), row=1, column=7, width=3, height=2) + grid.add(TextPixel(icon_name="material/north", no_outline=True), row=3, column=7, width=3, height=2) + grid.add(TextPixel(icon_name="material/wc"), row=5, column=7, width=3, height=2) # Entry/Helpdesk grid.add(TextPixel(text="Einlass\n &Orga"), row=19, column=3, width=7, height=5) diff --git a/src/ez_lan_manager/components/SeatingPlanInfoBox.py b/src/ez_lan_manager/components/SeatingPlanInfoBox.py new file mode 100644 index 0000000..6a2bcd3 --- /dev/null +++ b/src/ez_lan_manager/components/SeatingPlanInfoBox.py @@ -0,0 +1,26 @@ +from typing import Optional + +from rio import Component, Column, Text, TextStyle, Button, Spacer + +from src.ez_lan_manager import AccountingService + + +class SeatingPlanInfoBox(Component): + seat_id: Optional[str] = None + seat_occupant: Optional[str] = None + seat_price: int = 0 + is_blocked: bool = False + + def build(self) -> Component: + if self.is_blocked: + return Column(Text(f"Sitzplatz gesperrt", margin=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.4), wrap=True, 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), wrap=True, 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), wrap=True, justify="center"), + Text(f"{self.seat_occupant}", margin_bottom=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.4), wrap=True, 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), wrap=True, justify="center"), + Button(Text(f"Buchen ({AccountingService.make_euro_string_from_int(self.seat_price)})", margin=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.1), wrap=True, justify="center"), shape="rounded", style="major", color="secondary", margin=1, grow_y=False), min_height=10 + ) diff --git a/src/ez_lan_manager/components/SeatingPlanPixels.py b/src/ez_lan_manager/components/SeatingPlanPixels.py index d5e7994..1f1013d 100644 --- a/src/ez_lan_manager/components/SeatingPlanPixels.py +++ b/src/ez_lan_manager/components/SeatingPlanPixels.py @@ -1,21 +1,39 @@ -from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color -from typing import Optional +from functools import partial + +from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color, MouseEventListener +from typing import Optional, Callable + +from src.ez_lan_manager.types.Seat import Seat +from src.ez_lan_manager.types.SessionStorage import SessionStorage class SeatPixel(Component): seat_id: str + on_press_cb: Callable + seat: Seat + + def determine_color(self) -> Color: + if self.seat.user is not None and self.seat.user.user_id == self.session[SessionStorage].user_id: + return Color.from_hex("800080") + elif self.seat.is_blocked or self.seat.user is not None: + return self.session.theme.danger_color + return self.session.theme.success_color def build(self) -> Component: - return Rectangle( - content=Text(self.seat_id, style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5), - min_width=1, - min_height=1, - fill=self.session.theme.success_color, - hover_stroke_width = 0.1, - grow_x=True, - grow_y=True, - hover_fill=self.session.theme.hud_color, - transition_time=0.4 + return MouseEventListener( + content=Rectangle( + content=Text(self.seat_id, style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False), + min_width=1, + min_height=1, + fill=self.determine_color(), + hover_stroke_width = 0.1, + grow_x=True, + grow_y=True, + hover_fill=self.session.theme.hud_color, + transition_time=0.4, + ripple=True + ), + on_press=partial(self.on_press_cb, self.seat_id) ) class TextPixel(Component): @@ -25,7 +43,7 @@ class TextPixel(Component): def build(self) -> Component: if self.text is not None: - content = Text(self.text, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1), align_x=0.5) + content = Text(self.text, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1), align_x=0.5, selectable=False) elif self.icon_name is not None: content = Icon(self.icon_name, fill=self.session.theme.neutral_color) else: @@ -40,8 +58,8 @@ class TextPixel(Component): hover_stroke_width = None if self.no_outline else 0.1, grow_x=True, grow_y=True, - hover_fill=None if self.no_outline else self.session.theme.hud_color, - transition_time=0.4 + hover_fill=None, + ripple=True ) class WallPixel(Component): diff --git a/src/ez_lan_manager/pages/SeatingPlanPage.py b/src/ez_lan_manager/pages/SeatingPlanPage.py index c63f176..4906586 100644 --- a/src/ez_lan_manager/pages/SeatingPlanPage.py +++ b/src/ez_lan_manager/pages/SeatingPlanPage.py @@ -1,21 +1,52 @@ -from rio import Text, Column, TextStyle, Component, event +from typing import Optional -from src.ez_lan_manager import ConfigurationService +from from_root import from_root +from rio import Text, Column, TextStyle, Component, event, PressEvent, ProgressCircle, Row, Image, Button, Spacer + +from src.ez_lan_manager import ConfigurationService, SeatingService, TicketingService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.components.SeatingPlan import SeatingPlan +from src.ez_lan_manager.components.SeatingPlanInfoBox import SeatingPlanInfoBox from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.types.Seat import Seat + class SeatingPlanPage(Component): + seating_info: Optional[list[Seat]] = None + current_seat_id: Optional[str] = None + current_seat_occupant: Optional[str] = None + current_seat_price: int = 0 + current_seat_is_blocked: bool = False + @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Sitzplan") + self.seating_info = await self.session[SeatingService].get_seating() + + async def on_seat_clicked(self, seat_id: str, _: PressEvent) -> None: + seat = next(filter(lambda s: s.seat_id == seat_id, self.seating_info), None) + if not seat: + return + self.current_seat_is_blocked = seat.is_blocked + self.current_seat_id = seat.seat_id + ticket_info = self.session[TicketingService].get_ticket_info_by_category(seat.category) + price = 0 if not ticket_info else ticket_info.price + self.current_seat_price = price + if seat.user: + self.current_seat_occupant = seat.user.user_name + else: + self.current_seat_occupant = None def build(self) -> Component: return BasePage( content=Column( - MainViewContentBox(Text("Sitzplatz Infobox", margin=1, style=TextStyle(fill=self.session.theme.neutral_color))), MainViewContentBox( - SeatingPlan() + SeatingPlan(seat_clicked_cb=self.on_seat_clicked, seating_info=self.seating_info) if self.seating_info else + Column(ProgressCircle(color=self.session.theme.secondary_color, margin=3), Text("Sitzplan wird geladen", style=TextStyle(fill=self.session.theme.neutral_color), align_x=0.5, margin=1)) + ), + MainViewContentBox( + SeatingPlanInfoBox(seat_id=self.current_seat_id, seat_occupant=self.current_seat_occupant, seat_price=self.current_seat_price, + is_blocked=self.current_seat_is_blocked) ), align_y=0 ), -- 2.45.2 From 871d8d6a3d8dc9ac01ff6d4abebda48be34ef4b0 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Fri, 6 Sep 2024 12:22:40 +0200 Subject: [PATCH 69/85] add seating plan legend, improve ui --- src/ez_lan_manager/components/SeatingPlan.py | 61 ++++++++++++++++++- .../components/SeatingPlanInfoBox.py | 18 +++++- .../components/SeatingPlanPixels.py | 7 ++- src/ez_lan_manager/pages/SeatingPlanPage.py | 38 ++++++++++-- 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/src/ez_lan_manager/components/SeatingPlan.py b/src/ez_lan_manager/components/SeatingPlan.py index c194ce4..ee78842 100644 --- a/src/ez_lan_manager/components/SeatingPlan.py +++ b/src/ez_lan_manager/components/SeatingPlan.py @@ -1,6 +1,6 @@ from typing import Callable -from rio import Component, Rectangle, Grid +from rio import Component, Rectangle, Grid, Column, Row, Text, TextStyle, Color from src.ez_lan_manager.components.SeatingPlanPixels import SeatPixel, WallPixel, InvisiblePixel, TextPixel from src.ez_lan_manager.types.Seat import Seat @@ -8,6 +8,65 @@ from src.ez_lan_manager.types.Seat import Seat MAX_GRID_WIDTH_PIXELS = 34 MAX_GRID_HEIGHT_PIXELS = 45 +class SeatingPlanLegend(Component): + def build(self) -> Component: + return Column( + Text("Legende", style=TextStyle(fill=self.session.theme.neutral_color), justify="center", margin=1), + Row( + Text("L = Luxus Platz", justify="center", style=TextStyle(fill=self.session.theme.neutral_color)), + Text("N = Normaler Platz", justify="center", style=TextStyle(fill=self.session.theme.neutral_color)), + ), + Row( + Rectangle( + content=Column( + Text(f"Freier Platz", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False), + Text(f"", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, + selectable=False, wrap=True) + ), + min_width=1, + min_height=1, + fill=self.session.theme.success_color, + grow_x=False, + grow_y=False, + hover_fill=self.session.theme.success_color, + transition_time=0.4, + ripple=True + ), + Rectangle( + content=Column( + Text(f"Belegter Platz", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False), + Text(f"", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, + selectable=False, wrap=True) + ), + min_width=1, + min_height=1, + fill=self.session.theme.danger_color, + grow_x=False, + grow_y=False, + hover_fill=self.session.theme.danger_color, + transition_time=0.4, + ripple=True + ), + Rectangle( + content=Column( + Text(f"Eigener Platz", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False), + Text(f"", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, + selectable=False, wrap=True) + ), + min_width=1, + min_height=1, + fill=Color.from_hex("800080"), + grow_x=False, + grow_y=False, + hover_fill=Color.from_hex("800080"), + transition_time=0.4, + ripple=True + ), + margin=1, + spacing=1 + ) + ) + class SeatingPlan(Component): seat_clicked_cb: Callable diff --git a/src/ez_lan_manager/components/SeatingPlanInfoBox.py b/src/ez_lan_manager/components/SeatingPlanInfoBox.py index 6a2bcd3..9bf347b 100644 --- a/src/ez_lan_manager/components/SeatingPlanInfoBox.py +++ b/src/ez_lan_manager/components/SeatingPlanInfoBox.py @@ -10,6 +10,7 @@ class SeatingPlanInfoBox(Component): seat_occupant: Optional[str] = None seat_price: int = 0 is_blocked: bool = False + user_has_seat: bool = False def build(self) -> Component: if self.is_blocked: @@ -22,5 +23,20 @@ class SeatingPlanInfoBox(Component): 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), wrap=True, justify="center"), - Button(Text(f"Buchen ({AccountingService.make_euro_string_from_int(self.seat_price)})", margin=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.1), wrap=True, justify="center"), shape="rounded", style="major", color="secondary", margin=1, grow_y=False), min_height=10 + Button( + Text( + f"Buchen ({AccountingService.make_euro_string_from_int(self.seat_price)})", + margin=1, + style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.1), + wrap=True, + justify="center" + ), + shape="rounded", + style="major", + color="secondary", + margin=1, + grow_y=False, + is_sensitive=not self.user_has_seat + ), + min_height=10 ) diff --git a/src/ez_lan_manager/components/SeatingPlanPixels.py b/src/ez_lan_manager/components/SeatingPlanPixels.py index 1f1013d..ea7189b 100644 --- a/src/ez_lan_manager/components/SeatingPlanPixels.py +++ b/src/ez_lan_manager/components/SeatingPlanPixels.py @@ -1,6 +1,6 @@ from functools import partial -from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color, MouseEventListener +from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color, MouseEventListener, Column from typing import Optional, Callable from src.ez_lan_manager.types.Seat import Seat @@ -22,7 +22,10 @@ class SeatPixel(Component): def build(self) -> Component: return MouseEventListener( content=Rectangle( - content=Text(self.seat_id, style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False), + content=Column( + Text(f"{self.seat_id}", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False), + Text(f"{self.seat.category[0]}", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, selectable=False, wrap=True) + ), min_width=1, min_height=1, fill=self.determine_color(), diff --git a/src/ez_lan_manager/pages/SeatingPlanPage.py b/src/ez_lan_manager/pages/SeatingPlanPage.py index 4906586..9b9424e 100644 --- a/src/ez_lan_manager/pages/SeatingPlanPage.py +++ b/src/ez_lan_manager/pages/SeatingPlanPage.py @@ -1,14 +1,15 @@ from typing import Optional -from from_root import from_root from rio import Text, Column, TextStyle, Component, event, PressEvent, ProgressCircle, Row, Image, Button, Spacer -from src.ez_lan_manager import ConfigurationService, SeatingService, TicketingService +from src.ez_lan_manager import ConfigurationService, SeatingService, TicketingService, UserService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.components.SeatingPlan import SeatingPlan +from src.ez_lan_manager.components.SeatingPlan import SeatingPlan, SeatingPlanLegend from src.ez_lan_manager.components.SeatingPlanInfoBox import SeatingPlanInfoBox from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.Seat import Seat +from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.User import User class SeatingPlanPage(Component): @@ -17,11 +18,21 @@ class SeatingPlanPage(Component): current_seat_occupant: Optional[str] = None current_seat_price: int = 0 current_seat_is_blocked: bool = False + user: Optional[User] = None + user_has_seat: bool = False @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Sitzplan") self.seating_info = await self.session[SeatingService].get_seating() + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) + user_has_seat = False + for seat in self.seating_info: + if not seat.user or not self.user: + continue + if seat.user.user_id == self.user.user_id: + user_has_seat = True + self.user_has_seat = user_has_seat async def on_seat_clicked(self, seat_id: str, _: PressEvent) -> None: seat = next(filter(lambda s: s.seat_id == seat_id, self.seating_info), None) @@ -38,15 +49,32 @@ class SeatingPlanPage(Component): self.current_seat_occupant = None def build(self) -> Component: + if not self.seating_info: + return BasePage( + content=Column( + MainViewContentBox( + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=2, + margin_bottom=2 + ) + ), + align_y=0 + ) + ) return BasePage( content=Column( + MainViewContentBox( + SeatingPlanInfoBox(seat_id=self.current_seat_id, seat_occupant=self.current_seat_occupant, seat_price=self.current_seat_price, + is_blocked=self.current_seat_is_blocked, user_has_seat=self.user_has_seat) + ), MainViewContentBox( SeatingPlan(seat_clicked_cb=self.on_seat_clicked, seating_info=self.seating_info) if self.seating_info else Column(ProgressCircle(color=self.session.theme.secondary_color, margin=3), Text("Sitzplan wird geladen", style=TextStyle(fill=self.session.theme.neutral_color), align_x=0.5, margin=1)) ), MainViewContentBox( - SeatingPlanInfoBox(seat_id=self.current_seat_id, seat_occupant=self.current_seat_occupant, seat_price=self.current_seat_price, - is_blocked=self.current_seat_is_blocked) + SeatingPlanLegend(), ), align_y=0 ), -- 2.45.2 From 3c3fd878c568c4b0121b5c405db79aab4a1a69c1 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Fri, 6 Sep 2024 14:12:33 +0200 Subject: [PATCH 70/85] add seat booking --- .../components/SeatingPlanInfoBox.py | 16 ++-- .../components/SeatingPurchaseBox.py | 96 +++++++++++++++++++ .../components/UserInfoAndLoginBox.py | 2 +- src/ez_lan_manager/pages/SeatingPlanPage.py | 86 +++++++++++++++-- 4 files changed, 183 insertions(+), 17 deletions(-) create mode 100644 src/ez_lan_manager/components/SeatingPurchaseBox.py diff --git a/src/ez_lan_manager/components/SeatingPlanInfoBox.py b/src/ez_lan_manager/components/SeatingPlanInfoBox.py index 9bf347b..63c663c 100644 --- a/src/ez_lan_manager/components/SeatingPlanInfoBox.py +++ b/src/ez_lan_manager/components/SeatingPlanInfoBox.py @@ -1,18 +1,21 @@ -from typing import Optional +from typing import Optional, Callable from rio import Component, Column, Text, TextStyle, Button, Spacer -from src.ez_lan_manager import AccountingService - class SeatingPlanInfoBox(Component): + show: bool + purchase_cb: Callable + is_booking_blocked: bool seat_id: Optional[str] = None seat_occupant: Optional[str] = None seat_price: int = 0 is_blocked: bool = False - user_has_seat: 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), wrap=True, justify="center"), min_height=10) if self.seat_id is None and self.seat_occupant is None: @@ -25,7 +28,7 @@ class SeatingPlanInfoBox(Component): Text(f"Dieser Sitzplatz ({self.seat_id}) ist frei", margin=1, style=TextStyle(fill=self.session.theme.neutral_color), wrap=True, justify="center"), Button( Text( - f"Buchen ({AccountingService.make_euro_string_from_int(self.seat_price)})", + f"Buchen", margin=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.1), wrap=True, @@ -36,7 +39,8 @@ class SeatingPlanInfoBox(Component): color="secondary", margin=1, grow_y=False, - is_sensitive=not self.user_has_seat + is_sensitive=not self.is_booking_blocked, + on_press=self.purchase_cb ), min_height=10 ) diff --git a/src/ez_lan_manager/components/SeatingPurchaseBox.py b/src/ez_lan_manager/components/SeatingPurchaseBox.py new file mode 100644 index 0000000..c823996 --- /dev/null +++ b/src/ez_lan_manager/components/SeatingPurchaseBox.py @@ -0,0 +1,96 @@ +from typing import Optional, Callable + +from rio import Component, Column, Text, TextStyle, Button, Spacer, Row, ProgressCircle + + +class SeatingPurchaseBox(Component): + show: bool + seat_id: str + is_loading: bool + confirm_cb: Callable + cancel_cb: Callable + error_msg: Optional[str] = None + success_msg: Optional[str] = None + + def build(self) -> Component: + if not self.show: + return Spacer() + if self.is_loading: + return Column( + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=2, + margin_bottom=2 + ), + min_height=10 + ) + + if self.success_msg: + return Column( + Text(f"{self.success_msg}", margin=1, style=TextStyle(fill=self.session.theme.success_color, font_size=1.1), + wrap=True, justify="center"), + Row( + Button( + Text("Zurück", + margin=1, + style=TextStyle(fill=self.session.theme.success_color, font_size=1.1), + wrap=True, + justify="center" + ), + shape="rounded", + style="plain", + on_press=self.cancel_cb + ) + ), + min_height=10 + ) + + if self.error_msg: + return Column( + Text(f"{self.error_msg}", margin=1, style=TextStyle(fill=self.session.theme.danger_color, font_size=1.1), + wrap=True, justify="center"), + Row( + Button( + Text("Zurück", + margin=1, + style=TextStyle(fill=self.session.theme.danger_color, font_size=1.1), + wrap=True, + justify="center" + ), + shape="rounded", + style="plain", + on_press=self.cancel_cb + ) + ), + min_height=10 + ) + + return Column( + Text(f"Sitzplatz {self.seat_id} verbindlich buchen?", margin=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.4), wrap=True, justify="center"), + Row( + Button( + Text("Nein", + margin=1, + style=TextStyle(fill=self.session.theme.danger_color, font_size=1.1), + wrap=True, + justify="center" + ), + shape="rounded", + style="plain", + on_press=self.cancel_cb + ), + Button( + Text("Ja", + margin=1, + style=TextStyle(fill=self.session.theme.success_color, font_size=1.1), + wrap=True, + justify="center" + ), + shape="rounded", + style="minor", + on_press=self.confirm_cb + ) + ), + min_height=10 + ) diff --git a/src/ez_lan_manager/components/UserInfoAndLoginBox.py b/src/ez_lan_manager/components/UserInfoAndLoginBox.py index 9feccd1..5284bc1 100644 --- a/src/ez_lan_manager/components/UserInfoAndLoginBox.py +++ b/src/ez_lan_manager/components/UserInfoAndLoginBox.py @@ -58,7 +58,7 @@ class UserInfoAndLoginBox(Component): @staticmethod def get_greeting() -> str: - return choice(["Guten Tacho", "Tuten Gag", "Servus", "Moinjour", "Hallöchen Popöchen", "Heyho", "Moinsen"]) + return choice(["Guten Tacho", "Tuten Gag", "Servus", "Moinjour", "Hallöchen", "Heyho", "Moinsen"]) # @FixMe: If the user logs out and then tries to log back in, it does not work # If the user navigates to another page and then tries again. It works. diff --git a/src/ez_lan_manager/pages/SeatingPlanPage.py b/src/ez_lan_manager/pages/SeatingPlanPage.py index 9b9424e..68c7045 100644 --- a/src/ez_lan_manager/pages/SeatingPlanPage.py +++ b/src/ez_lan_manager/pages/SeatingPlanPage.py @@ -1,3 +1,5 @@ +import logging +from asyncio import sleep from typing import Optional from rio import Text, Column, TextStyle, Component, event, PressEvent, ProgressCircle, Row, Image, Button, Spacer @@ -6,12 +8,16 @@ from src.ez_lan_manager import ConfigurationService, SeatingService, TicketingSe from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.components.SeatingPlan import SeatingPlan, SeatingPlanLegend from src.ez_lan_manager.components.SeatingPlanInfoBox import SeatingPlanInfoBox +from src.ez_lan_manager.components.SeatingPurchaseBox import SeatingPurchaseBox from src.ez_lan_manager.pages import BasePage +from src.ez_lan_manager.services.SeatingService import NoTicketError, SeatNotFoundError, WrongCategoryError, SeatAlreadyTakenError from src.ez_lan_manager.types.Seat import Seat from src.ez_lan_manager.types.SessionStorage import SessionStorage from src.ez_lan_manager.types.User import User +logger = logging.getLogger(__name__.split(".")[-1]) + class SeatingPlanPage(Component): seating_info: Optional[list[Seat]] = None current_seat_id: Optional[str] = None @@ -19,22 +25,30 @@ class SeatingPlanPage(Component): current_seat_price: int = 0 current_seat_is_blocked: bool = False user: Optional[User] = None - user_has_seat: bool = False + show_info_box: bool = True + show_purchase_box: bool = False + purchase_box_loading: bool = False + purchase_box_success_msg: Optional[str] = None + purchase_box_error_msg: Optional[str] = None + is_booking_blocked: bool = False @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Sitzplan") self.seating_info = await self.session[SeatingService].get_seating() self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) - user_has_seat = False - for seat in self.seating_info: - if not seat.user or not self.user: - continue - if seat.user.user_id == self.user.user_id: - user_has_seat = True - self.user_has_seat = user_has_seat + if not self.user: + self.is_booking_blocked = True + else: + for seat in self.seating_info: + if not seat.user or not self.user: + continue + if seat.user.user_id == self.user.user_id: + self.is_booking_blocked = True async def on_seat_clicked(self, seat_id: str, _: PressEvent) -> None: + self.show_info_box = True + self.show_purchase_box = False seat = next(filter(lambda s: s.seat_id == seat_id, self.seating_info), None) if not seat: return @@ -48,6 +62,47 @@ class SeatingPlanPage(Component): else: self.current_seat_occupant = None + def set_error(self, msg: str) -> None: + self.purchase_box_error_msg = msg + self.purchase_box_success_msg = None + + def set_success(self, msg: str) -> None: + self.purchase_box_error_msg = None + self.purchase_box_success_msg = msg + + async def on_purchase_clicked(self) -> None: + self.show_info_box = False + self.show_purchase_box = True + + async def on_purchase_confirmed(self) -> None: + self.purchase_box_loading = True + await self.force_refresh() + await sleep(0.5) + try: + await self.session[SeatingService].seat_user(self.user.user_id, self.current_seat_id) + except (NoTicketError, WrongCategoryError): + self.set_error("Du besitzt kein gültiges Ticket für diesen Platz") + except SeatNotFoundError: + self.set_error("Der angegebene Sitzplatz existiert nicht") + except SeatAlreadyTakenError: + self.set_error("Dieser Platz ist bereits vergeben") + except Exception as e: + self.set_error("Ein unbekannter Fehler ist aufgetreten") + logger.error(e) + else: + self.set_success("Platz erfolgreich gebucht!") + self.purchase_box_loading = False + await self.on_populate() + + + async def on_purchase_cancelled(self) -> None: + self.purchase_box_loading = False + self.show_info_box = True + self.show_purchase_box = False + self.purchase_box_error_msg = None + self.purchase_box_success_msg = None + + def build(self) -> Component: if not self.seating_info: return BasePage( @@ -66,8 +121,19 @@ class SeatingPlanPage(Component): return BasePage( content=Column( MainViewContentBox( - SeatingPlanInfoBox(seat_id=self.current_seat_id, seat_occupant=self.current_seat_occupant, seat_price=self.current_seat_price, - is_blocked=self.current_seat_is_blocked, user_has_seat=self.user_has_seat) + Column( + SeatingPlanInfoBox(seat_id=self.current_seat_id, seat_occupant=self.current_seat_occupant, seat_price=self.current_seat_price, + is_blocked=self.current_seat_is_blocked, is_booking_blocked=self.is_booking_blocked, show=self.show_info_box, purchase_cb=self.on_purchase_clicked), + SeatingPurchaseBox( + show=self.show_purchase_box, + seat_id=self.current_seat_id, + is_loading=self.purchase_box_loading, + confirm_cb=self.on_purchase_confirmed, + cancel_cb=self.on_purchase_cancelled, + error_msg=self.purchase_box_error_msg, + success_msg=self.purchase_box_success_msg + ) + ) ), MainViewContentBox( SeatingPlan(seat_clicked_cb=self.on_seat_clicked, seating_info=self.seating_info) if self.seating_info else -- 2.45.2 From 1a33ea69f21d46666de2a8b852d61fcb0a8622cc Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 27 Nov 2024 11:18:59 +0000 Subject: [PATCH 71/85] upgrade-rio-version (#1) Co-authored-by: David Rodenkirchen Reviewed-on: https://git.ezgg-ev.de/Vereins-IT/ez-lan-manager/pulls/1 --- requirements.txt | Bin 190 -> 186 bytes src/EzLanManager.py | 71 +-- .../components/CateringCartItem.py | 4 +- .../components/CateringOrderItem.py | 4 +- .../components/CateringSelectionItem.py | 8 +- src/ez_lan_manager/components/NewsPost.py | 10 +- src/ez_lan_manager/components/SeatingPlan.py | 6 +- .../components/SeatingPlanInfoBox.py | 12 +- .../components/SeatingPlanPixels.py | 6 +- .../components/SeatingPurchaseBox.py | 20 +- .../components/ShoppingCartAndOrders.py | 4 +- .../components/TicketBuyCard.py | 2 +- src/ez_lan_manager/pages/Account.py | 120 +++-- src/ez_lan_manager/pages/BasePage.py | 21 +- src/ez_lan_manager/pages/BuyTicketPage.py | 79 ++-- src/ez_lan_manager/pages/CateringPage.py | 425 +++++++++--------- src/ez_lan_manager/pages/ContactPage.py | 50 +-- src/ez_lan_manager/pages/DbErrorPage.py | 2 +- src/ez_lan_manager/pages/EditProfile.py | 112 +++-- src/ez_lan_manager/pages/FaqPage.py | 88 ++-- src/ez_lan_manager/pages/ForgotPassword.py | 42 +- src/ez_lan_manager/pages/GuestsPage.py | 70 ++- src/ez_lan_manager/pages/ImprintPage.py | 169 ++++--- src/ez_lan_manager/pages/NewsPage.py | 5 +- src/ez_lan_manager/pages/PlaceholderPage.py | 18 +- src/ez_lan_manager/pages/RegisterPage.py | 47 +- src/ez_lan_manager/pages/RulesPage.py | 266 ++++++----- src/ez_lan_manager/pages/SeatingPlanPage.py | 74 ++- src/ez_lan_manager/pages/TEMPLATE.py | 50 +-- src/ez_lan_manager/pages/TournamentsPage.py | 50 +-- 30 files changed, 892 insertions(+), 943 deletions(-) diff --git a/requirements.txt b/requirements.txt index 54b23df4e6b70533b8f4a90e613f680d0a59643a..1f4254def008c892b834093480747e2ae53874fd 100644 GIT binary patch delta 17 YcmdnTxQlT@47(wN0fQcc#l-Y*04vr6ga7~l delta 21 ccmdnRxQ}r{47VkN9)mGM5koSA!NlZn06szmTmS$7 diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 50d7842..84598de 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -5,7 +5,7 @@ import sys from pathlib import Path -from rio import App, Theme, Color, Font, Page, Session +from rio import App, Theme, Color, Font, ComponentPage, Session from from_root import from_root from src.ez_lan_manager import pages, init_services @@ -45,94 +45,95 @@ if __name__ == "__main__": app = App( name="EZ LAN Manager", + build=pages.BasePage, pages=[ - Page( + ComponentPage( name="News", - page_url="", + url_segment="", build=pages.NewsPage, ), - Page( + ComponentPage( name="News", - page_url="news", + url_segment="news", build=pages.NewsPage, ), - Page( + ComponentPage( name="Overview", - page_url="overview", + url_segment="overview", build=lambda: pages.PlaceholderPage(placeholder_name="LAN Übersicht"), ), - Page( + ComponentPage( name="BuyTicket", - page_url="buy_ticket", + url_segment="buy_ticket", build=pages.BuyTicketPage, ), - Page( + ComponentPage( name="SeatingPlan", - page_url="seating", + url_segment="seating", build=pages.SeatingPlanPage, ), - Page( + ComponentPage( name="Catering", - page_url="catering", + url_segment="catering", build=pages.CateringPage, ), - Page( + ComponentPage( name="Guests", - page_url="guests", + url_segment="guests", build=pages.GuestsPage, ), - Page( + ComponentPage( name="Tournaments", - page_url="tournaments", + url_segment="tournaments", build=pages.TournamentsPage, ), - Page( + ComponentPage( name="FAQ", - page_url="faq", + url_segment="faq", build=pages.FaqPage, ), - Page( + ComponentPage( name="RulesGTC", - page_url="rules-gtc", + url_segment="rules-gtc", build=pages.RulesPage ), - Page( + ComponentPage( name="Contact", - page_url="contact", + url_segment="contact", build=pages.ContactPage, ), - Page( + ComponentPage( name="Imprint", - page_url="imprint", + url_segment="imprint", build=pages.ImprintPage, ), - Page( + ComponentPage( name="Register", - page_url="register", + url_segment="register", build=pages.RegisterPage, guard=not_logged_in_guard ), - Page( + ComponentPage( name="ForgotPassword", - page_url="forgot-password", + url_segment="forgot-password", build=pages.ForgotPasswordPage, guard=not_logged_in_guard ), - Page( + ComponentPage( name="EditProfile", - page_url="edit-profile", + url_segment="edit-profile", build=pages.EditProfilePage, guard=logged_in_guard ), - Page( + ComponentPage( name="Account", - page_url="account", + url_segment="account", build=pages.AccountPage, guard=logged_in_guard ), - Page( + ComponentPage( name="DbErrorPage", - page_url="db-error", + url_segment="db-error", build=pages.DbErrorPage, ) ], diff --git a/src/ez_lan_manager/components/CateringCartItem.py b/src/ez_lan_manager/components/CateringCartItem.py index 6298995..f14b37c 100644 --- a/src/ez_lan_manager/components/CateringCartItem.py +++ b/src/ez_lan_manager/components/CateringCartItem.py @@ -23,8 +23,8 @@ class CateringCartItem(Component): def build(self) -> rio.Component: return Row( - Text(self.ellipsize_string(self.article_name), align_x=0, wrap=True, min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), + Text(self.ellipsize_string(self.article_name), align_x=0, overflow="wrap", min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), Text(AccountingService.make_euro_string_from_int(self.article_price), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), - IconButton(icon="material/close", size=2, color=self.session.theme.danger_color, style="plain", on_press=lambda: self.remove_item_cb(self.list_id)), + IconButton(icon="material/close", size=2, color=self.session.theme.danger_color, style="plain-text", on_press=lambda: self.remove_item_cb(self.list_id)), proportions=(19, 5, 2) ) diff --git a/src/ez_lan_manager/components/CateringOrderItem.py b/src/ez_lan_manager/components/CateringOrderItem.py index f82a49e..5f89410 100644 --- a/src/ez_lan_manager/components/CateringOrderItem.py +++ b/src/ez_lan_manager/components/CateringOrderItem.py @@ -32,7 +32,7 @@ class CateringOrderItem(Component): def build(self) -> rio.Component: order_status, color = self.get_display_text_and_color_for_order_status(self.order_status) return Row( - Text(f"ID: {str(self.order_id):0>6}", align_x=0, wrap=True, min_width=10, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), margin_right=1), - Text(order_status, wrap=True, min_width=10, style=TextStyle(fill=color, font_size=0.9), margin_right=1), + Text(f"ID: {str(self.order_id):0>6}", align_x=0, overflow="wrap", min_width=10, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), margin_right=1), + Text(order_status, overflow="wrap", min_width=10, style=TextStyle(fill=color, font_size=0.9), margin_right=1), Text(self.order_datetime.strftime("%d.%m. %H:%M"), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), align_x=1) ) diff --git a/src/ez_lan_manager/components/CateringSelectionItem.py b/src/ez_lan_manager/components/CateringSelectionItem.py index bfbb56d..183b0bc 100644 --- a/src/ez_lan_manager/components/CateringSelectionItem.py +++ b/src/ez_lan_manager/components/CateringSelectionItem.py @@ -40,25 +40,25 @@ class CateringSelectionItem(Component): return Card( content=Column( Row( - Text(article_name_top, align_x=0, wrap=True, min_width=19, 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_int(self.article_price), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), IconButton( icon="material/add", size=2, color=self.session.theme.success_color, - style="plain", + style="plain-text", on_press=lambda: self.on_add_callback(self.article_id), is_sensitive=self.is_sensitive ), proportions=(19, 5, 2), margin_bottom=0 ), - Spacer() if not article_name_bottom else Text(article_name_bottom, align_x=0, wrap=True, 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, align_x=0, - wrap=True, + overflow="wrap", min_width=19, style=TextStyle(fill=self.session.theme.background_color, font_size=0.6) ), diff --git a/src/ez_lan_manager/components/NewsPost.py b/src/ez_lan_manager/components/NewsPost.py index 87daa35..adac0ba 100644 --- a/src/ez_lan_manager/components/NewsPost.py +++ b/src/ez_lan_manager/components/NewsPost.py @@ -21,7 +21,7 @@ class NewsPost(Component): fill=self.session.theme.background_color, font_size=1.3 ), - wrap="ellipsize" + overflow="ellipsize" ), Text( self.date, @@ -31,7 +31,7 @@ class NewsPost(Component): fill=self.session.theme.background_color, font_size=0.6 ), - wrap=True + overflow="wrap" ) ), Text( @@ -44,7 +44,7 @@ class NewsPost(Component): fill=self.session.theme.background_color, font_size=0.8 ), - wrap="ellipsize" + overflow="ellipsize" ), Text( self.text, @@ -52,7 +52,7 @@ class NewsPost(Component): style=TextStyle( fill=self.session.theme.background_color ), - wrap=True + overflow="wrap" ), Text( f"Geschrieben von {self.author}", @@ -66,7 +66,7 @@ class NewsPost(Component): font_size=0.5, italic=True ), - wrap=False + overflow="nowrap" ) ), fill=self.session.theme.primary_color, diff --git a/src/ez_lan_manager/components/SeatingPlan.py b/src/ez_lan_manager/components/SeatingPlan.py index ee78842..ee06988 100644 --- a/src/ez_lan_manager/components/SeatingPlan.py +++ b/src/ez_lan_manager/components/SeatingPlan.py @@ -21,7 +21,7 @@ class SeatingPlanLegend(Component): content=Column( Text(f"Freier Platz", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False), Text(f"", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, - selectable=False, wrap=True) + selectable=False, overflow="wrap") ), min_width=1, min_height=1, @@ -36,7 +36,7 @@ class SeatingPlanLegend(Component): content=Column( Text(f"Belegter Platz", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False), Text(f"", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, - selectable=False, wrap=True) + selectable=False, overflow="wrap") ), min_width=1, min_height=1, @@ -51,7 +51,7 @@ class SeatingPlanLegend(Component): content=Column( Text(f"Eigener Platz", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False), Text(f"", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, - selectable=False, wrap=True) + selectable=False, overflow="wrap") ), min_width=1, min_height=1, diff --git a/src/ez_lan_manager/components/SeatingPlanInfoBox.py b/src/ez_lan_manager/components/SeatingPlanInfoBox.py index 63c663c..0096138 100644 --- a/src/ez_lan_manager/components/SeatingPlanInfoBox.py +++ b/src/ez_lan_manager/components/SeatingPlanInfoBox.py @@ -17,21 +17,21 @@ class SeatingPlanInfoBox(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), wrap=True, 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), wrap=True, 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), wrap=True, justify="center"), - Text(f"{self.seat_occupant}", margin_bottom=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.4), wrap=True, 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), wrap=True, 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", margin=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.1), - wrap=True, + overflow="wrap", justify="center" ), shape="rounded", diff --git a/src/ez_lan_manager/components/SeatingPlanPixels.py b/src/ez_lan_manager/components/SeatingPlanPixels.py index ea7189b..01ad113 100644 --- a/src/ez_lan_manager/components/SeatingPlanPixels.py +++ b/src/ez_lan_manager/components/SeatingPlanPixels.py @@ -1,6 +1,6 @@ from functools import partial -from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color, MouseEventListener, Column +from rio import Component, Text, Icon, TextStyle, Rectangle, Spacer, Color, PointerEventListener, Column from typing import Optional, Callable from src.ez_lan_manager.types.Seat import Seat @@ -20,11 +20,11 @@ class SeatPixel(Component): return self.session.theme.success_color def build(self) -> Component: - return MouseEventListener( + return PointerEventListener( content=Rectangle( content=Column( Text(f"{self.seat_id}", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.7), align_x=0.5, selectable=False), - Text(f"{self.seat.category[0]}", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, selectable=False, wrap=True) + Text(f"{self.seat.category[0]}", style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9), align_x=0.5, selectable=False, overflow="wrap") ), min_width=1, min_height=1, diff --git a/src/ez_lan_manager/components/SeatingPurchaseBox.py b/src/ez_lan_manager/components/SeatingPurchaseBox.py index c823996..074aae8 100644 --- a/src/ez_lan_manager/components/SeatingPurchaseBox.py +++ b/src/ez_lan_manager/components/SeatingPurchaseBox.py @@ -29,17 +29,17 @@ class SeatingPurchaseBox(Component): if self.success_msg: return Column( Text(f"{self.success_msg}", margin=1, style=TextStyle(fill=self.session.theme.success_color, font_size=1.1), - wrap=True, justify="center"), + overflow="wrap", justify="center"), Row( Button( Text("Zurück", margin=1, style=TextStyle(fill=self.session.theme.success_color, font_size=1.1), - wrap=True, + overflow="wrap", justify="center" ), shape="rounded", - style="plain", + style="plain-text", on_press=self.cancel_cb ) ), @@ -49,17 +49,17 @@ class SeatingPurchaseBox(Component): if self.error_msg: return Column( Text(f"{self.error_msg}", margin=1, style=TextStyle(fill=self.session.theme.danger_color, font_size=1.1), - wrap=True, justify="center"), + overflow="wrap", justify="center"), Row( Button( Text("Zurück", margin=1, style=TextStyle(fill=self.session.theme.danger_color, font_size=1.1), - wrap=True, + overflow="wrap", justify="center" ), shape="rounded", - style="plain", + style="plain-text", on_press=self.cancel_cb ) ), @@ -67,24 +67,24 @@ class SeatingPurchaseBox(Component): ) return Column( - Text(f"Sitzplatz {self.seat_id} verbindlich buchen?", margin=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.4), wrap=True, justify="center"), + Text(f"Sitzplatz {self.seat_id} verbindlich buchen?", margin=1, style=TextStyle(fill=self.session.theme.neutral_color, font_size=1.4), overflow="wrap", justify="center"), Row( Button( Text("Nein", margin=1, style=TextStyle(fill=self.session.theme.danger_color, font_size=1.1), - wrap=True, + overflow="wrap", justify="center" ), shape="rounded", - style="plain", + style="plain-text", on_press=self.cancel_cb ), Button( Text("Ja", margin=1, style=TextStyle(fill=self.session.theme.success_color, font_size=1.1), - wrap=True, + overflow="wrap", justify="center" ), shape="rounded", diff --git a/src/ez_lan_manager/components/ShoppingCartAndOrders.py b/src/ez_lan_manager/components/ShoppingCartAndOrders.py index afd7f60..dbb7f2e 100644 --- a/src/ez_lan_manager/components/ShoppingCartAndOrders.py +++ b/src/ez_lan_manager/components/ShoppingCartAndOrders.py @@ -1,7 +1,7 @@ from asyncio import sleep, create_task import rio -from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer, Popup, PopupOpenOrCloseEvent +from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer, Popup from src.ez_lan_manager.components.CateringCartItem import CateringCartItem from src.ez_lan_manager.components.CateringOrderItem import CateringOrderItem @@ -111,7 +111,7 @@ class ShoppingCartAndOrders(Component): cart_container, Popup( anchor=cart_container, - content=Text(self.popup_message, style=TextStyle(fill=self.session.theme.danger_color if self.popup_is_error else self.session.theme.success_color), wrap=True, margin=2, justify="center", min_width=20), + content=Text(self.popup_message, style=TextStyle(fill=self.session.theme.danger_color if self.popup_is_error else self.session.theme.success_color), overflow="wrap", margin=2, justify="center", min_width=20), is_open=self.popup_is_shown, position="center", color=self.session.theme.primary_color diff --git a/src/ez_lan_manager/components/TicketBuyCard.py b/src/ez_lan_manager/components/TicketBuyCard.py index ca5d276..2ce608f 100644 --- a/src/ez_lan_manager/components/TicketBuyCard.py +++ b/src/ez_lan_manager/components/TicketBuyCard.py @@ -58,7 +58,7 @@ class TicketBuyCard(Component): Column( Text(self.description, margin_left=1, margin_top=1, style=ticket_description_style), Text("Du besitzt dieses Ticket!", margin_left=1, margin_top=1, style=ticket_owned_style) if self.user_ticket is not None and self.user_ticket.category == self.category else Spacer(), - Text(self.additional_info, margin_left=1, margin_top=1, style=ticket_additional_info_style, wrap=True), + Text(self.additional_info, margin_left=1, margin_top=1, style=ticket_additional_info_style, overflow="wrap"), Row( progress_bar, tickets_side_text, diff --git a/src/ez_lan_manager/pages/Account.py b/src/ez_lan_manager/pages/Account.py index 0006531..7320736 100644 --- a/src/ez_lan_manager/pages/Account.py +++ b/src/ez_lan_manager/pages/Account.py @@ -1,11 +1,9 @@ -from asyncio import sleep from typing import Optional from rio import Column, Component, event, Text, TextStyle, Button, Color, Spacer, Revealer, Row, ProgressCircle from src.ez_lan_manager import ConfigurationService, UserService, AccountingService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.SessionStorage import SessionStorage from src.ez_lan_manager.types.Transaction import Transaction from src.ez_lan_manager.types.User import User @@ -28,18 +26,16 @@ class AccountPage(Component): def build(self) -> Component: if not self.user and not self.balance: - return BasePage( - content=Column( - MainViewContentBox( - ProgressCircle( - color="secondary", - align_x=0.5, - margin_top=2, - margin_bottom=2 - ) - ), - align_y = 0, - ) + return Column( + MainViewContentBox( + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=2, + margin_bottom=2 + ) + ), + align_y=0, ) self.banking_info_revealer = Revealer( @@ -79,9 +75,9 @@ class AccountPage(Component): align_x=0.5 ) ), - margin = 2, - margin_top = 0, - margin_bottom = 1, + margin=2, + margin_top=0, + margin_bottom=1, grow_x=True ) @@ -127,57 +123,55 @@ class AccountPage(Component): ) ) ) - return BasePage( - content=Column( - MainViewContentBox( - content=Text( - f"Kontostand: {AccountingService.make_euro_string_from_int(self.balance)}", + return Column( + MainViewContentBox( + content=Text( + f"Kontostand: {AccountingService.make_euro_string_from_int(self.balance)}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=2, + align_x=0.5 + ) + ), + MainViewContentBox( + content=Column( + Text( + "LAN-Konto aufladen", style=TextStyle( fill=self.session.theme.background_color, font_size=1.2 ), margin=2, align_x=0.5 + ), + Button( + content=Text("BANKÜBERWEISUNG", style=TextStyle(fill=Color.from_hex("121212"), font_size=0.8), justify="center"), + shape="rectangle", + style="major", + color="secondary", + grow_x=True, + margin=2, + margin_top=0, + margin_bottom=1, + on_press=self._on_banking_info_press + ), + self.banking_info_revealer, + Button( + content=Text("PAYPAL", style=TextStyle(fill=Color.from_hex("121212"), font_size=0.8), justify="center"), + shape="rectangle", + style="major", + color="secondary", + grow_x=True, + margin=2, + margin_top=0, + is_sensitive=False ) - ), - MainViewContentBox( - content=Column( - Text( - "LAN-Konto aufladen", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin=2, - align_x=0.5 - ), - Button( - content=Text("BANKÜBERWEISUNG", style=TextStyle(fill=Color.from_hex("121212"), font_size=0.8), justify="center"), - shape="rectangle", - style="major", - color="secondary", - grow_x=True, - margin=2, - margin_top=0, - margin_bottom=1, - on_press=self._on_banking_info_press - ), - self.banking_info_revealer, - Button( - content=Text("PAYPAL", style=TextStyle(fill=Color.from_hex("121212"), font_size=0.8), justify="center"), - shape="rectangle", - style="major", - color="secondary", - grow_x=True, - margin=2, - margin_top=0, - is_sensitive=False - ) - ) - ), - MainViewContentBox( - content=transaction_history - ), - align_y=0, - ) + ) + ), + MainViewContentBox( + content=transaction_history + ), + align_y=0, ) diff --git a/src/ez_lan_manager/pages/BasePage.py b/src/ez_lan_manager/pages/BasePage.py index ea4e1fd..a301058 100644 --- a/src/ez_lan_manager/pages/BasePage.py +++ b/src/ez_lan_manager/pages/BasePage.py @@ -2,14 +2,14 @@ from __future__ import annotations from typing import * # type: ignore -from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text +from rio import Component, event, Spacer, Card, Container, Column, Row, TextStyle, Color, Text, PageView from src.ez_lan_manager import ConfigurationService, DatabaseService from src.ez_lan_manager.components.DesktopNavigation import DesktopNavigation class BasePage(Component): - content: Component - + color = "secondary" + corner_radius = (0, 0.5, 0, 0) @event.periodic(60) async def check_db_conn(self) -> None: is_healthy = await self.session[DatabaseService].is_healthy() @@ -21,15 +21,12 @@ class BasePage(Component): await self.force_refresh() def build(self) -> Component: - if self.content is None: - content = Spacer() - else: - content = Card( - self.content, - color="secondary", - min_width=38, - corner_radius=(0, 0.5, 0, 0) - ) + content = Card( + PageView(), + color="secondary", + min_width=38, + corner_radius=(0, 0.5, 0, 0) + ) if self.session.window_width > 28: return Container( content=Column( diff --git a/src/ez_lan_manager/pages/BuyTicketPage.py b/src/ez_lan_manager/pages/BuyTicketPage.py index 285f332..f1ffaa2 100644 --- a/src/ez_lan_manager/pages/BuyTicketPage.py +++ b/src/ez_lan_manager/pages/BuyTicketPage.py @@ -1,14 +1,10 @@ -from asyncio import sleep -from functools import partial from typing import Optional -from rio import Text, Column, TextStyle, Component, event, TextInput, MultiLineTextInput, Row, Button, Card, Popup +from rio import Text, Column, TextStyle, Component, event, Button, Popup -from src.ez_lan_manager import ConfigurationService, UserService, MailingService, AccountingService, TicketingService -from src.ez_lan_manager.components.AnimatedText import AnimatedText +from src.ez_lan_manager import ConfigurationService, UserService, TicketingService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.components.TicketBuyCard import TicketBuyCard -from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.services.AccountingService import InsufficientFundsError from src.ez_lan_manager.services.TicketingService import TicketNotAvailableError, UserAlreadyHasTicketError from src.ez_lan_manager.types.SessionStorage import SessionStorage @@ -57,7 +53,7 @@ class BuyTicketPage(Component): self.is_popup_success = False except UserAlreadyHasTicketError: self.popup_message = (f"Du besitzt bereits ein Ticket. Um dein aktuelles Ticket zu stornieren, kontaktiere bitte den Support unter " - f"{self.session[ConfigurationService].get_lan_info().organizer_mail}.") + f"{self.session[ConfigurationService].get_lan_info().organizer_mail}.") self.is_popup_success = False except RuntimeError: self.popup_message = "Ein unbekannter Fehler ist aufgetreten." @@ -65,12 +61,10 @@ class BuyTicketPage(Component): self.is_popup_open = True await self.on_populate() - async def on_popup_close_pressed(self) -> None: self.is_popup_open = False self.popup_message = "" - def build(self) -> Component: ticket_infos = self.session[ConfigurationService].get_ticket_info() header = Text( @@ -84,44 +78,41 @@ class BuyTicketPage(Component): align_x=0.5 ) - return BasePage( - content=Column( - MainViewContentBox( - Column( - header, - Popup( - anchor=header, - content=Column( - Text( - self.popup_message, - style=TextStyle(font_size=1.1, fill=self.session.theme.success_color if self.is_popup_success else self.session.theme.danger_color), - wrap=True, - grow_y=True, - margin=1 - ), - Button("Bestätigen", shape="rounded", grow_y=False, on_press=self.on_popup_close_pressed), - min_width=34, - min_height=10 + return Column( + MainViewContentBox( + Column( + header, + Popup( + anchor=header, + content=Column( + Text( + self.popup_message, + style=TextStyle(font_size=1.1, fill=self.session.theme.success_color if self.is_popup_success else self.session.theme.danger_color), + overflow="wrap", + grow_y=True, + margin=1 ), - is_open=self.is_popup_open, - position="bottom", - margin=1, - corner_radius=0.2, - color=self.session.theme.primary_color + Button("Bestätigen", shape="rounded", grow_y=False, on_press=self.on_popup_close_pressed), + min_width=34, + min_height=10 ), - *[TicketBuyCard( - description=t.description, - additional_info=t.additional_info, - price=t.price, - category=t.category, - pressed_cb=self.on_buy_pressed, - is_enabled=self.is_buying_enabled, - total_tickets=t.total_tickets, - user_ticket=self.user_ticket - ) for t in ticket_infos] + is_open=self.is_popup_open, + position="bottom", + margin=1, + corner_radius=0.2, + color=self.session.theme.primary_color ), + *[TicketBuyCard( + description=t.description, + additional_info=t.additional_info, + price=t.price, + category=t.category, + pressed_cb=self.on_buy_pressed, + is_enabled=self.is_buying_enabled, + total_tickets=t.total_tickets, + user_ticket=self.user_ticket + ) for t in ticket_infos] ), - align_y=0 ), - grow_x=True + align_y=0 ) diff --git a/src/ez_lan_manager/pages/CateringPage.py b/src/ez_lan_manager/pages/CateringPage.py index f76bcd2..21041b0 100644 --- a/src/ez_lan_manager/pages/CateringPage.py +++ b/src/ez_lan_manager/pages/CateringPage.py @@ -6,7 +6,6 @@ from src.ez_lan_manager import ConfigurationService, CateringService from src.ez_lan_manager.components.CateringSelectionItem import CateringSelectionItem from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.components.ShoppingCartAndOrders import ShoppingCartAndOrders -from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.CateringMenuItem import CateringMenuItemCategory, CateringMenuItem from src.ez_lan_manager.types.SessionStorage import SessionStorage @@ -24,7 +23,6 @@ class CateringPage(Component): await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Catering") self.all_menu_items = await self.session[CateringService].get_menu() - async def on_user_logged_in_status_changed(self) -> None: await self.force_refresh() @@ -35,7 +33,6 @@ class CateringPage(Component): def get_menu_items_by_category(all_menu_items: list[CateringMenuItem], category: Optional[CateringMenuItemCategory]) -> list[CateringMenuItem]: return list(filter(lambda item: item.category == category, all_menu_items)) - def build(self) -> Component: user_id = self.session[SessionStorage].user_id if len(self.shopping_cart_and_orders) == 0: @@ -73,217 +70,215 @@ class CateringPage(Component): ) if user_id else Spacer() menu = [MainViewContentBox( - ProgressCircle( - color="secondary", - align_x=0.5, - margin_top=2, - margin_bottom=2 - ) - )] if not self.all_menu_items else [MainViewContentBox( - Revealer( - header="Snacks", - content=Column( - *[CateringSelectionItem( - article_name=catering_menu_item.name, - article_price=catering_menu_item.price, - article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders[0].on_add_item, - is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, - additional_info=catering_menu_item.additional_info, - is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.SNACK))], - ), - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin=1, - align_y=0.5 - ) - ), - MainViewContentBox( - Revealer( - header="Frühstück", - content=Column( - *[CateringSelectionItem( - article_name=catering_menu_item.name, - article_price=catering_menu_item.price, - article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders[0].on_add_item, - is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, - additional_info=catering_menu_item.additional_info, - is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BREAKFAST))], - ), - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin=1, - align_y=0.5 - ) - ), - MainViewContentBox( - Revealer( - header="Hauptspeisen", - content=Column( - *[CateringSelectionItem( - article_name=catering_menu_item.name, - article_price=catering_menu_item.price, - article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders[0].on_add_item, - is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, - additional_info=catering_menu_item.additional_info, - is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.MAIN_COURSE))], - ), - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin=1, - align_y=0.5 - ) - ), - MainViewContentBox( - Revealer( - header="Desserts", - content=Column( - *[CateringSelectionItem( - article_name=catering_menu_item.name, - article_price=catering_menu_item.price, - article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders[0].on_add_item, - is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, - additional_info=catering_menu_item.additional_info, - is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.DESSERT))], - ), - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin=1, - align_y=0.5 - ) - ), - MainViewContentBox( - Revealer( - header="Wasser & Softdrinks", - content=Column( - *[CateringSelectionItem( - article_name=catering_menu_item.name, - article_price=catering_menu_item.price, - article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders[0].on_add_item, - is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, - additional_info=catering_menu_item.additional_info, - is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC))], - ), - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin=1, - align_y=0.5 - ) - ), - MainViewContentBox( - Revealer( - header="Alkoholische Getränke", - content=Column( - *[CateringSelectionItem( - article_name=catering_menu_item.name, - article_price=catering_menu_item.price, - article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders[0].on_add_item, - is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, - additional_info=catering_menu_item.additional_info, - is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC))], - ), - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin=1, - align_y=0.5 - ) - ), - MainViewContentBox( - Revealer( - header="Cocktails & Longdrinks", - content=Column( - *[CateringSelectionItem( - article_name=catering_menu_item.name, - article_price=catering_menu_item.price, - article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders[0].on_add_item, - is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, - additional_info=catering_menu_item.additional_info, - is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_COCKTAIL))], - ), - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin=1, - align_y=0.5 - ) - ), - MainViewContentBox( - Revealer( - header="Shots", - content=Column( - *[CateringSelectionItem( - article_name=catering_menu_item.name, - article_price=catering_menu_item.price, - article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders[0].on_add_item, - is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, - additional_info=catering_menu_item.additional_info, - is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_SHOT))], - ), - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin=1, - align_y=0.5 - ) - ), - MainViewContentBox( - Revealer( - header="Sonstiges", - content=Column( - *[CateringSelectionItem( - article_name=catering_menu_item.name, - article_price=catering_menu_item.price, - article_id=catering_menu_item.item_id, - on_add_callback=self.shopping_cart_and_orders[0].on_add_item, - is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, - additional_info=catering_menu_item.additional_info, - is_grey=idx % 2 == 0 - ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.NON_FOOD))], - ), - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin=1, - align_y=0.5 - ) - )] - - return BasePage( - content=Column( - # SHOPPING CART - shopping_cart_and_orders_container, - # ITEM SELECTION - *menu, - align_y=0 + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=2, + margin_bottom=2 ) + )] if not self.all_menu_items else [MainViewContentBox( + Revealer( + header="Snacks", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.SNACK))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Frühstück", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BREAKFAST))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Hauptspeisen", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.MAIN_COURSE))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Desserts", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.DESSERT))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Wasser & Softdrinks", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_NON_ALCOHOLIC))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Alkoholische Getränke", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_ALCOHOLIC))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Cocktails & Longdrinks", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_COCKTAIL))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Shots", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.BEVERAGE_SHOT))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + ), + MainViewContentBox( + Revealer( + header="Sonstiges", + content=Column( + *[CateringSelectionItem( + article_name=catering_menu_item.name, + article_price=catering_menu_item.price, + article_id=catering_menu_item.item_id, + on_add_callback=self.shopping_cart_and_orders[0].on_add_item, + is_sensitive=(user_id is not None) and not catering_menu_item.is_disabled, + additional_info=catering_menu_item.additional_info, + is_grey=idx % 2 == 0 + ) for idx, catering_menu_item in enumerate(self.get_menu_items_by_category(self.all_menu_items, CateringMenuItemCategory.NON_FOOD))], + ), + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin=1, + align_y=0.5 + ) + )] + + return Column( + # SHOPPING CART + shopping_cart_and_orders_container, + # ITEM SELECTION + *menu, + align_y=0 ) diff --git a/src/ez_lan_manager/pages/ContactPage.py b/src/ez_lan_manager/pages/ContactPage.py index 6fdb7d9..65ee183 100644 --- a/src/ez_lan_manager/pages/ContactPage.py +++ b/src/ez_lan_manager/pages/ContactPage.py @@ -6,7 +6,6 @@ from rio import Text, Column, TextStyle, Component, event, TextInput, MultiLineT from src.ez_lan_manager import ConfigurationService, UserService, MailingService from src.ez_lan_manager.components.AnimatedText import AnimatedText from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.SessionStorage import SessionStorage from src.ez_lan_manager.types.User import User @@ -59,9 +58,9 @@ class ContactPage(Component): def build(self) -> Component: self.animated_text = AnimatedText( - margin_top = 2, - margin_bottom = 1, - align_x = 0.1 + margin_top=2, + margin_bottom=1, + align_x=0.1 ) self.email_input = TextInput( @@ -105,30 +104,27 @@ class ContactPage(Component): color="primary", on_press=self.on_send_pressed ) - return BasePage( - content=Column( - MainViewContentBox( - Column( - Text( - text="Kontakt", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=1, - align_x=0.5 + return Column( + MainViewContentBox( + Column( + Text( + text="Kontakt", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 ), - self.email_input, - self.subject_input, - self.message_input, - Row( - self.animated_text, - self.submit_button, - ) + margin_top=2, + margin_bottom=1, + align_x=0.5 + ), + self.email_input, + self.subject_input, + self.message_input, + Row( + self.animated_text, + self.submit_button, ) - ), - align_y=0 + ) ), - grow_x=True + align_y=0 ) diff --git a/src/ez_lan_manager/pages/DbErrorPage.py b/src/ez_lan_manager/pages/DbErrorPage.py index 2f5a125..4a4e7f2 100644 --- a/src/ez_lan_manager/pages/DbErrorPage.py +++ b/src/ez_lan_manager/pages/DbErrorPage.py @@ -34,7 +34,7 @@ class DbErrorPage(Component): fill=self.session.theme.danger_color, font_size=1.3 ), - wrap=True + overflow="wrap" ) ), color="secondary", diff --git a/src/ez_lan_manager/pages/EditProfile.py b/src/ez_lan_manager/pages/EditProfile.py index 4543337..7bb8aab 100644 --- a/src/ez_lan_manager/pages/EditProfile.py +++ b/src/ez_lan_manager/pages/EditProfile.py @@ -10,7 +10,6 @@ from email_validator import validate_email, EmailNotValidError from src.ez_lan_manager import ConfigurationService, UserService from src.ez_lan_manager.components.AnimatedText import AnimatedText from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.SessionStorage import SessionStorage from src.ez_lan_manager.types.User import User @@ -18,6 +17,7 @@ from src.ez_lan_manager.types.User import User class EditProfilePage(Component): user: Optional[User] = None pfp: Optional[bytes] = None + @staticmethod def optional_date_to_str(d: Optional[date]) -> str: if not d: @@ -96,18 +96,16 @@ class EditProfilePage(Component): def build(self) -> Component: if not self.user: - return BasePage( - content=Column( - MainViewContentBox( - ProgressCircle( - color="secondary", - align_x=0.5, - margin_top=2, - margin_bottom=2 - ) - ), - align_y = 0 - ) + return Column( + MainViewContentBox( + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=2, + margin_bottom=2 + ) + ), + align_y=0 ) self.animated_text = AnimatedText( @@ -175,57 +173,55 @@ class EditProfilePage(Component): margin_bottom=1 ) - return BasePage( - content=Column( - MainViewContentBox( - content=Column( - self.pfp_image_container, + return Column( + MainViewContentBox( + content=Column( + self.pfp_image_container, + Button( + content=Text( + "Neues Bild hochladen", + style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + ), + align_x=0.5, + margin_bottom=1, + shape="rectangle", + style="major", + color="primary", + on_press=self.upload_new_pfp + ), + Row( + TextInput(label="Deine User-ID", text=self.user.user_id, is_sensitive=False, margin_left=1, grow_x=False), + TextInput(label="Dein Nickname", text=self.user.user_name, is_sensitive=False, margin_left=1, margin_right=1, grow_x=True), + margin_bottom=1 + ), + self.email_input, + Row( + self.first_name_input, + self.last_name_input, + margin_bottom=1 + ), + self.birthday_input, + self.new_pw_1_input, + self.new_pw_2_input, + + Row( + self.animated_text, Button( content=Text( - "Neues Bild hochladen", - style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + "Speichern", + style=TextStyle(fill=self.session.theme.success_color, font_size=0.9), + align_x=0.2 ), - align_x=0.5, + align_x=0.9, + margin_top=2, margin_bottom=1, shape="rectangle", style="major", color="primary", - on_press=self.upload_new_pfp + on_press=self.on_save_pressed ), - Row( - TextInput(label="Deine User-ID", text=self.user.user_id, is_sensitive=False, margin_left=1, grow_x=False), - TextInput(label="Dein Nickname", text=self.user.user_name, is_sensitive=False, margin_left=1, margin_right=1, grow_x=True), - margin_bottom=1 - ), - self.email_input, - Row( - self.first_name_input, - self.last_name_input, - margin_bottom=1 - ), - self.birthday_input, - self.new_pw_1_input, - self.new_pw_2_input, - - Row( - self.animated_text, - Button( - content=Text( - "Speichern", - style=TextStyle(fill=self.session.theme.success_color, font_size=0.9), - align_x=0.2 - ), - align_x=0.9, - margin_top=2, - margin_bottom=1, - shape="rectangle", - style="major", - color="primary", - on_press=self.on_save_pressed - ), - ) - ) - ), - align_y=0, - ) + ) + ) + ), + align_y=0, ) diff --git a/src/ez_lan_manager/pages/FaqPage.py b/src/ez_lan_manager/pages/FaqPage.py index 8b652c4..72a39c7 100644 --- a/src/ez_lan_manager/pages/FaqPage.py +++ b/src/ez_lan_manager/pages/FaqPage.py @@ -2,63 +2,69 @@ from rio import Column, Component, event, TextStyle, Text, Revealer from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage FAQ: list[list[str]] = [ - ["Wie melde ich mich für die LAN an?", "Registriere dich auf dieser Seite, lade dein Guthabenkonto auf und kaufe ein Ticket. Danach such dir einen freien Sitzplatz auf dem Sitzplan aus."], - ["Wie lade ich mein Guthabenkonto auf?", "Logge dich in deinen Account ein und klicke auf die Schaltfläche 'Guthaben' in der Navigationsleiste. Dort findest du alle weiteren Informationen."], + ["Wie melde ich mich für die LAN an?", + "Registriere dich auf dieser Seite, lade dein Guthabenkonto auf und kaufe ein Ticket. Danach such dir einen freien Sitzplatz auf dem Sitzplan aus."], + ["Wie lade ich mein Guthabenkonto auf?", + "Logge dich in deinen Account ein und klicke auf die Schaltfläche 'Guthaben' in der Navigationsleiste. Dort findest du alle weiteren Informationen."], ["Wie kann ich mein Ticket stornieren?", "Schreibe uns eine Mail an tech@ezgg-ev.de, wir kümmern uns dann Zeitnah um die Stornierung."], - ["Was soll ich zur LAN mitbringen?", "Deinen PC inklusive aller zugehörigen Geräte (Maus, Tastatur, Monitor, Headset), sowie aller Anschlusskabel. Wir empfehlen ein LAN Kabel von mindestens 5 Metern Länge mitzubringen. Des weiteren benötigste du eine Mehrfachsteckdose, da dir an deinem Platz nur ein einzelner Steckplatz zugewiesen wird."], + ["Was soll ich zur LAN mitbringen?", + "Deinen PC inklusive aller zugehörigen Geräte (Maus, Tastatur, Monitor, Headset), sowie aller Anschlusskabel. Wir empfehlen ein LAN Kabel von mindestens 5 Metern Länge mitzubringen. Des weiteren benötigste du eine Mehrfachsteckdose, da dir an deinem Platz nur ein einzelner Steckplatz zugewiesen wird."], ["Wohin mit technischen Problemen?", "Melde dich einfach am Einlass bzw in der Orga-Ecke, wir helfen gerne weiter."], ["Wo entsorge ich meinen Müll?", "Im gesamten Veranstaltungsgebäude findest du Mülltüten/Mülleimer."], ["Darf ich Cannabis konsumieren?", "Generell verbieten wir den Konsum von Cannabis nicht. Beachte aber die allgemeine Gesetzeslage und ziehe ggf. die Bubatzkarte zu Rat."], - ["Gibt es einen Discord oder TeamSpeak?", "Du kannst gerne unseren Vereins-TeamSpeak3-Server unter ts3.ezgg-ev.de nutzen. Den Link zum offiziellen Discord findest du in der Navigationsleiste."], - ["Wo bleibt mein Essen?", "Vermutlich ist es auf dem Weg. Du kannst auf der Catering-Seite den Status deiner Bestellung überprüfen. Hast du Bedenken das sie verloren gegangen sein könnte, sprich ein Team-Mitglied an der Theke darauf an."], - ["Wie lange dauert eine Aufladung per Überweißung?", "In der Regel wird das Guthaben deinem Konto innerhalb von 2 bis 3 Werktagen gutgeschrieben. In Ausnahmefällen kann es bis zu 7 Tagen dauern."], - ["Wie melde ich meinen Clan an?", "Wenn in deiner Gruppe mehr als 3 Personen sind, dann schreib uns bitte eine Mail mit dem Betreff 'Gruppenticket' an tech@ezgg-ev.de. Schreibe uns dort die Nutzer-ID's sowie die Sitzplätze deiner Gruppe auf. Gehe sicher das jede Person in deiner Gruppe entweder bereits ein passendes Ticket besitzt oder über genug Guthaben verfügt um ein Ticket zu kaufen."], - ["Wo kann ich schlafen?", "Im Veranstaltungsgebäude sind offizielle Schlafbereiche ausgewiesen. Solange du keine Zugangs-, Durchgangs-, oder Rettungswege blockierst, darfst du überall schlafen."] + ["Gibt es einen Discord oder TeamSpeak?", + "Du kannst gerne unseren Vereins-TeamSpeak3-Server unter ts3.ezgg-ev.de nutzen. Den Link zum offiziellen Discord findest du in der Navigationsleiste."], + ["Wo bleibt mein Essen?", + "Vermutlich ist es auf dem Weg. Du kannst auf der Catering-Seite den Status deiner Bestellung überprüfen. Hast du Bedenken das sie verloren gegangen sein könnte, sprich ein Team-Mitglied an der Theke darauf an."], + ["Wie lange dauert eine Aufladung per Überweißung?", + "In der Regel wird das Guthaben deinem Konto innerhalb von 2 bis 3 Werktagen gutgeschrieben. In Ausnahmefällen kann es bis zu 7 Tagen dauern."], + ["Wie melde ich meinen Clan an?", + "Wenn in deiner Gruppe mehr als 3 Personen sind, dann schreib uns bitte eine Mail mit dem Betreff 'Gruppenticket' an tech@ezgg-ev.de. Schreibe uns dort die Nutzer-ID's sowie die Sitzplätze deiner Gruppe auf. Gehe sicher das jede Person in deiner Gruppe entweder bereits ein passendes Ticket besitzt oder über genug Guthaben verfügt um ein Ticket zu kaufen."], + ["Wo kann ich schlafen?", + "Im Veranstaltungsgebäude sind offizielle Schlafbereiche ausgewiesen. Solange du keine Zugangs-, Durchgangs-, oder Rettungswege blockierst, darfst du überall schlafen."] ] + class FaqPage(Component): @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - FAQ") def build(self) -> Component: - return BasePage( - content=Column( - MainViewContentBox( - Column( - Text( - text="FAQ", + return Column( + MainViewContentBox( + Column( + Text( + text="FAQ", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + *[Revealer( + header=question, + content=Text( + text=answer, style=TextStyle( fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=0, - align_x=0.5 - ), - *[Revealer( - header=question, - content=Text( - text=answer, - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.9 - ), - margin=1, - wrap=True + font_size=0.9 ), margin=1, - grow_x=True, - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ) - ) for question, answer in FAQ] - ) - ), - align_y=0 - ) + overflow="wrap" + ), + margin=1, + grow_x=True, + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ) + ) for question, answer in FAQ] + ) + ), + align_y=0 ) diff --git a/src/ez_lan_manager/pages/ForgotPassword.py b/src/ez_lan_manager/pages/ForgotPassword.py index eafba10..aa585c7 100644 --- a/src/ez_lan_manager/pages/ForgotPassword.py +++ b/src/ez_lan_manager/pages/ForgotPassword.py @@ -6,7 +6,7 @@ from rio import Column, Component, event, Text, TextStyle, TextInput, TextInputC from src.ez_lan_manager import ConfigurationService, UserService, MailingService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage + class ForgotPasswordPage(Component): def on_email_changed(self, change_event: TextInputChangeEvent) -> None: @@ -85,27 +85,25 @@ class ForgotPasswordPage(Component): margin_left=1, margin_right=1, margin_bottom=2, - wrap=True + overflow="wrap" ) - return BasePage( - content=Column( - MainViewContentBox( - content=Column( - Text( - "Passwort vergessen", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=2, - align_x=0.5 + return Column( + MainViewContentBox( + content=Column( + Text( + "Passwort vergessen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 ), - self.email_input, - self.submit_button, - self.info_text - ) - ), - align_y=0, - ) + margin_top=2, + margin_bottom=2, + align_x=0.5 + ), + self.email_input, + self.submit_button, + self.info_text + ) + ), + align_y=0, ) diff --git a/src/ez_lan_manager/pages/GuestsPage.py b/src/ez_lan_manager/pages/GuestsPage.py index bb35ce1..d679c86 100644 --- a/src/ez_lan_manager/pages/GuestsPage.py +++ b/src/ez_lan_manager/pages/GuestsPage.py @@ -4,7 +4,6 @@ from rio import Column, Component, event, TextStyle, Text, Button, Row, TextInpu from src.ez_lan_manager import ConfigurationService, UserService, TicketingService, SeatingService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.Seat import Seat from src.ez_lan_manager.types.User import User @@ -51,46 +50,45 @@ class GuestsPage(Component): seat = None self.table_elements.append( Button( - content=Row(Text(text=f"{user.user_id:0>4}", align_x=0, margin_right=1), Text(text=user.user_name, grow_x=True, wrap="ellipsize"), Text(text="-" if seat is None else seat.seat_id, align_x=1)), + content=Row(Text(text=f"{user.user_id:0>4}", align_x=0, margin_right=1), Text(text=user.user_name, grow_x=True, overflow="ellipsize"), + Text(text="-" if seat is None else seat.seat_id, align_x=1)), shape="rectangle", grow_x=True, color=self.session.theme.hud_color if idx % 2 == 0 else self.session.theme.primary_color ) ) - return BasePage( - content=Column( - MainViewContentBox( - Column( - Text( - text="Teilnehmer", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=2, - align_x=0.5 + return Column( + MainViewContentBox( + Column( + Text( + text="Teilnehmer", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 ), - TextInput( - label="Suche nach Name oder ID", - margin=1, - margin_left=3, - margin_right=3, - on_change=self.on_searchbar_content_change - ), - Button( - content=Row(Text(text="ID ", align_x=0, margin_right=1), Text(text="Benutzername", grow_x=True), Text(text="Sitzplatz", align_x=1)), - shape="rectangle", - grow_x=True, - color=self.session.theme.primary_color, - style="plain", - is_sensitive=False - ), - *self.table_elements, - Spacer(min_height=1) - ) - ), - align_y=0 - ) + margin_top=2, + margin_bottom=2, + align_x=0.5 + ), + TextInput( + label="Suche nach Name oder ID", + margin=1, + margin_left=3, + margin_right=3, + on_change=self.on_searchbar_content_change + ), + Button( + content=Row(Text(text="ID ", align_x=0, margin_right=1), Text(text="Benutzername", grow_x=True), Text(text="Sitzplatz", align_x=1)), + shape="rectangle", + grow_x=True, + color=self.session.theme.primary_color, + style="plain-text", + is_sensitive=False + ), + *self.table_elements, + Spacer(min_height=1) + ) + ), + align_y=0 ) diff --git a/src/ez_lan_manager/pages/ImprintPage.py b/src/ez_lan_manager/pages/ImprintPage.py index eba4be5..bc713da 100644 --- a/src/ez_lan_manager/pages/ImprintPage.py +++ b/src/ez_lan_manager/pages/ImprintPage.py @@ -2,7 +2,7 @@ from rio import Text, Column, TextStyle, Component, event, Link, Color from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage + class ImprintPage(Component): @event.on_populate @@ -10,98 +10,95 @@ class ImprintPage(Component): await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Impressum & DSGVO") def build(self) -> Component: - return BasePage( - content=Column( - MainViewContentBox( - Column( - Text( - text="Impressum", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - align_x=0.5 + return Column( + MainViewContentBox( + Column( + Text( + text="Impressum", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 ), - Text( - text="Angaben gemäß § 5 TMG:\n\n" - "Einfach Zockem Gaming Gesellschaft e.V.\n" - "Im Elchgrund 18\n" - "35080 Bad Endbach - Bottenhorn\n\n" - - "Vertreten durch:\n\n" - - "1. Vorsitzender: David Rodenkirchen\n" - "2. Vorsitzender: Julia Albring\n" - "Schatzmeisterin: Jessica Rodenkirchen\n\n" - - "Kontakt:\n\n" - - "E-Mail: vorstand (at) ezgg-ev.de\n\n" - - "Registereintrag:\n\n" - - "Eingetragen im Vereinsregister.\n" - "Registergericht: Amtsgericht Marburg\n" - "Registernummer: VR 5837\n\n" - - "Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:\n\n" - - "David Rodenkirchen\n" - "Im Elchgrund 18\n" - "35080 Bad Endbach - Bottenhorn\n", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.9 - ), - margin=2, - wrap=True - ) + margin_top=2, + align_x=0.5 + ), + Text( + text="Angaben gemäß § 5 TMG:\n\n" + "Einfach Zockem Gaming Gesellschaft e.V.\n" + "Im Elchgrund 18\n" + "35080 Bad Endbach - Bottenhorn\n\n" + + "Vertreten durch:\n\n" + + "1. Vorsitzender: David Rodenkirchen\n" + "2. Vorsitzender: Julia Albring\n" + "Schatzmeisterin: Jessica Rodenkirchen\n\n" + + "Kontakt:\n\n" + + "E-Mail: vorstand (at) ezgg-ev.de\n\n" + + "Registereintrag:\n\n" + + "Eingetragen im Vereinsregister.\n" + "Registergericht: Amtsgericht Marburg\n" + "Registernummer: VR 5837\n\n" + + "Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:\n\n" + + "David Rodenkirchen\n" + "Im Elchgrund 18\n" + "35080 Bad Endbach - Bottenhorn\n", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 + ), + margin=2, + overflow="wrap" ) - ), - MainViewContentBox( - Column( - Text( + ) + ), + MainViewContentBox( + Column( + Text( + text="Datenschutzerklärung", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + align_x=0.5 + ), + Text( + text="Die Datenschutzerklärung kann über den untenstehenden Link eingesehen werden", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 + ), + margin_top=2, + margin_bottom=0, + overflow="wrap", + align_x=0.5, + grow_x=True, + min_width=30 + ), + Link( + content=Text( text="Datenschutzerklärung", style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 + fill=Color.from_hex("000080"), + font_size=0.9, + underlined=True ), - margin_top=2, + margin_bottom=1, + margin_top=1, + overflow="wrap", align_x=0.5 ), - Text( - text="Die Datenschutzerklärung kann über den untenstehenden Link eingesehen werden", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.9 - ), - margin_top=2, - margin_bottom=0, - wrap=True, - align_x=0.5, - grow_x=True, - min_width=30 - ), - Link( - content=Text( - text="Datenschutzerklärung", - style=TextStyle( - fill=Color.from_hex("000080"), - font_size=0.9, - underlined=True - ), - margin_bottom=1, - margin_top=1, - wrap=True, - align_x=0.5 - ), - target_url="https://ezgg-ev.de/privacy", - open_in_new_tab=True - ) + target_url="https://ezgg-ev.de/privacy", + open_in_new_tab=True ) - ), - align_y=0 + ) ), - grow_x=True + align_y=0 ) diff --git a/src/ez_lan_manager/pages/NewsPage.py b/src/ez_lan_manager/pages/NewsPage.py index 1d83807..c2d96ae 100644 --- a/src/ez_lan_manager/pages/NewsPage.py +++ b/src/ez_lan_manager/pages/NewsPage.py @@ -2,7 +2,6 @@ from rio import Column, Component, event from src.ez_lan_manager import ConfigurationService, NewsService from src.ez_lan_manager.components.NewsPost import NewsPost -from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.types.News import News @@ -22,9 +21,7 @@ class NewsPage(Component): date=news.news_date.strftime("%d.%m.%Y"), author=news.author.user_name ) for news in self.news_posts] - return BasePage( - content=Column( + return Column( *posts, align_y=0, ) - ) diff --git a/src/ez_lan_manager/pages/PlaceholderPage.py b/src/ez_lan_manager/pages/PlaceholderPage.py index ee54c88..560333b 100644 --- a/src/ez_lan_manager/pages/PlaceholderPage.py +++ b/src/ez_lan_manager/pages/PlaceholderPage.py @@ -2,7 +2,7 @@ from rio import Column, Component, event from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.NewsPost import NewsPost -from src.ez_lan_manager.pages import BasePage + class PlaceholderPage(Component): placeholder_name: str @@ -12,13 +12,11 @@ class PlaceholderPage(Component): await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.placeholder_name}") def build(self) -> Component: - return BasePage( - content=Column( - NewsPost( - title="Platzhalter", - text=f"Dies ist die Platzhalterseite für {self.placeholder_name}.", - date="99.99.9999" - ), - align_y=0, - ) + return Column( + NewsPost( + title="Platzhalter", + text=f"Dies ist die Platzhalterseite für {self.placeholder_name}.", + date="99.99.9999" + ), + align_y=0, ) diff --git a/src/ez_lan_manager/pages/RegisterPage.py b/src/ez_lan_manager/pages/RegisterPage.py index 1ee6d66..451a243 100644 --- a/src/ez_lan_manager/pages/RegisterPage.py +++ b/src/ez_lan_manager/pages/RegisterPage.py @@ -6,12 +6,12 @@ from rio import Column, Component, event, Text, TextStyle, TextInput, TextInputC from src.ez_lan_manager import ConfigurationService, UserService, MailingService from src.ez_lan_manager.components.AnimatedText import AnimatedText from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage MINIMUM_PASSWORD_LENGTH = 6 logger = logging.getLogger(__name__.split(".")[-1]) + class RegisterPage(Component): def on_pw_change(self, _: TextInputChangeEvent) -> None: if not (self.pw_1.text == self.pw_2.text) or len(self.pw_1.text) < MINIMUM_PASSWORD_LENGTH: @@ -21,7 +21,6 @@ class RegisterPage(Component): self.pw_1.is_valid = True self.pw_2.is_valid = True - def on_email_changed(self, change_event: TextInputChangeEvent) -> None: try: validate_email(change_event.text, check_deliverability=False) @@ -154,28 +153,26 @@ class RegisterPage(Component): margin_right=1, margin_bottom=2 ) - return BasePage( - content=Column( - MainViewContentBox( - content=Column( - Text( - "Neues Konto anlegen", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=2, - align_x=0.5 + return Column( + MainViewContentBox( + content=Column( + Text( + "Neues Konto anlegen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 ), - self.user_name_input, - self.email_input, - self.pw_1, - self.pw_2, - self.submit_button, - self.animated_text - ) - ), - align_y=0, - ) + margin_top=2, + margin_bottom=2, + align_x=0.5 + ), + self.user_name_input, + self.email_input, + self.pw_1, + self.pw_2, + self.submit_button, + self.animated_text + ) + ), + align_y=0, ) diff --git a/src/ez_lan_manager/pages/RulesPage.py b/src/ez_lan_manager/pages/RulesPage.py index 5077c55..e699fb0 100644 --- a/src/ez_lan_manager/pages/RulesPage.py +++ b/src/ez_lan_manager/pages/RulesPage.py @@ -2,7 +2,6 @@ from rio import Column, Component, event, TextStyle, Text, Revealer from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage RULES: list[str] = [ "Respektvolles Verhalten: Sei höflich und respektvoll gegenüber anderen Gästen und dem Team.", @@ -44,151 +43,150 @@ AGB: dict[str, list[str]] = { ] } + class RulesPage(Component): @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Regeln & AGB") def build(self) -> Component: - return BasePage( - content=Column( - MainViewContentBox( - Column( - Text( - text="Regeln", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=0, - align_x=0.5 + return Column( + MainViewContentBox( + Column( + Text( + text="Regeln", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 ), - Text( - text="(AGB's in verständlichem deutsch)", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.5 - ), - margin_top=0.5, - margin_bottom=2, - align_x=0.5 + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="(AGB's in verständlichem deutsch)", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.5 ), - *[Text( - f"{idx + 1}. {rule}", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.9 - ), - margin_bottom=0.8, - margin_left=1, - margin_right=1, - wrap=True - ) for idx, rule in enumerate(RULES)], - ) - ), - MainViewContentBox( - Column( - Text( - text="AGB", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=1, - align_x=0.5 + margin_top=0.5, + margin_bottom=2, + align_x=0.5 + ), + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 ), - Revealer( - header="§ 1 Allgemeine Bestimmungen", - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1 - ), - margin=1, - margin_top=2, - content=Column( - *[Text( - f"{idx + 1}. {rule}", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ), - margin_bottom=0.8, - margin_left=1, - margin_right=1, - wrap=True - ) for idx, rule in enumerate(AGB["§1"])] - ) + margin_bottom=0.8, + margin_left=1, + margin_right=1, + overflow="wrap" + ) for idx, rule in enumerate(RULES)], + ) + ), + MainViewContentBox( + Column( + Text( + text="AGB", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 ), - Revealer( - header="§ 2 Teilnahmevoraussetzungen", - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1 - ), - margin=1, - margin_top=0, - content=Column( - *[Text( - f"{idx + 1}. {rule}", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ), - margin_bottom=0.8, - margin_left=1, - margin_right=1, - wrap=True - ) for idx, rule in enumerate(AGB["§2"])] - ) + margin_top=2, + margin_bottom=1, + align_x=0.5 + ), + Revealer( + header="§ 1 Allgemeine Bestimmungen", + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 ), - Revealer( - header="§ 3 Verhaltensregeln", - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1 - ), - margin=1, - margin_top=0, - content=Column( - *[Text( - f"{idx + 1}. {rule}", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ), - margin_bottom=0.8, - margin_left=1, - margin_right=1, - wrap=True - ) for idx, rule in enumerate(AGB["§3"])] - ) + margin=1, + margin_top=2, + content=Column( + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + overflow="wrap" + ) for idx, rule in enumerate(AGB["§1"])] + ) + ), + Revealer( + header="§ 2 Teilnahmevoraussetzungen", + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 ), - Revealer( - header="§ 4 Internetzugang", - header_style=TextStyle( - fill=self.session.theme.background_color, - font_size=1 - ), - margin=1, - margin_top=0, - content=Column( - *[Text( - f"{idx + 1}. {rule}", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.8 - ), - margin_bottom=0.8, - margin_left=1, - margin_right=1, - wrap=True - ) for idx, rule in enumerate(AGB["§4"])] - ) + margin=1, + margin_top=0, + content=Column( + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + overflow="wrap" + ) for idx, rule in enumerate(AGB["§2"])] + ) + ), + Revealer( + header="§ 3 Verhaltensregeln", + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin=1, + margin_top=0, + content=Column( + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + overflow="wrap" + ) for idx, rule in enumerate(AGB["§3"])] + ) + ), + Revealer( + header="§ 4 Internetzugang", + header_style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin=1, + margin_top=0, + content=Column( + *[Text( + f"{idx + 1}. {rule}", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.8 + ), + margin_bottom=0.8, + margin_left=1, + margin_right=1, + overflow="wrap" + ) for idx, rule in enumerate(AGB["§4"])] ) ) - ), - align_y=0 - ) + ) + ), + align_y=0 ) diff --git a/src/ez_lan_manager/pages/SeatingPlanPage.py b/src/ez_lan_manager/pages/SeatingPlanPage.py index 68c7045..058aba4 100644 --- a/src/ez_lan_manager/pages/SeatingPlanPage.py +++ b/src/ez_lan_manager/pages/SeatingPlanPage.py @@ -2,22 +2,21 @@ import logging from asyncio import sleep from typing import Optional -from rio import Text, Column, TextStyle, Component, event, PressEvent, ProgressCircle, Row, Image, Button, Spacer +from rio import Text, Column, TextStyle, Component, event, PressEvent, ProgressCircle from src.ez_lan_manager import ConfigurationService, SeatingService, TicketingService, UserService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox from src.ez_lan_manager.components.SeatingPlan import SeatingPlan, SeatingPlanLegend from src.ez_lan_manager.components.SeatingPlanInfoBox import SeatingPlanInfoBox from src.ez_lan_manager.components.SeatingPurchaseBox import SeatingPurchaseBox -from src.ez_lan_manager.pages import BasePage from src.ez_lan_manager.services.SeatingService import NoTicketError, SeatNotFoundError, WrongCategoryError, SeatAlreadyTakenError from src.ez_lan_manager.types.Seat import Seat from src.ez_lan_manager.types.SessionStorage import SessionStorage from src.ez_lan_manager.types.User import User - logger = logging.getLogger(__name__.split(".")[-1]) + class SeatingPlanPage(Component): seating_info: Optional[list[Seat]] = None current_seat_id: Optional[str] = None @@ -94,7 +93,6 @@ class SeatingPlanPage(Component): self.purchase_box_loading = False await self.on_populate() - async def on_purchase_cancelled(self) -> None: self.purchase_box_loading = False self.show_info_box = True @@ -102,47 +100,43 @@ class SeatingPlanPage(Component): self.purchase_box_error_msg = None self.purchase_box_success_msg = None - def build(self) -> Component: if not self.seating_info: - return BasePage( - content=Column( - MainViewContentBox( - ProgressCircle( - color="secondary", - align_x=0.5, - margin_top=2, - margin_bottom=2 - ) - ), - align_y=0 - ) - ) - return BasePage( - content=Column( + return Column( MainViewContentBox( - Column( - SeatingPlanInfoBox(seat_id=self.current_seat_id, seat_occupant=self.current_seat_occupant, seat_price=self.current_seat_price, - is_blocked=self.current_seat_is_blocked, is_booking_blocked=self.is_booking_blocked, show=self.show_info_box, purchase_cb=self.on_purchase_clicked), - SeatingPurchaseBox( - show=self.show_purchase_box, - seat_id=self.current_seat_id, - is_loading=self.purchase_box_loading, - confirm_cb=self.on_purchase_confirmed, - cancel_cb=self.on_purchase_cancelled, - error_msg=self.purchase_box_error_msg, - success_msg=self.purchase_box_success_msg - ) + ProgressCircle( + color="secondary", + align_x=0.5, + margin_top=2, + margin_bottom=2 ) ), - MainViewContentBox( - SeatingPlan(seat_clicked_cb=self.on_seat_clicked, seating_info=self.seating_info) if self.seating_info else - Column(ProgressCircle(color=self.session.theme.secondary_color, margin=3), Text("Sitzplan wird geladen", style=TextStyle(fill=self.session.theme.neutral_color), align_x=0.5, margin=1)) - ), - MainViewContentBox( - SeatingPlanLegend(), - ), align_y=0 + ) + return Column( + MainViewContentBox( + Column( + SeatingPlanInfoBox(seat_id=self.current_seat_id, seat_occupant=self.current_seat_occupant, seat_price=self.current_seat_price, + is_blocked=self.current_seat_is_blocked, is_booking_blocked=self.is_booking_blocked, show=self.show_info_box, + purchase_cb=self.on_purchase_clicked), + SeatingPurchaseBox( + show=self.show_purchase_box, + seat_id=self.current_seat_id, + is_loading=self.purchase_box_loading, + confirm_cb=self.on_purchase_confirmed, + cancel_cb=self.on_purchase_cancelled, + error_msg=self.purchase_box_error_msg, + success_msg=self.purchase_box_success_msg + ) + ) ), - grow_x=True + MainViewContentBox( + SeatingPlan(seat_clicked_cb=self.on_seat_clicked, seating_info=self.seating_info) if self.seating_info else + Column(ProgressCircle(color=self.session.theme.secondary_color, margin=3), + Text("Sitzplan wird geladen", style=TextStyle(fill=self.session.theme.neutral_color), align_x=0.5, margin=1)) + ), + MainViewContentBox( + SeatingPlanLegend(), + ), + align_y=0 ) diff --git a/src/ez_lan_manager/pages/TEMPLATE.py b/src/ez_lan_manager/pages/TEMPLATE.py index 1380947..64c52a1 100644 --- a/src/ez_lan_manager/pages/TEMPLATE.py +++ b/src/ez_lan_manager/pages/TEMPLATE.py @@ -2,7 +2,7 @@ from rio import Column, Component, event, TextStyle, Text from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage + class PAGENAME(Component): @event.on_populate @@ -10,31 +10,29 @@ class PAGENAME(Component): await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - PAGENAME") def build(self) -> Component: - return BasePage( - content=Column( - MainViewContentBox( - Column( - Text( - text="HEADER", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=0, - align_x=0.5 + return Column( + MainViewContentBox( + Column( + Text( + text="HEADER", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 ), - Text( - text="BASIC TEXT", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.9 - ), - margin=1, - wrap=True - ) + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="BASIC TEXT", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 + ), + margin=1, + overflow="wrap" ) - ), - align_y=0 - ) + ) + ), + align_y=0 ) diff --git a/src/ez_lan_manager/pages/TournamentsPage.py b/src/ez_lan_manager/pages/TournamentsPage.py index ec04017..0b43829 100644 --- a/src/ez_lan_manager/pages/TournamentsPage.py +++ b/src/ez_lan_manager/pages/TournamentsPage.py @@ -2,7 +2,7 @@ from rio import Column, Component, event, TextStyle, Text from src.ez_lan_manager import ConfigurationService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox -from src.ez_lan_manager.pages import BasePage + class TournamentsPage(Component): @event.on_populate @@ -10,31 +10,29 @@ class TournamentsPage(Component): await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turniere") def build(self) -> Component: - return BasePage( - content=Column( - MainViewContentBox( - Column( - Text( - text="Turniere", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=0, - align_x=0.5 + return Column( + MainViewContentBox( + Column( + Text( + text="Turniere", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 ), - Text( - text="Aktuell ist noch kein Turnierplan hinterlegt.", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=0.9 - ), - margin=1, - wrap=True - ) + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="Aktuell ist noch kein Turnierplan hinterlegt.", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=0.9 + ), + margin=1, + overflow="wrap" ) - ), - align_y=0 - ) + ) + ), + align_y=0 ) -- 2.45.2 From 4706183bde1f538b7358e17c491aa3e74b1e364e Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 27 Nov 2024 12:24:49 +0100 Subject: [PATCH 72/85] fix guards --- src/ez_lan_manager/helpers/LoggedInGuard.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ez_lan_manager/helpers/LoggedInGuard.py b/src/ez_lan_manager/helpers/LoggedInGuard.py index 04aa8ca..0e50427 100644 --- a/src/ez_lan_manager/helpers/LoggedInGuard.py +++ b/src/ez_lan_manager/helpers/LoggedInGuard.py @@ -1,16 +1,16 @@ from typing import Optional -from rio import Session, URL +from rio import Session, URL, GuardEvent from src.ez_lan_manager.types.SessionStorage import SessionStorage # Guards pages against access from users that are NOT logged in -def logged_in_guard(session: Session, _) -> Optional[URL]: - if session[SessionStorage].user_id is None: +def logged_in_guard(event: GuardEvent) -> Optional[URL]: + if event.session[SessionStorage].user_id is None: return URL("./") # Guards pages against access from users that ARE logged in -def not_logged_in_guard(session: Session, _) -> Optional[URL]: - if session[SessionStorage].user_id is not None: +def not_logged_in_guard(event: GuardEvent) -> Optional[URL]: + if event.session[SessionStorage].user_id is not None: return URL("./") -- 2.45.2 From b1dc4071cfb2ace77a1b8c005fffc9e030a4be67 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 27 Nov 2024 13:48:09 +0100 Subject: [PATCH 73/85] add catering order info modal --- .../components/CateringOrderItem.py | 35 +++++++++----- .../components/CateringSelectionItem.py | 2 +- .../components/ShoppingCartAndOrders.py | 47 +++++++++++++++++-- 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/src/ez_lan_manager/components/CateringOrderItem.py b/src/ez_lan_manager/components/CateringOrderItem.py index 5f89410..42e49a9 100644 --- a/src/ez_lan_manager/components/CateringOrderItem.py +++ b/src/ez_lan_manager/components/CateringOrderItem.py @@ -1,16 +1,15 @@ -from datetime import datetime +from typing import Callable -import rio -from rio import Component, Row, Text, TextStyle, Color +from rio import Component, Row, Text, TextStyle, Color, Rectangle, CursorStyle +from rio.components.pointer_event_listener import PointerEvent, PointerEventListener -from src.ez_lan_manager.types.CateringOrder import CateringOrderStatus +from src.ez_lan_manager.types.CateringOrder import CateringOrderStatus, CateringOrder MAX_LEN = 24 class CateringOrderItem(Component): - order_id: int - order_datetime: datetime - order_status: CateringOrderStatus + order: CateringOrder + info_modal_cb: Callable def get_display_text_and_color_for_order_status(self, order_status: CateringOrderStatus) -> tuple[str, Color]: match order_status: @@ -29,10 +28,20 @@ class CateringOrderItem(Component): case _: return "Unbekannt(wtf?)", self.session.theme.danger_color - def build(self) -> rio.Component: - order_status, color = self.get_display_text_and_color_for_order_status(self.order_status) - return Row( - Text(f"ID: {str(self.order_id):0>6}", align_x=0, overflow="wrap", min_width=10, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), margin_right=1), - Text(order_status, overflow="wrap", min_width=10, style=TextStyle(fill=color, font_size=0.9), margin_right=1), - Text(self.order_datetime.strftime("%d.%m. %H:%M"), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), align_x=1) + + def build(self) -> Component: + order_status, color = self.get_display_text_and_color_for_order_status(self.order.status) + return PointerEventListener( + Rectangle( + content=Row( + Text(f"ID: {str(self.order.order_id):0>6}", align_x=0, overflow="wrap", min_width=10, style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), margin_right=1), + Text(order_status, overflow="wrap", min_width=10, style=TextStyle(fill=color, font_size=0.9), margin_right=1), + Text(self.order.order_date.strftime("%d.%m. %H:%M"), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9), align_x=1) + ), + fill=self.session.theme.primary_color, + hover_fill=self.session.theme.hud_color, + transition_time=0.1, + cursor=CursorStyle.POINTER + ), + on_press=lambda _: self.info_modal_cb(self.order), ) diff --git a/src/ez_lan_manager/components/CateringSelectionItem.py b/src/ez_lan_manager/components/CateringSelectionItem.py index 183b0bc..e7a5ca9 100644 --- a/src/ez_lan_manager/components/CateringSelectionItem.py +++ b/src/ez_lan_manager/components/CateringSelectionItem.py @@ -44,7 +44,7 @@ class CateringSelectionItem(Component): Text(AccountingService.make_euro_string_from_int(self.article_price), style=TextStyle(fill=self.session.theme.background_color, font_size=0.9)), IconButton( icon="material/add", - size=2, + min_size=2, color=self.session.theme.success_color, style="plain-text", on_press=lambda: self.on_add_callback(self.article_id), diff --git a/src/ez_lan_manager/components/ShoppingCartAndOrders.py b/src/ez_lan_manager/components/ShoppingCartAndOrders.py index dbb7f2e..d4d6b73 100644 --- a/src/ez_lan_manager/components/ShoppingCartAndOrders.py +++ b/src/ez_lan_manager/components/ShoppingCartAndOrders.py @@ -1,7 +1,7 @@ from asyncio import sleep, create_task import rio -from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer, Popup +from rio import Component, Column, Text, TextStyle, Button, Row, ScrollContainer, Spacer, Popup, Table from src.ez_lan_manager.components.CateringCartItem import CateringCartItem from src.ez_lan_manager.components.CateringOrderItem import CateringOrderItem @@ -87,6 +87,44 @@ class ShoppingCartAndOrders(Component): self.order_button_loading = False _ = create_task(self.show_popup("Bestellung erfolgreich aufgegeben!", False)) + async def _create_order_info_modal(self, order: CateringOrder) -> None: + def build_dialog_content() -> rio.Component: + # @todo: rio 0.10.8 did not have the ability to align the columns, check back in a future version + table = Table( + { + "Artikel": [item.name for item in order.items.keys()] + ["Gesamtpreis:"], + "Anzahl": [item for item in order.items.values()] + [""], + "Preis": [AccountingService.make_euro_string_from_int(item.price) for item in order.items.keys()] + [AccountingService.make_euro_string_from_int(order.price)], + }, + show_row_numbers=False + ) + return rio.Card( + rio.Column( + rio.Text( + f"Deine Bestellung ({order.order_id})", + align_x=0.5, + margin_bottom=0.5 + ), + table, + margin=2, + ), + align_x=0.5, + align_y=0.2, + min_width=50, + min_height=10, + color=self.session.theme.primary_color, + margin_left=1, + margin_right=1, + margin_top=2, + margin_bottom=1, + ) + dialog = await self.session.show_custom_dialog( + build=build_dialog_content, + modal=True, + user_closeable=True, + ) + await dialog.wait_for_close() + def build(self) -> rio.Component: user_id = self.session[SessionStorage].user_id catering_service = self.session[CateringService] @@ -158,9 +196,8 @@ class ShoppingCartAndOrders(Component): orders_container = ScrollContainer( content=Column( *[CateringOrderItem( - order_id=order_item.order_id, - order_datetime=order_item.order_date, - order_status=order_item.status, + order=order_item, + info_modal_cb=self._create_order_info_modal ) for order_item in self.orders], Spacer(grow_y=True) ), @@ -168,4 +205,4 @@ class ShoppingCartAndOrders(Component): min_width=33, margin=1 ) - return Column(orders_container) \ No newline at end of file + return Column(orders_container) -- 2.45.2 From 951e6c8dceb1ed9295b0c417e3cfd44f4526bfea Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 27 Nov 2024 14:33:21 +0100 Subject: [PATCH 74/85] fix login box --- src/ez_lan_manager/components/LoginBox.py | 102 +++++++++ .../components/UserInfoAndLoginBox.py | 195 +----------------- src/ez_lan_manager/components/UserInfoBox.py | 100 +++++++++ 3 files changed, 208 insertions(+), 189 deletions(-) create mode 100644 src/ez_lan_manager/components/LoginBox.py create mode 100644 src/ez_lan_manager/components/UserInfoBox.py diff --git a/src/ez_lan_manager/components/LoginBox.py b/src/ez_lan_manager/components/LoginBox.py new file mode 100644 index 0000000..e3f5ff5 --- /dev/null +++ b/src/ez_lan_manager/components/LoginBox.py @@ -0,0 +1,102 @@ +from typing import Callable + +from rio import Component, TextStyle, Color, TextInput, Button, Text, Rectangle, Column, Row, Spacer, TextInputChangeEvent + +from src.ez_lan_manager.services.UserService import UserService +from src.ez_lan_manager.types.SessionStorage import SessionStorage + + +class LoginBox(Component): + status_change_cb: Callable + TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + user_name_input_text: list[str] = [""] + password_input_text: list[str] = [""] + user_name_input_is_valid = True + password_input_is_valid = True + login_button_is_loading = False + + async def _on_login_pressed(self) -> None: + if await self.session[UserService].is_login_valid(self.user_name_input_text[0], self.password_input_text[0]): + self.user_name_input_is_valid = True + self.password_input_is_valid = True + self.login_button_is_loading = False + await self.session[SessionStorage].set_user_id((await self.session[UserService].get_user(self.user_name_input_text[0])).user_id) + await self.status_change_cb() + else: + self.user_name_input_is_valid = False + self.password_input_is_valid = False + self.login_button_is_loading = False + await self.force_refresh() + + def build(self) -> Component: + def set_user_name_input_text(e: TextInputChangeEvent) -> None: + self.user_name_input_text[0] = e.text + + def set_password_input_text(e: TextInputChangeEvent) -> None: + self.password_input_text[0] = e.text + + user_name_input = TextInput( + text="", + label="Benutzername", + accessibility_label="Benutzername", + min_height=0.5, + on_confirm=lambda _: self._on_login_pressed(), + on_change=set_user_name_input_text, + is_valid=self.user_name_input_is_valid + ) + password_input = TextInput( + text="", + label="Passwort", + accessibility_label="Passwort", + is_secret=True, + on_confirm=lambda _: self._on_login_pressed(), + on_change=set_password_input_text, + is_valid=self.password_input_is_valid + ) + login_button = Button( + Text("LOGIN", style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary", + margin_bottom=0.4, + on_press=self._on_login_pressed + ) + register_button = Button( + Text("REG", style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary", + on_press=lambda: self.session.navigate_to("./register") + ) + forgot_password_button = Button( + Text("LST PWD", style=self.TEXT_STYLE, justify="center"), + shape="rectangle", + style="minor", + color="secondary", + on_press=lambda: self.session.navigate_to("./forgot-password") + ) + + return Rectangle( + content=Column( + user_name_input, + password_input, + Column( + Row( + login_button + ), + Row( + register_button, + Spacer(), + forgot_password_button, + proportions=(49, 2, 49) + ) + ), + spacing=0.4 + ), + fill=Color.TRANSPARENT, + min_height=8, + min_width=12, + align_x=0.5, + margin_top=0.3, + margin_bottom=2 + ) \ No newline at end of file diff --git a/src/ez_lan_manager/components/UserInfoAndLoginBox.py b/src/ez_lan_manager/components/UserInfoAndLoginBox.py index 5284bc1..2f4e218 100644 --- a/src/ez_lan_manager/components/UserInfoAndLoginBox.py +++ b/src/ez_lan_manager/components/UserInfoAndLoginBox.py @@ -1,198 +1,15 @@ import logging -from random import choice -from typing import Optional -from rio import Component, Column, Text, Row, Rectangle, Button, TextStyle, Color, Spacer, TextInput, Link, event - -from src.ez_lan_manager import UserService -from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton -from src.ez_lan_manager.services.AccountingService import AccountingService -from src.ez_lan_manager.services.TicketingService import TicketingService -from src.ez_lan_manager.services.SeatingService import SeatingService -from src.ez_lan_manager.types.Seat import Seat -from src.ez_lan_manager.types.Ticket import Ticket -from src.ez_lan_manager.types.User import User +from rio import Component +from src.ez_lan_manager.components.LoginBox import LoginBox +from src.ez_lan_manager.components.UserInfoBox import UserInfoBox from src.ez_lan_manager.types.SessionStorage import SessionStorage logger = logging.getLogger(__name__.split(".")[-1]) -class StatusButton(Component): - STYLE = TextStyle(fill=Color.from_hex("121212"), font_size=0.5) - label: str - target_url: str - enabled: bool - - def build(self) -> Component: - return Link( - content=Button( - content=Text(self.label, style=self.STYLE, justify="center"), - shape="rectangle", - style="major", - color="success" if self.enabled else "danger", - grow_x=True, - margin_left=0.6, - margin_right=0.6, - margin_top=0.6 - ), - target_url=self.target_url, - align_y=0.5, - grow_y=False - ) - - class UserInfoAndLoginBox(Component): - TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) - show_login: bool = True - user: Optional[User] = None - user_balance: Optional[int] = 0 - user_ticket: Optional[Ticket] = None - user_seat: Optional[Seat] = None - - @event.on_populate - async def async_init(self) -> None: - if self.session[SessionStorage].user_id: - self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) - self.user_balance = await self.session[AccountingService].get_balance(self.user.user_id) - self.user_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id) - self.user_seat = await self.session[SeatingService].get_user_seat(self.user.user_id) - - @staticmethod - def get_greeting() -> str: - return choice(["Guten Tacho", "Tuten Gag", "Servus", "Moinjour", "Hallöchen", "Heyho", "Moinsen"]) - - # @FixMe: If the user logs out and then tries to log back in, it does not work - # If the user navigates to another page and then tries again. It works. - # When fixed, remove the workaround below. - async def logout(self) -> None: - self.show_login = True - await self.session[SessionStorage].clear() - await self.force_refresh() - # @FixMe: Workaround for the bug described above. Navigating to another page solves the issue. - # Yet, this is not desired behavior. - subpage = str(self.session.active_page_url) - if subpage.endswith("/") or subpage.endswith("news"): - self.session.navigate_to("./overview") - else: - self.session.navigate_to("./news") - - async def _on_login_pressed(self) -> None: - user_name = self.user_name_input.text.lower() - if await self.session[UserService].is_login_valid(user_name, self.password_input.text): - self.user_name_input.is_valid = True - self.password_input.is_valid = True - self.login_button.is_loading = False - await self.session[SessionStorage].set_user_id((await self.session[UserService].get_user(user_name)).user_id) - await self.async_init() - self.show_login = False - else: - self.user_name_input.is_valid = False - self.password_input.is_valid = False - self.login_button.is_loading = False - def build(self) -> Component: - self.user_name_input = TextInput( - text="", - label="Benutzername", - accessibility_label="Benutzername", - min_height=0.5, - on_confirm=lambda _: self._on_login_pressed() - ) - self.password_input = TextInput( - text="", - label="Passwort", - accessibility_label="Passwort", - is_secret=True, - on_confirm=lambda _: self._on_login_pressed() - ) - self.login_button = Button( - Text("LOGIN", style=self.TEXT_STYLE, justify="center"), - shape="rectangle", - style="minor", - color="secondary", - margin_bottom=0.4, - on_press=self._on_login_pressed - ) - self.register_button = Button( - Text("REG", style=self.TEXT_STYLE, justify="center"), - shape="rectangle", - style="minor", - color="secondary", - on_press=lambda: self.session.navigate_to("./register") - ) - self.forgot_password_button = Button( - Text("LST PWD", style=self.TEXT_STYLE, justify="center"), - shape="rectangle", - style="minor", - color="secondary", - on_press=lambda: self.session.navigate_to("./forgot-password") - ) - - if self.user is None and self.session[SessionStorage].user_id is None: - return Rectangle( - content=Column( - self.user_name_input, - self.password_input, - Column( - Row( - self.login_button - ), - Row( - self.register_button, - Spacer(), - self.forgot_password_button, - proportions=(49, 2, 49) - ) - ), - spacing=0.4 - ), - fill=Color.TRANSPARENT, - min_height=8, - min_width=12, - align_x=0.5, - margin_top=0.3, - margin_bottom=2 - ) - elif self.user is None and self.session[SessionStorage].user_id is not None: - return Rectangle( - content=Column(), - fill=Color.TRANSPARENT, - min_height=8, - min_width=12, - align_x=0.5, - margin_top=0.3, - margin_bottom=2 - ) + if self.session[SessionStorage].user_id is None: + return LoginBox(status_change_cb=self.force_refresh) else: - return Rectangle( - content=Column( - Text(f"{self.get_greeting()},", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9), justify="center"), - Text(f"{self.user.user_name}", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=1.2), justify="center"), - Row( - StatusButton(label="TICKET", target_url="./buy_ticket", - enabled=self.user_ticket is not None), - StatusButton(label="SITZPLATZ", target_url="./seating", - enabled=self.user_seat is not None), - proportions=(50, 50), - grow_y=False - ), - UserInfoBoxButton("Profil bearbeiten", "./edit-profile"), - UserInfoBoxButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_int(self.user_balance)}", "./account"), - Button( - content=Text("Ausloggen", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.6)), - shape="rectangle", - style="minor", - color="secondary", - grow_x=True, - margin_left=0.6, - margin_right=0.6, - margin_top=0.6, - on_press=self.logout - ) - ), - fill=Color.TRANSPARENT, - min_height=8, - min_width=12, - align_x=0.5, - margin_top=0.3, - margin_bottom=2 - ) + return UserInfoBox(status_change_cb=self.force_refresh) diff --git a/src/ez_lan_manager/components/UserInfoBox.py b/src/ez_lan_manager/components/UserInfoBox.py new file mode 100644 index 0000000..378aeb7 --- /dev/null +++ b/src/ez_lan_manager/components/UserInfoBox.py @@ -0,0 +1,100 @@ +from random import choice +from typing import Optional, Callable + +from rio import Component, TextStyle, Color, Button, Text, Rectangle, Column, Row, Spacer, Link, event + +from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton +from src.ez_lan_manager.services.UserService import UserService +from src.ez_lan_manager.services.AccountingService import AccountingService +from src.ez_lan_manager.services.TicketingService import TicketingService +from src.ez_lan_manager.services.SeatingService import SeatingService +from src.ez_lan_manager.types.Seat import Seat +from src.ez_lan_manager.types.Ticket import Ticket +from src.ez_lan_manager.types.User import User +from src.ez_lan_manager.types.SessionStorage import SessionStorage + + +class StatusButton(Component): + STYLE = TextStyle(fill=Color.from_hex("121212"), font_size=0.5) + label: str + target_url: str + enabled: bool + + def build(self) -> Component: + return Link( + content=Button( + content=Text(self.label, style=self.STYLE, justify="center"), + shape="rectangle", + style="major", + color="success" if self.enabled else "danger", + grow_x=True, + margin_left=0.6, + margin_right=0.6, + margin_top=0.6 + ), + target_url=self.target_url, + align_y=0.5, + grow_y=False + ) + +class UserInfoBox(Component): + status_change_cb: Callable + TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + user: Optional[User] = None + user_balance: Optional[int] = 0 + user_ticket: Optional[Ticket] = None + user_seat: Optional[Seat] = None + + @staticmethod + def get_greeting() -> str: + return choice(["Guten Tacho", "Tuten Gag", "Servus", "Moinjour", "Hallöchen", "Heyho", "Moinsen"]) + + async def logout(self) -> None: + await self.session[SessionStorage].clear() + self.user = None + await self.status_change_cb() + + @event.on_populate + async def async_init(self) -> None: + if self.session[SessionStorage].user_id: + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) + self.user_balance = await self.session[AccountingService].get_balance(self.user.user_id) + self.user_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id) + self.user_seat = await self.session[SeatingService].get_user_seat(self.user.user_id) + + def build(self) -> Component: + if not self.user: + return Spacer() + return Rectangle( + content=Column( + Text(f"{self.get_greeting()},", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9), justify="center"), + Text(f"{self.user.user_name}", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=1.2), justify="center"), + Row( + StatusButton(label="TICKET", target_url="./buy_ticket", + enabled=self.user_ticket is not None), + StatusButton(label="SITZPLATZ", target_url="./seating", + enabled=self.user_seat is not None), + proportions=(50, 50), + grow_y=False + ), + UserInfoBoxButton("Profil bearbeiten", "./edit-profile"), + UserInfoBoxButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_int(self.user_balance)}", "./account"), + Button( + content=Text("Ausloggen", style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.6)), + shape="rectangle", + style="minor", + color="secondary", + grow_x=True, + margin_left=0.6, + margin_right=0.6, + margin_top=0.6, + on_press=self.logout + ) + ), + fill=Color.TRANSPARENT, + min_height=8, + min_width=12, + align_x=0.5, + margin_top=0.3, + margin_bottom=2 + ) -- 2.45.2 From 5eca3d25cfca5db855d462b44d2e50e691d26992 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 27 Nov 2024 14:46:13 +0100 Subject: [PATCH 75/85] fix bug where account page expanded beyond bounds --- src/ez_lan_manager/pages/Account.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ez_lan_manager/pages/Account.py b/src/ez_lan_manager/pages/Account.py index 7320736..eb585ec 100644 --- a/src/ez_lan_manager/pages/Account.py +++ b/src/ez_lan_manager/pages/Account.py @@ -1,6 +1,6 @@ from typing import Optional -from rio import Column, Component, event, Text, TextStyle, Button, Color, Spacer, Revealer, Row, ProgressCircle +from rio import Column, Component, event, Text, TextStyle, Button, Color, Revealer, Row, ProgressCircle from src.ez_lan_manager import ConfigurationService, UserService, AccountingService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox @@ -59,7 +59,7 @@ class AccountPage(Component): "Verwendungszweck:", style=TextStyle( fill=self.session.theme.background_color, - font_size=0.78 + font_size=0.7 ), margin=0, margin_bottom=1, -- 2.45.2 From f691851c9ea284e4e584a7ebb010ea78a991c029 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 27 Nov 2024 15:45:51 +0100 Subject: [PATCH 76/85] fix account balance not updating in real time --- src/ez_lan_manager/components/UserInfoBox.py | 6 ++++++ src/ez_lan_manager/services/AccountingService.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/ez_lan_manager/components/UserInfoBox.py b/src/ez_lan_manager/components/UserInfoBox.py index 378aeb7..b93fee6 100644 --- a/src/ez_lan_manager/components/UserInfoBox.py +++ b/src/ez_lan_manager/components/UserInfoBox.py @@ -61,6 +61,12 @@ class UserInfoBox(Component): self.user_balance = await self.session[AccountingService].get_balance(self.user.user_id) self.user_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id) self.user_seat = await self.session[SeatingService].get_user_seat(self.user.user_id) + self.session[AccountingService].add_update_hook(self.update) + + async def update(self) -> None: + self.user_balance = await self.session[AccountingService].get_balance(self.user.user_id) + self.user_ticket = await self.session[TicketingService].get_user_ticket(self.user.user_id) + self.user_seat = await self.session[SeatingService].get_user_seat(self.user.user_id) def build(self) -> Component: if not self.user: diff --git a/src/ez_lan_manager/services/AccountingService.py b/src/ez_lan_manager/services/AccountingService.py index 120ac85..735fea1 100644 --- a/src/ez_lan_manager/services/AccountingService.py +++ b/src/ez_lan_manager/services/AccountingService.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Callable from datetime import datetime from src.ez_lan_manager.services.DatabaseService import DatabaseService @@ -12,6 +13,11 @@ class InsufficientFundsError(Exception): class AccountingService: def __init__(self, db_service: DatabaseService) -> None: self._db_service = db_service + self._update_hooks: set[Callable] = set() + + def add_update_hook(self, update_hook: Callable) -> None: + """ Adds a function to this service, which is called whenever the account balance changes """ + self._update_hooks.add(update_hook) async def add_balance(self, user_id: int, balance_to_add: int, reference: str) -> int: await self._db_service.add_transaction(Transaction( @@ -22,6 +28,8 @@ class AccountingService: transaction_date=datetime.now() )) logger.debug(f"Added balance of {self.make_euro_string_from_int(balance_to_add)} to user with ID {user_id}") + for update_hook in self._update_hooks: + await update_hook() return await self.get_balance(user_id) async def remove_balance(self, user_id: int, balance_to_remove: int, reference: str) -> int: @@ -36,6 +44,8 @@ class AccountingService: transaction_date=datetime.now() )) logger.debug(f"Removed balance of {self.make_euro_string_from_int(balance_to_remove)} to user with ID {user_id}") + for update_hook in self._update_hooks: + await update_hook() return await self.get_balance(user_id) async def get_balance(self, user_id: int) -> int: -- 2.45.2 From 48ad800853a91ec061f183f465e6fa2d56dada36 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Wed, 27 Nov 2024 17:16:24 +0100 Subject: [PATCH 77/85] add team navbar --- .../components/DesktopNavigation.py | 69 ++++++++++++++----- .../components/DesktopNavigationButton.py | 7 +- src/ez_lan_manager/types/SessionStorage.py | 6 +- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/src/ez_lan_manager/components/DesktopNavigation.py b/src/ez_lan_manager/components/DesktopNavigation.py index ae86f70..fa7a260 100644 --- a/src/ez_lan_manager/components/DesktopNavigation.py +++ b/src/ez_lan_manager/components/DesktopNavigation.py @@ -1,34 +1,69 @@ +from copy import copy, deepcopy +from typing import Optional + from rio import * -from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager import ConfigurationService, UserService from src.ez_lan_manager.components.DesktopNavigationButton import DesktopNavigationButton from src.ez_lan_manager.components.UserInfoAndLoginBox import UserInfoAndLoginBox from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.User import User + class DesktopNavigation(Component): + user: Optional[User] = None + + @event.on_populate + async def async_init(self) -> None: + self.session[SessionStorage].subscribe_to_logged_in_or_out_event(str(self.__class__), self.async_init) + if self.session[SessionStorage].user_id: + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) + else: + self.user = None + def build(self) -> Component: lan_info = self.session[ConfigurationService].get_lan_info() + user_navigation = [ + DesktopNavigationButton("News", "./news"), + Spacer(min_height=1), + DesktopNavigationButton(f"Über {lan_info.name} {lan_info.iteration}", "./overview"), + DesktopNavigationButton("Ticket kaufen", "./buy_ticket"), + DesktopNavigationButton("Sitzplan", "./seating"), + DesktopNavigationButton("Catering", "./catering"), + DesktopNavigationButton("Teilnehmer", "./guests"), + DesktopNavigationButton("Turniere", "./tournaments"), + DesktopNavigationButton("FAQ", "./faq"), + DesktopNavigationButton("Regeln & AGB", "./rules-gtc"), + Spacer(min_height=1), + DesktopNavigationButton("Discord", "#", open_new_tab=True), # Temporarily disabled: https://discord.gg/8gTjg34yyH + DesktopNavigationButton("Die EZ GG e.V.", "https://ezgg-ev.de/about", open_new_tab=True), + DesktopNavigationButton("Kontakt", "./contact"), + DesktopNavigationButton("Impressum & DSGVO", "./imprint"), + Spacer(min_height=1) + ] + team_navigation = [ + Text("Verwaltung", align_x=0.5, margin_top=0.3, style=TextStyle(fill=Color.from_hex("F0EADE"), font_size=1.2)), + Text("Vorsichtig sein!", align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.danger_color, font_size=0.6)), + DesktopNavigationButton("News", "./manage_news", is_team_navigation=True), + DesktopNavigationButton("Benutzer", "./manage_users", is_team_navigation=True), + DesktopNavigationButton("Catering", "./manage_catering", is_team_navigation=True), + DesktopNavigationButton("Turniere", "./manage_tournaments", is_team_navigation=True), + Spacer(min_height=1), + Revealer( + header="Normale Navigation", + content=Column(*user_navigation), + header_style=TextStyle(fill=self.session.theme.primary_color, font_size=0.9) + ) + ] if self.user is not None and self.user.is_team_member else [] + + nav_to_use = copy(team_navigation) if self.user is not None and self.user.is_team_member else copy(user_navigation) + return Card( Column( Text(lan_info.name, align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.hud_color, font_size=2.5)), Text(f"Edition {lan_info.iteration}", align_x=0.5, style=TextStyle(fill=self.session.theme.hud_color, font_size=1.2), margin_top=0.3, margin_bottom=2), UserInfoAndLoginBox(), - DesktopNavigationButton("News", "./news"), - Spacer(min_height=1), - DesktopNavigationButton(f"Über {lan_info.name} {lan_info.iteration}", "./overview"), - DesktopNavigationButton("Ticket kaufen", "./buy_ticket"), - DesktopNavigationButton("Sitzplan", "./seating"), - DesktopNavigationButton("Catering", "./catering"), - DesktopNavigationButton("Teilnehmer", "./guests"), - DesktopNavigationButton("Turniere", "./tournaments"), - DesktopNavigationButton("FAQ", "./faq"), - DesktopNavigationButton("Regeln & AGB", "./rules-gtc"), - Spacer(min_height=1), - DesktopNavigationButton("Discord", "#", open_new_tab=True), # Temporarily disabled: https://discord.gg/8gTjg34yyH - DesktopNavigationButton("Die EZ GG e.V.", "https://ezgg-ev.de/about", open_new_tab=True), - DesktopNavigationButton("Kontakt", "./contact"), - DesktopNavigationButton("Impressum & DSGVO", "./imprint"), - Spacer(min_height=1), + *nav_to_use, align_y=0 ), color=self.session.theme.neutral_color, diff --git a/src/ez_lan_manager/components/DesktopNavigationButton.py b/src/ez_lan_manager/components/DesktopNavigationButton.py index b59ea3b..dce5178 100644 --- a/src/ez_lan_manager/components/DesktopNavigationButton.py +++ b/src/ez_lan_manager/components/DesktopNavigationButton.py @@ -1,19 +1,20 @@ from rio import Component, TextStyle, Color, Link, Button, Text - class DesktopNavigationButton(Component): STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + TEAM_STYLE = TextStyle(fill=Color.from_hex("F0EADE"), font_size=0.9) label: str target_url: str + is_team_navigation: bool = False open_new_tab: bool = False def build(self) -> Component: return Link( content=Button( - content=Text(self.label, style=self.STYLE), + content=Text(self.label, style=self.TEAM_STYLE if self.is_team_navigation else self.STYLE), shape="rectangle", style="minor", - color="secondary", + color="danger" if self.is_team_navigation else "secondary", grow_x=True, margin_left=0.6, margin_right=0.6, diff --git a/src/ez_lan_manager/types/SessionStorage.py b/src/ez_lan_manager/types/SessionStorage.py index ede4b78..1968ed9 100644 --- a/src/ez_lan_manager/types/SessionStorage.py +++ b/src/ez_lan_manager/types/SessionStorage.py @@ -1,7 +1,10 @@ +import logging from collections.abc import Callable from dataclasses import dataclass, field from typing import Optional +logger = logging.getLogger(__name__.split(".")[-1]) + # ToDo: Persist between reloads: https://rio.dev/docs/howto/persistent-settings # Note for ToDo: rio.UserSettings are saved LOCALLY, do not just read a user_id here! @@ -22,5 +25,6 @@ class SessionStorage: async def set_user_id(self, user_id: Optional[int]) -> None: self._user_id = user_id - for callback in self._notification_callbacks.values(): + for component_id, callback in self._notification_callbacks.items(): + logger.debug(f"Calling logged in callback from {component_id}") await callback() -- 2.45.2 From 947a05ad14baef5a051d5099737ea8d133012adf Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Thu, 28 Nov 2024 18:52:51 +0100 Subject: [PATCH 78/85] add news mananger --- src/EzLanManager.py | 8 +- .../components/DesktopNavigation.py | 8 +- src/ez_lan_manager/components/LoginBox.py | 4 +- src/ez_lan_manager/components/NewsPost.py | 70 +++++++++- src/ez_lan_manager/helpers/LoggedInGuard.py | 10 +- src/ez_lan_manager/pages/ManageNewsPage.py | 131 ++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 1 + .../services/DatabaseService.py | 40 ++++++ src/ez_lan_manager/services/NewsService.py | 6 + src/ez_lan_manager/types/SessionStorage.py | 10 +- 10 files changed, 278 insertions(+), 10 deletions(-) create mode 100644 src/ez_lan_manager/pages/ManageNewsPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index 84598de..f4b4910 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -9,7 +9,7 @@ from rio import App, Theme, Color, Font, ComponentPage, Session from from_root import from_root from src.ez_lan_manager import pages, init_services -from src.ez_lan_manager.helpers.LoggedInGuard import logged_in_guard, not_logged_in_guard +from src.ez_lan_manager.helpers.LoggedInGuard import logged_in_guard, not_logged_in_guard, team_guard from src.ez_lan_manager.services.DatabaseService import NoDatabaseConnectionError from src.ez_lan_manager.types.SessionStorage import SessionStorage @@ -131,6 +131,12 @@ if __name__ == "__main__": build=pages.AccountPage, guard=logged_in_guard ), + ComponentPage( + name="ManageNewsPage", + url_segment="manage-news", + build=pages.ManageNewsPage, + guard=team_guard + ), ComponentPage( name="DbErrorPage", url_segment="db-error", diff --git a/src/ez_lan_manager/components/DesktopNavigation.py b/src/ez_lan_manager/components/DesktopNavigation.py index fa7a260..a20bc41 100644 --- a/src/ez_lan_manager/components/DesktopNavigation.py +++ b/src/ez_lan_manager/components/DesktopNavigation.py @@ -44,10 +44,10 @@ class DesktopNavigation(Component): team_navigation = [ Text("Verwaltung", align_x=0.5, margin_top=0.3, style=TextStyle(fill=Color.from_hex("F0EADE"), font_size=1.2)), Text("Vorsichtig sein!", align_x=0.5, margin_top=0.3, style=TextStyle(fill=self.session.theme.danger_color, font_size=0.6)), - DesktopNavigationButton("News", "./manage_news", is_team_navigation=True), - DesktopNavigationButton("Benutzer", "./manage_users", is_team_navigation=True), - DesktopNavigationButton("Catering", "./manage_catering", is_team_navigation=True), - DesktopNavigationButton("Turniere", "./manage_tournaments", is_team_navigation=True), + DesktopNavigationButton("News", "./manage-news", is_team_navigation=True), + DesktopNavigationButton("Benutzer", "./manage-users", is_team_navigation=True), + DesktopNavigationButton("Catering", "./manage-catering", is_team_navigation=True), + DesktopNavigationButton("Turniere", "./manage-tournaments", is_team_navigation=True), Spacer(min_height=1), Revealer( header="Normale Navigation", diff --git a/src/ez_lan_manager/components/LoginBox.py b/src/ez_lan_manager/components/LoginBox.py index e3f5ff5..6770043 100644 --- a/src/ez_lan_manager/components/LoginBox.py +++ b/src/ez_lan_manager/components/LoginBox.py @@ -4,6 +4,7 @@ from rio import Component, TextStyle, Color, TextInput, Button, Text, Rectangle, from src.ez_lan_manager.services.UserService import UserService from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.User import User class LoginBox(Component): @@ -17,10 +18,11 @@ class LoginBox(Component): async def _on_login_pressed(self) -> None: if await self.session[UserService].is_login_valid(self.user_name_input_text[0], self.password_input_text[0]): + user: User = await self.session[UserService].get_user(self.user_name_input_text[0]) self.user_name_input_is_valid = True self.password_input_is_valid = True self.login_button_is_loading = False - await self.session[SessionStorage].set_user_id((await self.session[UserService].get_user(self.user_name_input_text[0])).user_id) + await self.session[SessionStorage].set_user_id_and_team_member_flag(user.user_id, user.is_team_member) await self.status_change_cb() else: self.user_name_input_is_valid = False diff --git a/src/ez_lan_manager/components/NewsPost.py b/src/ez_lan_manager/components/NewsPost.py index adac0ba..13e4b49 100644 --- a/src/ez_lan_manager/components/NewsPost.py +++ b/src/ez_lan_manager/components/NewsPost.py @@ -1,4 +1,8 @@ -from rio import Component, Rectangle, Text, TextStyle, Column, Row +from datetime import datetime +from functools import partial +from typing import Optional, Callable + +from rio import Component, Rectangle, Text, TextStyle, Column, Row, TextInput, DateInput, MultiLineTextInput, IconButton, Color, Button class NewsPost(Component): @@ -79,3 +83,67 @@ class NewsPost(Component): shadow_offset_y=0, corner_radius=0.2 ) + + +class EditableNewsPost(NewsPost): + news_id: int = -1 + save_cb: Callable = lambda _: None + delete_cb: Callable = lambda _: None + + def set_prop(self, prop, value) -> None: + self.__setattr__(prop, value) + + def build(self) -> Component: + return Rectangle( + content=Column( + Row( + TextInput( + text=self.title, + label="Titel", + style="rounded", + min_width=15, + on_change=lambda e: self.set_prop("title", e.text) + ), + DateInput( + value=datetime.strptime(self.date, "%d.%m.%Y"), + style="rounded", + on_change=lambda e: self.set_prop("date", e.value.strftime("%d.%m.%Y")) + ) + ), + TextInput( + text=self.subtitle, + label="Untertitel", + style="rounded", + grow_x=True, + on_change=lambda e: self.set_prop("subtitle", e.text) + ), + MultiLineTextInput( + text=self.text, + label="Text", + style="rounded", + grow_x=True, + min_height=12, + on_change=lambda e: self.set_prop("text", e.text) + ), + Row( + TextInput( + text=self.author, + label="Autor", + style="rounded", + grow_x=True, + on_change=lambda e: self.set_prop("author", e.text) + ), + Rectangle(content=Button(icon="material/delete", style="major", color="danger", shape="rectangle", on_press=partial(self.delete_cb, self.news_id)), fill=Color.from_hex("0b7372")), + Rectangle(content=Button(icon="material/save", style="major", color="success", shape="rectangle", on_press=partial(self.save_cb, self)), fill=Color.from_hex("0b7372")) + ) + ), + fill=self.session.theme.primary_color, + margin_left=1, + margin_right=1, + margin_top=2, + margin_bottom=1, + shadow_radius=0.2, + shadow_color=self.session.theme.background_color, + shadow_offset_y=0, + corner_radius=0.2 + ) diff --git a/src/ez_lan_manager/helpers/LoggedInGuard.py b/src/ez_lan_manager/helpers/LoggedInGuard.py index 0e50427..e09f2a5 100644 --- a/src/ez_lan_manager/helpers/LoggedInGuard.py +++ b/src/ez_lan_manager/helpers/LoggedInGuard.py @@ -1,7 +1,8 @@ from typing import Optional -from rio import Session, URL, GuardEvent +from rio import URL, GuardEvent +from src.ez_lan_manager.services.UserService import UserService from src.ez_lan_manager.types.SessionStorage import SessionStorage @@ -14,3 +15,10 @@ def logged_in_guard(event: GuardEvent) -> Optional[URL]: def not_logged_in_guard(event: GuardEvent) -> Optional[URL]: if event.session[SessionStorage].user_id is not None: return URL("./") + +# Guards pages against access from users that are NOT logged in and NOT team members +def team_guard(event: GuardEvent) -> Optional[URL]: + user_id = event.session[SessionStorage].user_id + is_team_member = event.session[SessionStorage].is_team_member + if user_id is None or not is_team_member: + return URL("./") diff --git a/src/ez_lan_manager/pages/ManageNewsPage.py b/src/ez_lan_manager/pages/ManageNewsPage.py new file mode 100644 index 0000000..fa20800 --- /dev/null +++ b/src/ez_lan_manager/pages/ManageNewsPage.py @@ -0,0 +1,131 @@ +import logging +from asyncio import sleep +from datetime import datetime +from time import strptime + +from rio import Column, Component, event, TextStyle, Text + +from src.ez_lan_manager import ConfigurationService, UserService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.components.NewsPost import EditableNewsPost +from src.ez_lan_manager.services.NewsService import NewsService +from src.ez_lan_manager.types.News import News + +logger = logging.getLogger(__name__.split(".")[-1]) + +class ManageNewsPage(Component): + news_posts: list[News] = [] + show_success_message = False + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - News Verwaltung") + self.news_posts = (await self.session[NewsService].get_news())[:8] + + async def on_new_news_post(self, post: EditableNewsPost) -> None: + # @todo: For some reason, new posts do not appear through a force_refresh, only after visiting the page again + author = await self.session[UserService].get_user(post.author) + if author is None: + logger.warning(f"Tried to set news post author to '{post.author}', which does not exist.") + return + await self.session[NewsService].add_news(News( + news_id=None, + title=post.title, + subtitle=post.subtitle, + content=post.text, + author=author, + news_date=strptime(post.date, "%d.%m.%Y"), + )) + self.news_posts = (await self.session[NewsService].get_news())[:8] + self.show_success_message = True + await self.force_refresh() + await sleep(3) + self.show_success_message = False + await self.force_refresh() + + async def on_news_post_changed(self, post: EditableNewsPost) -> None: + author = await self.session[UserService].get_user(post.author) + if author is None: + logger.warning(f"Tried to set news post author to '{post.author}', which does not exist.") + return + await self.session[NewsService].update_news(News( + news_id=post.news_id, + title=post.title, + subtitle=post.subtitle, + content=post.text, + author=author, + news_date=strptime(post.date, "%d.%m.%Y"), + )) + self.news_posts = (await self.session[NewsService].get_news())[:8] + + async def on_news_post_deleted(self, news_id: int) -> None: + await self.session[NewsService].delete_news(news_id) + self.news_posts = (await self.session[NewsService].get_news())[:8] + + def build(self) -> Component: + posts = sorted([EditableNewsPost( + news_id=news.news_id, + title=news.title, + subtitle=news.subtitle, + text=news.content, + date=news.news_date.strftime("%d.%m.%Y"), + author=news.author.user_name, + save_cb=self.on_news_post_changed, + delete_cb=self.on_news_post_deleted + ) for news in self.news_posts], key=lambda p: p.date) + return Column( + MainViewContentBox( + Column( + Text( + text="News Verwaltung", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="Neuen News Post erstellen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.1 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + EditableNewsPost( + title="", + subtitle="", + text="", + date=datetime.now().strftime("%d.%m.%Y"), + author="", + save_cb=self.on_new_news_post + ), + Text( + text="Post erfolgreich erstellt", + style=TextStyle( + fill=self.session.theme.success_color, + font_size=0.7 if self.show_success_message else 0 + ), + margin_top=0.1, + margin_bottom=0, + align_x=0.5 + ), + Text( + text="Bisherige Posts", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.1 + ), + margin_top=2, + margin_bottom=0, + align_x=0.5 + ), + *posts + ) + ), + align_y=0 + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 92d6210..ce5fa34 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -15,3 +15,4 @@ from .CateringPage import CateringPage from .DbErrorPage import DbErrorPage from .SeatingPlanPage import SeatingPlanPage from .BuyTicketPage import BuyTicketPage +from .ManageNewsPage import ManageNewsPage diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index d32cc95..a917edd 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -246,7 +246,47 @@ class DatabaseService: return results + async def update_news(self, news: News) -> None: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + """ + UPDATE news + SET news_content = %s, + news_title = %s, + news_subtitle = %s, + news_author = %s, + news_date = %s + WHERE news_id = %s + """, + (news.content, news.title, news.subtitle, news.author.user_id, news.news_date, news.news_id) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.update_news(news) + except Exception as e: + logger.warning(f"Error updating news: {e}") + async def remove_news(self, news_id: int) -> None: + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "DELETE FROM news WHERE news_id = %s", + (news_id,) + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.remove_news(news_id) + except Exception as e: + logger.warning(f"Error removing news with ID {news_id}: {e}") async def get_tickets(self) -> list[Ticket]: async with self._connection_pool.acquire() as conn: diff --git a/src/ez_lan_manager/services/NewsService.py b/src/ez_lan_manager/services/NewsService.py index 6c829a5..1211af5 100644 --- a/src/ez_lan_manager/services/NewsService.py +++ b/src/ez_lan_manager/services/NewsService.py @@ -24,6 +24,12 @@ class NewsService: dt_start = date(1900, 1, 1) return await self._db_service.get_news(dt_start, dt_end) + async def update_news(self, news: News) -> None: + return await self._db_service.update_news(news) + + async def delete_news(self, news_id: int) -> None: + return await self._db_service.remove_news(news_id) + async def get_latest_news(self) -> Optional[News]: try: all_news = await self.get_news(None, date.today()) diff --git a/src/ez_lan_manager/types/SessionStorage.py b/src/ez_lan_manager/types/SessionStorage.py index 1968ed9..206cdd2 100644 --- a/src/ez_lan_manager/types/SessionStorage.py +++ b/src/ez_lan_manager/types/SessionStorage.py @@ -11,10 +11,11 @@ logger = logging.getLogger(__name__.split(".")[-1]) @dataclass(frozen=False) class SessionStorage: _user_id: Optional[int] = None # DEBUG: Put user ID here to skip login + _is_team_member: bool = False _notification_callbacks: dict[str, Callable] = field(default_factory=dict) async def clear(self) -> None: - await self.set_user_id(None) + await self.set_user_id_and_team_member_flag(None, False) def subscribe_to_logged_in_or_out_event(self, component_id: str, callback: Callable) -> None: self._notification_callbacks[component_id] = callback @@ -23,8 +24,13 @@ class SessionStorage: def user_id(self) -> Optional[int]: return self._user_id - async def set_user_id(self, user_id: Optional[int]) -> None: + @property + def is_team_member(self) -> bool: + return self._is_team_member + + async def set_user_id_and_team_member_flag(self, user_id: Optional[int], is_team_member: bool) -> None: self._user_id = user_id + self._is_team_member = is_team_member for component_id, callback in self._notification_callbacks.items(): logger.debug(f"Calling logged in callback from {component_id}") await callback() -- 2.45.2 From a1fb68c976f39ac327fd38abb53d0fc92051eedc Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Fri, 29 Nov 2024 20:16:12 +0100 Subject: [PATCH 79/85] improve news and news manager --- src/ez_lan_manager/components/NewsPost.py | 97 +++++++++++----------- src/ez_lan_manager/pages/ManageNewsPage.py | 4 +- src/ez_lan_manager/services/NewsService.py | 5 +- 3 files changed, 55 insertions(+), 51 deletions(-) diff --git a/src/ez_lan_manager/components/NewsPost.py b/src/ez_lan_manager/components/NewsPost.py index 13e4b49..7d814bd 100644 --- a/src/ez_lan_manager/components/NewsPost.py +++ b/src/ez_lan_manager/components/NewsPost.py @@ -2,7 +2,7 @@ from datetime import datetime from functools import partial from typing import Optional, Callable -from rio import Component, Rectangle, Text, TextStyle, Column, Row, TextInput, DateInput, MultiLineTextInput, IconButton, Color, Button +from rio import Component, Rectangle, Text, TextStyle, Column, Row, TextInput, DateInput, MultiLineTextInput, IconButton, Color, Button, ThemeContextSwitcher class NewsPost(Component): @@ -94,56 +94,59 @@ class EditableNewsPost(NewsPost): self.__setattr__(prop, value) def build(self) -> Component: - return Rectangle( - content=Column( - Row( - TextInput( - text=self.title, - label="Titel", - style="rounded", - min_width=15, - on_change=lambda e: self.set_prop("title", e.text) + return ThemeContextSwitcher( + content=Rectangle( + content=Column( + Row( + TextInput( + text=self.title, + label="Titel", + style="rounded", + min_width=15, + on_change=lambda e: self.set_prop("title", e.text) + ), + DateInput( + value=datetime.strptime(self.date, "%d.%m.%Y"), + style="rounded", + on_change=lambda e: self.set_prop("date", e.value.strftime("%d.%m.%Y")) + ) ), - DateInput( - value=datetime.strptime(self.date, "%d.%m.%Y"), - style="rounded", - on_change=lambda e: self.set_prop("date", e.value.strftime("%d.%m.%Y")) - ) - ), - TextInput( - text=self.subtitle, - label="Untertitel", - style="rounded", - grow_x=True, - on_change=lambda e: self.set_prop("subtitle", e.text) - ), - MultiLineTextInput( - text=self.text, - label="Text", - style="rounded", - grow_x=True, - min_height=12, - on_change=lambda e: self.set_prop("text", e.text) - ), - Row( TextInput( - text=self.author, - label="Autor", + text=self.subtitle, + label="Untertitel", style="rounded", grow_x=True, - on_change=lambda e: self.set_prop("author", e.text) + on_change=lambda e: self.set_prop("subtitle", e.text) ), - Rectangle(content=Button(icon="material/delete", style="major", color="danger", shape="rectangle", on_press=partial(self.delete_cb, self.news_id)), fill=Color.from_hex("0b7372")), - Rectangle(content=Button(icon="material/save", style="major", color="success", shape="rectangle", on_press=partial(self.save_cb, self)), fill=Color.from_hex("0b7372")) - ) + MultiLineTextInput( + text=self.text, + label="Text", + style="rounded", + grow_x=True, + min_height=12, + on_change=lambda e: self.set_prop("text", e.text) + ), + Row( + TextInput( + text=self.author, + label="Autor", + style="rounded", + grow_x=True, + on_change=lambda e: self.set_prop("author", e.text) + ), + Rectangle(content=Button(icon="material/delete", style="major", color="danger", shape="rectangle", on_press=partial(self.delete_cb, self.news_id)), fill=Color.from_hex("0b7372")), + Rectangle(content=Button(icon="material/save", style="major", color="success", shape="rectangle", on_press=partial(self.save_cb, self)), fill=Color.from_hex("0b7372")) + ) + ), + fill=self.session.theme.primary_color, + margin_left=1, + margin_right=1, + margin_top=2, + margin_bottom=1, + shadow_radius=0.2, + shadow_color=self.session.theme.background_color, + shadow_offset_y=0, + corner_radius=0.2 ), - fill=self.session.theme.primary_color, - margin_left=1, - margin_right=1, - margin_top=2, - margin_bottom=1, - shadow_radius=0.2, - shadow_color=self.session.theme.background_color, - shadow_offset_y=0, - corner_radius=0.2 + color="primary" ) diff --git a/src/ez_lan_manager/pages/ManageNewsPage.py b/src/ez_lan_manager/pages/ManageNewsPage.py index fa20800..36c51bf 100644 --- a/src/ez_lan_manager/pages/ManageNewsPage.py +++ b/src/ez_lan_manager/pages/ManageNewsPage.py @@ -63,7 +63,7 @@ class ManageNewsPage(Component): self.news_posts = (await self.session[NewsService].get_news())[:8] def build(self) -> Component: - posts = sorted([EditableNewsPost( + posts = [EditableNewsPost( news_id=news.news_id, title=news.title, subtitle=news.subtitle, @@ -72,7 +72,7 @@ class ManageNewsPage(Component): author=news.author.user_name, save_cb=self.on_news_post_changed, delete_cb=self.on_news_post_deleted - ) for news in self.news_posts], key=lambda p: p.date) + ) for news in self.news_posts] return Column( MainViewContentBox( Column( diff --git a/src/ez_lan_manager/services/NewsService.py b/src/ez_lan_manager/services/NewsService.py index 1211af5..8ac089a 100644 --- a/src/ez_lan_manager/services/NewsService.py +++ b/src/ez_lan_manager/services/NewsService.py @@ -17,12 +17,13 @@ class NewsService: return await self._db_service.add_news(news) - async def get_news(self, dt_start: Optional[date] = None, dt_end: Optional[date] = None) -> list[News]: + async def get_news(self, dt_start: Optional[date] = None, dt_end: Optional[date] = None, newest_first: bool = True) -> list[News]: if not dt_end: dt_end = date.today() if not dt_start: dt_start = date(1900, 1, 1) - return await self._db_service.get_news(dt_start, dt_end) + fetched_news = await self._db_service.get_news(dt_start, dt_end) + return sorted(fetched_news, key=lambda news: news.news_date, reverse=newest_first) async def update_news(self, news: News) -> None: return await self._db_service.update_news(news) -- 2.45.2 From 5a20db1a6ba7ae5b4991d1f5de7e98aa4b37c709 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Fri, 29 Nov 2024 21:15:42 +0100 Subject: [PATCH 80/85] Bugfix: Demo Database creation script had hardcoded ID's --- src/ez_lan_manager/helpers/create_demo_database_content.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ez_lan_manager/helpers/create_demo_database_content.py b/src/ez_lan_manager/helpers/create_demo_database_content.py index 7f7aa65..4ae593c 100644 --- a/src/ez_lan_manager/helpers/create_demo_database_content.py +++ b/src/ez_lan_manager/helpers/create_demo_database_content.py @@ -42,7 +42,7 @@ async def run() -> None: jason = await user_service.create_user(DEMO_USERS[2]["user_name"], DEMO_USERS[2]["user_mail"], DEMO_USERS[2]["password_clear_text"]) await accounting_service.add_balance(jason.user_id, 100000, "DEMO EINZAHLUNG") await ticket_service.purchase_ticket(jason.user_id, "NORMAL") - await seating_service.seat_user(30, "D10") + 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"]) @@ -151,6 +151,7 @@ async def run() -> None: sys.exit("Database does not contain users! Exiting...") await news_service.add_news(News( + news_id=None, title="Der EZ LAN Manager", subtitle="Eine Software des EZ GG e.V.", content="Dies ist eine WIP-Version des EZ LAN Managers. Diese Software soll uns helfen in Zukunft die LAN Parties des EZ GG e.V.'s zu organisieren. Wer Fehler findet darf sie behalten. (Oder er meldet sie)", -- 2.45.2 From 471b59d5ec40b5078d803fe43bb719c3ca7ca4d1 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sat, 30 Nov 2024 00:53:59 +0100 Subject: [PATCH 81/85] add basic user manager with user lookup --- src/EzLanManager.py | 6 + src/ez_lan_manager/pages/ManageUsersPage.py | 127 ++++++++++++++++++++ src/ez_lan_manager/pages/__init__.py | 1 + 3 files changed, 134 insertions(+) create mode 100644 src/ez_lan_manager/pages/ManageUsersPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index f4b4910..beea489 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -137,6 +137,12 @@ if __name__ == "__main__": build=pages.ManageNewsPage, guard=team_guard ), + ComponentPage( + name="ManageUsersPage", + url_segment="manage-users", + build=pages.ManageUsersPage, + guard=team_guard + ), ComponentPage( name="DbErrorPage", url_segment="db-error", diff --git a/src/ez_lan_manager/pages/ManageUsersPage.py b/src/ez_lan_manager/pages/ManageUsersPage.py new file mode 100644 index 0000000..0a92073 --- /dev/null +++ b/src/ez_lan_manager/pages/ManageUsersPage.py @@ -0,0 +1,127 @@ +import logging +from dataclasses import field +from typing import Optional, Coroutine + +import rio +from rio import Column, Component, event, TextStyle, Text, TextInput, ThemeContextSwitcher, Grid, \ + PointerEventListener, PointerEvent, Rectangle, CursorStyle, Color, TextInputChangeEvent + +from src.ez_lan_manager import ConfigurationService, UserService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.types.User import User + +logger = logging.getLogger(__name__.split(".")[-1]) + +# Helps type checker grasp the concept of "lambda _: None" as a Coroutine +async def noop(_) -> None: + pass + +class ClickableGridContent(Component): + text: str = "" + is_hovered: bool = False + clicked_cb: Coroutine = noop + + async def on_mouse_enter(self, _: PointerEvent) -> None: + self.is_hovered = True + + async def on_mouse_leave(self, _: PointerEvent) -> None: + self.is_hovered = False + + async def on_mouse_click(self, _: PointerEvent) -> None: + await self.clicked_cb(self.text) + + def build(self) -> rio.Component: + return PointerEventListener( + content=Rectangle( + content=Text( + self.text, + style=TextStyle(fill=self.session.theme.success_color) if self.is_hovered else TextStyle(fill=self.session.theme.background_color), + grow_x=True + ), + fill=Color.TRANSPARENT, + cursor=CursorStyle.POINTER + ), + on_pointer_enter=self.on_mouse_enter, + on_pointer_leave=self.on_mouse_leave, + on_press=self.on_mouse_click + ) + +class ManageUsersPage(Component): + selected_user: Optional[User] = None + all_users: Optional[list] = None + search_results: list[User] = field(default_factory=list) + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - News Verwaltung") + self.all_users = await self.session[UserService].get_all_users() + self.search_results = self.all_users + + async def on_user_clicked(self, user_name: str) -> None: + self.selected_user = next(filter(lambda user: user.user_name == user_name, self.all_users)) + + 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)) + + + def build(self) -> Component: + return Column( + MainViewContentBox( + Column( + Text( + text="Nutzersuche", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ), + TextInput( + label="Nutzername oder ID", + margin=1, + on_change=self.on_search_parameters_changed + ), + ThemeContextSwitcher( + Grid( + [ + Text("Nutzername", margin_bottom=1, grow_x=True, style=TextStyle(font_size=1.1)), + Text("Nutzer-ID", margin_bottom=1, style=TextStyle(font_size=1.1)) + ], + *[[ + ClickableGridContent(text=user.user_name, clicked_cb=self.on_user_clicked), + Text( + str(user.user_id), + justify="right" + ) + ] for user in self.search_results], + row_spacing=0.2, + margin=1 + ), + color="primary" + ) + ) + ), + MainViewContentBox( + Text( + text=f"Nutzer {self.selected_user.user_name} gewählt.", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ) if self.selected_user else Text( + text="Bitte Nutzer auswählen...", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + )), + align_y=0 + ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index ce5fa34..869d6a8 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -16,3 +16,4 @@ from .DbErrorPage import DbErrorPage from .SeatingPlanPage import SeatingPlanPage from .BuyTicketPage import BuyTicketPage from .ManageNewsPage import ManageNewsPage +from .ManageUsersPage import ManageUsersPage -- 2.45.2 From 64cf86e01da0d8a81bdec0887ffcff2502c35961 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sat, 30 Nov 2024 10:58:04 +0100 Subject: [PATCH 82/85] refactor user profile edit and add it to the user management system --- src/ez_lan_manager/components/UserEditForm.py | 250 ++++++++++++++++++ src/ez_lan_manager/pages/EditProfile.py | 209 +-------------- src/ez_lan_manager/pages/ManageUsersPage.py | 46 ++-- .../services/DatabaseService.py | 17 ++ src/ez_lan_manager/services/UserService.py | 3 + 5 files changed, 300 insertions(+), 225 deletions(-) create mode 100644 src/ez_lan_manager/components/UserEditForm.py diff --git a/src/ez_lan_manager/components/UserEditForm.py b/src/ez_lan_manager/components/UserEditForm.py new file mode 100644 index 0000000..cb53b00 --- /dev/null +++ b/src/ez_lan_manager/components/UserEditForm.py @@ -0,0 +1,250 @@ +from datetime import date +from hashlib import sha256 +from typing import Optional + +from email_validator import validate_email, EmailNotValidError +from from_root import from_root +from rio import Component, Column, Button, Color, TextStyle, Text, TextInput, Row, Image, event, Spacer, DateInput, \ + TextInputChangeEvent, NoFileSelectedError + +from src.ez_lan_manager.services.UserService import UserService, NameNotAllowedError +from src.ez_lan_manager.services.ConfigurationService import ConfigurationService +from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.User import User + + +class UserEditForm(Component): + is_own_profile: bool = True + profile_picture: Optional[bytes] = None + user: Optional[User] = None + + input_user_name: str = "" + input_user_mail: str = "" + input_user_first_name: str = "" + input_user_last_name: str = "" + input_password_1: str = "" + input_password_2: str = "" + input_birthday: date = date.today() + + is_email_valid: bool = True + + result_text: str = "" + result_success: bool = True + + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Profil bearbeiten") + if self.is_own_profile: + self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) + self.profile_picture = await self.session[UserService].get_profile_picture(self.user.user_id) + else: + self.profile_picture = await self.session[UserService].get_profile_picture(self.user.user_id) + + self.input_user_name = self.user.user_name + self.input_user_mail = self.user.user_mail + self.input_user_first_name = self.optional_str_to_str(self.user.user_first_name) + self.input_user_last_name = self.optional_str_to_str(self.user.user_last_name) + self.input_birthday = self.user.user_birth_day if self.user.user_birth_day else date.today() + + + @staticmethod + def optional_str_to_str(s: Optional[str]) -> str: + if s: + return s + return "" + + def on_email_changed(self, change_event: TextInputChangeEvent) -> None: + try: + validate_email(change_event.text, check_deliverability=False) + self.is_email_valid = True + except EmailNotValidError: + self.is_email_valid = False + + async def upload_new_pfp(self) -> None: + try: + new_pfp = await self.session.pick_file(file_types=("png", "jpg", "jpeg"), multiple=False) + except NoFileSelectedError: + self.result_text = "Keine Datei ausgewählt!" + self.result_success = False + return + + if new_pfp.size_in_bytes > 2 * 1_000_000: + self.result_text = "Bild zu groß! (> 2MB)" + self.result_success = False + return + + image_data = await new_pfp.read_bytes() + await self.session[UserService].set_profile_picture(self.user.user_id, image_data) + self.profile_picture = image_data + self.result_text = "Gespeichert!" + self.result_success = True + + async def remove_profile_picture(self) -> None: + await self.session[UserService].remove_profile_picture(self.user.user_id) + self.profile_picture = None + self.result_text = "Profilbild entfernt!" + self.result_success = True + + async def on_save_pressed(self) -> None: + if not all((self.is_email_valid, self.input_user_name, self.input_user_mail)): + self.result_text = "Ungültige Werte!" + self.result_success = False + return + + if len(self.input_password_1.strip()) > 0: + if self.input_password_1.strip() != self.input_password_2.strip(): + self.result_text = "Passwörter nicht gleich!" + self.result_success = False + return + + self.user.user_mail = self.input_user_mail + + if self.input_birthday == date.today(): + self.user.user_birth_day = None + else: + self.user.user_birth_day = self.input_birthday + + self.user.user_first_name = self.input_user_first_name + self.user.user_last_name = self.input_user_last_name + self.user.user_name = self.input_user_name + if len(self.input_password_1.strip()) > 0: + self.user.user_password = sha256(self.input_password_1.strip().encode(encoding="utf-8")).hexdigest() + + try: + await self.session[UserService].update_user(self.user) + except NameNotAllowedError: + self.result_text = "Ungültige Zeichen in Nutzername" + self.result_success = False + return + + self.result_text = "Gespeichert!" + self.result_success = True + + def build(self) -> Component: + pfp_image_container = Image( + from_root("src/ez_lan_manager/assets/img/anon_pfp.png") if self.profile_picture is None else self.profile_picture, + align_x=0.5, + min_width=10, + min_height=10, + margin_top=1, + margin_bottom=1 + ) + + return Column( + pfp_image_container, + Button( + content=Text( + "Neues Bild hochladen", + style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + ), + align_x=0.5, + margin_bottom=1, + shape="rectangle", + style="major", + color="primary", + on_press=self.upload_new_pfp + ) if self.is_own_profile else Button( + content=Text( + "Bild löschen", + style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) + ), + align_x=0.5, + margin_bottom=1, + shape="rectangle", + style="major", + color="primary", + on_press=self.remove_profile_picture + ), + Row( + TextInput( + label=f"{'Deine ' if self.is_own_profile else ''}User-ID", + text=str(self.user.user_id), + is_sensitive=False, + margin_left=1, + grow_x=False + ), + TextInput( + label=f"{'Dein ' if self.is_own_profile else ''}Nickname", + text=self.bind().input_user_name, + is_sensitive=not self.is_own_profile, + margin_left=1, + margin_right=1, + grow_x=True + ), + margin_bottom=1 + ), + TextInput( + label="E-Mail Adresse", + text=self.bind().input_user_mail, + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + is_valid=self.is_email_valid, + on_change=self.on_email_changed + ), + Row( + TextInput( + label="Vorname", + text=self.bind().input_user_first_name, + margin_left=1, + margin_right=1, + grow_x=True + ), + TextInput( + label="Nachname", + text=self.bind().input_user_last_name, + margin_right=1, + grow_x=True + ), + margin_bottom=1 + ), + DateInput( + value=self.bind().input_birthday, + label="Geburtstag", + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True + ), + TextInput( + label="Neues Passwort setzen", + text=self.bind().input_password_1, + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + is_secret=True + ), + TextInput( + label="Neues Passwort wiederholen", + text=self.bind().input_password_2, + margin_left=1, + margin_right=1, + margin_bottom=1, + grow_x=True, + is_secret=True + ), + + Row( + Text( + text=self.bind().result_text, + style=TextStyle(fill=self.session.theme.success_color if self.result_success else self.session.theme.danger_color), + margin_left=1 + ), + Button( + content=Text( + "Speichern", + style=TextStyle(fill=self.session.theme.success_color, font_size=0.9), + align_x=0.2 + ), + align_x=0.9, + margin_top=2, + margin_bottom=1, + shape="rectangle", + style="major", + color="primary", + on_press=self.on_save_pressed + ), + ) + ) if self.user else Spacer() \ No newline at end of file diff --git a/src/ez_lan_manager/pages/EditProfile.py b/src/ez_lan_manager/pages/EditProfile.py index 7bb8aab..7dfa603 100644 --- a/src/ez_lan_manager/pages/EditProfile.py +++ b/src/ez_lan_manager/pages/EditProfile.py @@ -1,15 +1,10 @@ -from datetime import date, datetime -from hashlib import sha256 from typing import Optional -from from_root import from_root -from rio import Column, Component, event, Text, TextStyle, Button, Color, Row, TextInput, Image, TextInputChangeEvent, NoFileSelectedError, \ - ProgressCircle -from email_validator import validate_email, EmailNotValidError +from rio import Column, Component, event, Spacer from src.ez_lan_manager import ConfigurationService, UserService -from src.ez_lan_manager.components.AnimatedText import AnimatedText from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.components.UserEditForm import UserEditForm from src.ez_lan_manager.types.SessionStorage import SessionStorage from src.ez_lan_manager.types.User import User @@ -18,210 +13,14 @@ class EditProfilePage(Component): user: Optional[User] = None pfp: Optional[bytes] = None - @staticmethod - def optional_date_to_str(d: Optional[date]) -> str: - if not d: - return "" - return d.strftime("%d.%m.%Y") - @event.on_populate async def on_populate(self) -> None: await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Profil bearbeiten") self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id) self.pfp = await self.session[UserService].get_profile_picture(self.user.user_id) - def on_email_changed(self, change_event: TextInputChangeEvent) -> None: - try: - validate_email(change_event.text, check_deliverability=False) - self.email_input.is_valid = True - except EmailNotValidError: - self.email_input.is_valid = False - - def on_birthday_changed(self, change_event: TextInputChangeEvent) -> None: - if len(change_event.text) == 0: - self.birthday_input.is_valid = True - return - try: - day, month, year = change_event.text.split(".") - year = int(year) - if year < 1900 or year > datetime.now().year - 12: - raise ValueError - date(day=int(day), month=int(month), year=year) - self.birthday_input.is_valid = True - except (ValueError, TypeError, IndexError): - self.birthday_input.is_valid = False - - async def upload_new_pfp(self) -> None: - try: - new_pfp = await self.session.file_chooser(file_extensions=("png", "jpg", "jpeg"), multiple=False) - except NoFileSelectedError: - await self.animated_text.display_text(False, "Keine Datei ausgewählt!") - return - - if new_pfp.size_in_bytes > 2 * 1_000_000: - await self.animated_text.display_text(False, "Bild zu groß! (> 2MB)") - return - - image_data = await new_pfp.read_bytes() - await self.session[UserService].set_profile_picture(self.session[SessionStorage].user_id, image_data) - self.pfp_image_container.image = image_data - await self.animated_text.display_text(True, "Gespeichert!") - - async def on_save_pressed(self) -> None: - if not all((self.email_input.is_valid, self.birthday_input.is_valid)): - await self.animated_text.display_text(False, "Ungültige Werte!") - return - - if len(self.new_pw_1_input.text.strip()) > 0: - if self.new_pw_1_input.text.strip() != self.new_pw_2_input.text.strip(): - await self.animated_text.display_text(False, "Passwörter nicht gleich!") - return - - user: User = await self.session[UserService].get_user(self.session[SessionStorage].user_id) - user.user_mail = self.email_input.text - - if len(self.birthday_input.text) == 0: - user.user_birth_day = None - else: - day, month, year = self.birthday_input.text.split(".") - user.user_birth_day = date(day=int(day), month=int(month), year=int(year)) - - user.user_first_name = self.first_name_input.text - user.user_last_name = self.last_name_input.text - if len(self.new_pw_1_input.text.strip()) > 0: - user.user_password = sha256(self.new_pw_1_input.text.encode(encoding="utf-8")).hexdigest() - - await self.session[UserService].update_user(user) - await self.animated_text.display_text(True, "Gespeichert!") - def build(self) -> Component: - if not self.user: - return Column( - MainViewContentBox( - ProgressCircle( - color="secondary", - align_x=0.5, - margin_top=2, - margin_bottom=2 - ) - ), - align_y=0 - ) - - self.animated_text = AnimatedText( - margin_top=2, - margin_bottom=1, - align_x=0.1 - ) - - self.email_input = TextInput( - label="E-Mail Adresse", - text=self.user.user_mail, - margin_left=1, - margin_right=1, - margin_bottom=1, - grow_x=True, - on_change=self.on_email_changed - ) - self.first_name_input = TextInput( - label="Vorname", - text=self.user.user_first_name, - margin_left=1, - margin_right=1, - grow_x=True - ) - self.last_name_input = TextInput( - label="Nachname", - text=self.user.user_last_name, - margin_right=1, - grow_x=True - ) - self.birthday_input = TextInput( - label="Geburtstag (TT.MM.JJJJ)", - text=self.optional_date_to_str(self.user.user_birth_day), - margin_left=1, - margin_right=1, - margin_bottom=1, - grow_x=True, - on_change=self.on_birthday_changed - ) - self.new_pw_1_input = TextInput( - label="Neues Passwort setzen", - text="", - margin_left=1, - margin_right=1, - margin_bottom=1, - grow_x=True, - is_secret=True - ) - self.new_pw_2_input = TextInput( - label="Neues Passwort wiederholen", - text="", - margin_left=1, - margin_right=1, - margin_bottom=1, - grow_x=True, - is_secret=True - ) - - self.pfp_image_container = Image( - from_root("src/ez_lan_manager/assets/img/anon_pfp.png") if self.pfp is None else self.pfp, - align_x=0.5, - min_width=10, - min_height=10, - margin_top=1, - margin_bottom=1 - ) - return Column( - MainViewContentBox( - content=Column( - self.pfp_image_container, - Button( - content=Text( - "Neues Bild hochladen", - style=TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) - ), - align_x=0.5, - margin_bottom=1, - shape="rectangle", - style="major", - color="primary", - on_press=self.upload_new_pfp - ), - Row( - TextInput(label="Deine User-ID", text=self.user.user_id, is_sensitive=False, margin_left=1, grow_x=False), - TextInput(label="Dein Nickname", text=self.user.user_name, is_sensitive=False, margin_left=1, margin_right=1, grow_x=True), - margin_bottom=1 - ), - self.email_input, - Row( - self.first_name_input, - self.last_name_input, - margin_bottom=1 - ), - self.birthday_input, - self.new_pw_1_input, - self.new_pw_2_input, - - Row( - self.animated_text, - Button( - content=Text( - "Speichern", - style=TextStyle(fill=self.session.theme.success_color, font_size=0.9), - align_x=0.2 - ), - align_x=0.9, - margin_top=2, - margin_bottom=1, - shape="rectangle", - style="major", - color="primary", - on_press=self.on_save_pressed - ), - ) - ) - ), - align_y=0, + MainViewContentBox(UserEditForm(is_own_profile=True)), + Spacer(grow_y=True) ) diff --git a/src/ez_lan_manager/pages/ManageUsersPage.py b/src/ez_lan_manager/pages/ManageUsersPage.py index 0a92073..2e143a0 100644 --- a/src/ez_lan_manager/pages/ManageUsersPage.py +++ b/src/ez_lan_manager/pages/ManageUsersPage.py @@ -4,10 +4,11 @@ from typing import Optional, Coroutine import rio from rio import Column, Component, event, TextStyle, Text, TextInput, ThemeContextSwitcher, Grid, \ - PointerEventListener, PointerEvent, Rectangle, CursorStyle, Color, TextInputChangeEvent + PointerEventListener, PointerEvent, Rectangle, CursorStyle, Color, TextInputChangeEvent, Spacer from src.ez_lan_manager import ConfigurationService, UserService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.components.UserEditForm import UserEditForm from src.ez_lan_manager.types.User import User logger = logging.getLogger(__name__.split(".")[-1]) @@ -104,24 +105,29 @@ class ManageUsersPage(Component): ) ), MainViewContentBox( - Text( - text=f"Nutzer {self.selected_user.user_name} gewählt.", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=2, - align_x=0.5 - ) if self.selected_user else Text( - text="Bitte Nutzer auswählen...", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=2, - align_x=0.5 - )), + Column( + Text( + text="Allgemeines", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ) if self.selected_user else Spacer(), + UserEditForm( + is_own_profile=False, + user=self.selected_user + ) if self.selected_user else Text( + text="Bitte Nutzer auswählen...", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ))), align_y=0 ) diff --git a/src/ez_lan_manager/services/DatabaseService.py b/src/ez_lan_manager/services/DatabaseService.py index a917edd..dd8619f 100644 --- a/src/ez_lan_manager/services/DatabaseService.py +++ b/src/ez_lan_manager/services/DatabaseService.py @@ -754,3 +754,20 @@ class DatabaseService: results.append(self._map_db_result_to_user(user_raw)) return results + + async def remove_profile_picture(self, user_id: int): + async with self._connection_pool.acquire() as conn: + async with conn.cursor(aiomysql.Cursor) as cursor: + try: + await cursor.execute( + "DELETE FROM user_profile_picture WHERE user_id = %s", + user_id + ) + await conn.commit() + except aiomysql.InterfaceError: + pool_init_result = await self.init_db_pool() + if not pool_init_result: + raise NoDatabaseConnectionError + return await self.remove_profile_picture(user_id) + except Exception as e: + logger.warning(f"Error deleting user profile picture: {e}") diff --git a/src/ez_lan_manager/services/UserService.py b/src/ez_lan_manager/services/UserService.py index 2120ae4..aa53b7c 100644 --- a/src/ez_lan_manager/services/UserService.py +++ b/src/ez_lan_manager/services/UserService.py @@ -33,6 +33,9 @@ class UserService: async def set_profile_picture(self, user_id: int, picture: bytes) -> None: await self._db_service.set_user_profile_picture(user_id, picture) + async def remove_profile_picture(self, user_id: int) -> None: + await self._db_service.remove_profile_picture(user_id) + async def get_profile_picture(self, user_id: int) -> bytes: return await self._db_service.get_user_profile_picture(user_id) -- 2.45.2 From 18ff806d3baebd5b8e96912a09ec9ceca5150529 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sat, 30 Nov 2024 12:13:20 +0100 Subject: [PATCH 83/85] make is_active = true the default for new users --- sql/create_database.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/create_database.sql b/sql/create_database.sql index d25b250..20a3cee 100644 --- a/sql/create_database.sql +++ b/sql/create_database.sql @@ -176,7 +176,7 @@ CREATE TABLE `users` ( `user_first_name` varchar(50) DEFAULT NULL, `user_last_name` varchar(50) DEFAULT NULL, `user_birth_date` date DEFAULT NULL, - `is_active` tinyint(4) DEFAULT NULL, + `is_active` tinyint(4) DEFAULT 1, `is_team_member` tinyint(4) DEFAULT NULL, `is_admin` tinyint(4) DEFAULT NULL, `created_at` datetime DEFAULT current_timestamp(), -- 2.45.2 From 82b16b868f6b2ec26b97048266222e42993031a0 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sat, 30 Nov 2024 12:32:31 +0100 Subject: [PATCH 84/85] add is_active to login, add account and seating management to user management, redirect to base page on logout --- src/ez_lan_manager/components/LoginBox.py | 37 ++-- .../components/NewTransactionForm.py | 76 ++++++++ src/ez_lan_manager/components/UserInfoBox.py | 7 +- src/ez_lan_manager/pages/ManageUsersPage.py | 174 ++++++++++++++++-- 4 files changed, 259 insertions(+), 35 deletions(-) create mode 100644 src/ez_lan_manager/components/NewTransactionForm.py diff --git a/src/ez_lan_manager/components/LoginBox.py b/src/ez_lan_manager/components/LoginBox.py index 6770043..0a643b7 100644 --- a/src/ez_lan_manager/components/LoginBox.py +++ b/src/ez_lan_manager/components/LoginBox.py @@ -1,6 +1,5 @@ -from typing import Callable - -from rio import Component, TextStyle, Color, TextInput, Button, Text, Rectangle, Column, Row, Spacer, TextInputChangeEvent +from rio import Component, TextStyle, Color, TextInput, Button, Text, Rectangle, Column, Row, Spacer, \ + EventHandler from src.ez_lan_manager.services.UserService import UserService from src.ez_lan_manager.types.SessionStorage import SessionStorage @@ -8,51 +7,49 @@ from src.ez_lan_manager.types.User import User class LoginBox(Component): - status_change_cb: Callable + status_change_cb: EventHandler = None TEXT_STYLE = TextStyle(fill=Color.from_hex("02dac5"), font_size=0.9) - user_name_input_text: list[str] = [""] - password_input_text: list[str] = [""] + user_name_input_text: str = "" + password_input_text: str = "" user_name_input_is_valid = True password_input_is_valid = True login_button_is_loading = False + is_account_locked: bool = False async def _on_login_pressed(self) -> None: - if await self.session[UserService].is_login_valid(self.user_name_input_text[0], self.password_input_text[0]): - user: User = await self.session[UserService].get_user(self.user_name_input_text[0]) + if await self.session[UserService].is_login_valid(self.user_name_input_text, self.password_input_text): + user: User = await self.session[UserService].get_user(self.user_name_input_text) + if not user.is_active: + self.is_account_locked = True + return self.user_name_input_is_valid = True self.password_input_is_valid = True self.login_button_is_loading = False + self.is_account_locked = False await self.session[SessionStorage].set_user_id_and_team_member_flag(user.user_id, user.is_team_member) await self.status_change_cb() else: self.user_name_input_is_valid = False self.password_input_is_valid = False self.login_button_is_loading = False + self.is_account_locked = False await self.force_refresh() def build(self) -> Component: - def set_user_name_input_text(e: TextInputChangeEvent) -> None: - self.user_name_input_text[0] = e.text - - def set_password_input_text(e: TextInputChangeEvent) -> None: - self.password_input_text[0] = e.text - user_name_input = TextInput( - text="", + text=self.bind().user_name_input_text, label="Benutzername", accessibility_label="Benutzername", min_height=0.5, on_confirm=lambda _: self._on_login_pressed(), - on_change=set_user_name_input_text, is_valid=self.user_name_input_is_valid ) password_input = TextInput( - text="", + text=self.bind().password_input_text, label="Passwort", accessibility_label="Passwort", is_secret=True, on_confirm=lambda _: self._on_login_pressed(), - on_change=set_password_input_text, is_valid=self.password_input_is_valid ) login_button = Button( @@ -91,8 +88,10 @@ class LoginBox(Component): Spacer(), forgot_password_button, proportions=(49, 2, 49) - ) + ), + margin_bottom=0.5 ), + Text(text="Dieses Konto\nist gesperrt", style=TextStyle(fill=self.session.theme.danger_color, font_size=0.9 if self.is_account_locked else 0), align_x=0.5), spacing=0.4 ), fill=Color.TRANSPARENT, diff --git a/src/ez_lan_manager/components/NewTransactionForm.py b/src/ez_lan_manager/components/NewTransactionForm.py new file mode 100644 index 0000000..366fd5e --- /dev/null +++ b/src/ez_lan_manager/components/NewTransactionForm.py @@ -0,0 +1,76 @@ +from datetime import datetime +from typing import Optional + +from rio import Component, Column, NumberInput, ThemeContextSwitcher, TextInput, Row, Button, EventHandler + +from src.ez_lan_manager.types.Transaction import Transaction +from src.ez_lan_manager.types.User import User + + +class NewTransactionForm(Component): + user: Optional[User] = None + input_value: float = 0 + input_reason: str = "" + new_transaction_cb: EventHandler[Transaction] = None + + async def send_debit_transaction(self) -> None: + await self.call_event_handler( + self.new_transaction_cb, + Transaction( + user_id=self.user.user_id, + value=round(self.input_value * 100), + is_debit=True, + reference=self.input_reason, + transaction_date=datetime.now() + ) + ) + + async def send_credit_transaction(self) -> None: + await self.call_event_handler( + self.new_transaction_cb, + Transaction( + user_id=self.user.user_id, + value=round(self.input_value * 100), + is_debit=False, + reference=self.input_reason, + transaction_date=datetime.now() + ) + ) + + def build(self) -> Component: + return ThemeContextSwitcher( + content=Column( + NumberInput( + value=self.bind().input_value, + label="Betrag", + suffix_text="€", + decimals=2, + thousands_separator=".", + margin=1, + margin_bottom=0 + ), + TextInput( + text=self.bind().input_reason, + label="Beschreibung", + margin=1, + margin_bottom=0 + ), + Row( + Button( + content="Entfernen", + shape="rectangle", + color="danger", + margin=1, + on_press=self.send_debit_transaction + ), + Button( + content="Hinzufügen", + shape="rectangle", + color="success", + margin=1, + on_press=self.send_credit_transaction + ) + ) + ), + color="primary" + ) diff --git a/src/ez_lan_manager/components/UserInfoBox.py b/src/ez_lan_manager/components/UserInfoBox.py index b93fee6..a303140 100644 --- a/src/ez_lan_manager/components/UserInfoBox.py +++ b/src/ez_lan_manager/components/UserInfoBox.py @@ -1,7 +1,7 @@ from random import choice -from typing import Optional, Callable +from typing import Optional -from rio import Component, TextStyle, Color, Button, Text, Rectangle, Column, Row, Spacer, Link, event +from rio import Component, TextStyle, Color, Button, Text, Rectangle, Column, Row, Spacer, Link, event, EventHandler from src.ez_lan_manager.components.UserInfoBoxButton import UserInfoBoxButton from src.ez_lan_manager.services.UserService import UserService @@ -38,7 +38,7 @@ class StatusButton(Component): ) class UserInfoBox(Component): - status_change_cb: Callable + 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 @@ -53,6 +53,7 @@ class UserInfoBox(Component): await self.session[SessionStorage].clear() self.user = None await self.status_change_cb() + self.session.navigate_to("/") @event.on_populate async def async_init(self) -> None: diff --git a/src/ez_lan_manager/pages/ManageUsersPage.py b/src/ez_lan_manager/pages/ManageUsersPage.py index 2e143a0..ec55e26 100644 --- a/src/ez_lan_manager/pages/ManageUsersPage.py +++ b/src/ez_lan_manager/pages/ManageUsersPage.py @@ -1,26 +1,26 @@ import logging from dataclasses import field -from typing import Optional, Coroutine +from typing import Optional -import rio from rio import Column, Component, event, TextStyle, Text, TextInput, ThemeContextSwitcher, Grid, \ - PointerEventListener, PointerEvent, Rectangle, CursorStyle, Color, TextInputChangeEvent, Spacer + PointerEventListener, PointerEvent, Rectangle, CursorStyle, Color, TextInputChangeEvent, Spacer, Row, Switch, \ + SwitchChangeEvent, EventHandler -from src.ez_lan_manager import ConfigurationService, UserService +from src.ez_lan_manager import ConfigurationService, UserService, AccountingService, SeatingService from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox +from src.ez_lan_manager.components.NewTransactionForm import NewTransactionForm from src.ez_lan_manager.components.UserEditForm import UserEditForm +from src.ez_lan_manager.services.AccountingService import InsufficientFundsError +from src.ez_lan_manager.types.SessionStorage import SessionStorage +from src.ez_lan_manager.types.Transaction import Transaction from src.ez_lan_manager.types.User import User logger = logging.getLogger(__name__.split(".")[-1]) -# Helps type checker grasp the concept of "lambda _: None" as a Coroutine -async def noop(_) -> None: - pass - class ClickableGridContent(Component): text: str = "" is_hovered: bool = False - clicked_cb: Coroutine = noop + clicked_cb: EventHandler[str] = None async def on_mouse_enter(self, _: PointerEvent) -> None: self.is_hovered = True @@ -29,9 +29,9 @@ class ClickableGridContent(Component): self.is_hovered = False async def on_mouse_click(self, _: PointerEvent) -> None: - await self.clicked_cb(self.text) + await self.call_event_handler(self.clicked_cb, self.text) - def build(self) -> rio.Component: + def build(self) -> Component: return PointerEventListener( content=Rectangle( content=Text( @@ -51,19 +51,63 @@ class ManageUsersPage(Component): selected_user: Optional[User] = None all_users: Optional[list] = None search_results: list[User] = field(default_factory=list) + accounting_section_result_text: str = "" + accounting_section_result_success: bool = True + user_account_balance: str = "0.00 €" + user_seat: str = "-" + is_user_account_locked: bool = False @event.on_populate async def on_populate(self) -> None: - await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - News Verwaltung") + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Nutzer Verwaltung") self.all_users = await self.session[UserService].get_all_users() self.search_results = self.all_users async def on_user_clicked(self, user_name: str) -> None: self.selected_user = next(filter(lambda user: user.user_name == user_name, self.all_users)) + user_account_balance_raw = await self.session[AccountingService].get_balance(self.selected_user.user_id) + self.user_account_balance = AccountingService.make_euro_string_from_int(user_account_balance_raw) + seat = await self.session[SeatingService].get_user_seat(self.selected_user.user_id) + self.user_seat = seat.seat_id if seat else "-" + self.is_user_account_locked = not self.selected_user.is_active async def on_search_parameters_changed(self, e: TextInputChangeEvent) -> None: self.search_results = list(filter(lambda user: (e.text.lower() in user.user_name.lower()) or e.text.lower() in str(user.user_id), self.all_users)) + async def change_account_active(self, _: SwitchChangeEvent) -> None: + self.selected_user.is_active = not self.is_user_account_locked + await self.session[UserService].update_user(self.selected_user) + + async def on_new_transaction(self, transaction: Transaction) -> None: + if not self.session[SessionStorage].is_team_member: # Better safe than sorry + return + + logger.info(f"Got new transaction for user with ID '{transaction.user_id}' over " + f"{'-' if transaction.is_debit else '+'}" + f"{AccountingService.make_euro_string_from_int(transaction.value)} " + f"with reference '{transaction.reference}'") + + if transaction.is_debit: + try: + await self.session[AccountingService].remove_balance( + transaction.user_id, + transaction.value, + transaction.reference + ) + except InsufficientFundsError: + self.accounting_section_result_text = "Guthaben nicht ausreichend!" + self.accounting_section_result_success = False + return + else: + await self.session[AccountingService].add_balance( + transaction.user_id, + transaction.value, + transaction.reference + ) + + self.accounting_section_result_text = f"Guthaben {'entfernt' if transaction.is_debit else 'hinzugefügt'}!" + self.accounting_section_result_success = True + def build(self) -> Component: return Column( @@ -128,6 +172,110 @@ class ManageUsersPage(Component): margin_top=2, margin_bottom=2, align_x=0.5 - ))), + ) + ) + ), + MainViewContentBox( + Column( + Text( + text="Konto & Sitzplatz", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ), + Row( + Text( + text="Kontostand:", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=0.5, + margin_bottom=1, + margin_left=2 + ), + Text( + text=self.bind().user_account_balance, + style=TextStyle( + fill=self.session.theme.neutral_color, + font_size=1 + ), + margin_top=0.5, + margin_bottom=1, + margin_right=2, + justify="right" + ), + ), + Row( + Text( + text="Kontosperrung:", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=0.5, + margin_bottom=1, + margin_left=2, + grow_x=True + ), + ThemeContextSwitcher( + content=Switch( + is_on=self.bind().is_user_account_locked, + margin_top=0.5, + margin_bottom=1, + margin_right=2, + on_change=self.change_account_active + ), + color="primary" + ), + ), + Row( + Text( + text="Sitzplatz:", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=0.5, + margin_bottom=1, + margin_left=2 + ), + Text( + text=self.bind().user_seat, + style=TextStyle( + fill=self.session.theme.neutral_color, + font_size=1 + ), + margin_top=0.5, + margin_bottom=1, + margin_right=2, + justify="right" + ), + ), + Text( + text="Geld hinzufügen/entfernen", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1 + ), + margin_top=0.5, + margin_bottom=1, + align_x=0.5 + ), + NewTransactionForm(user=self.selected_user, new_transaction_cb=self.on_new_transaction), + Text( + text=self.bind().accounting_section_result_text, + style=TextStyle( + fill=self.session.theme.success_color if self.accounting_section_result_success else self.session.theme.danger_color + ), + margin_left=1, + margin_bottom=1 + ) + ) + ) if self.selected_user else Spacer(), align_y=0 ) -- 2.45.2 From a501948aeef4e8b40e89db3ef7bab24763e08510 Mon Sep 17 00:00:00 2001 From: David Rodenkirchen Date: Sat, 30 Nov 2024 12:41:26 +0100 Subject: [PATCH 85/85] Add placeholder pages, change order of boxes in user management --- src/EzLanManager.py | 12 +++++ .../pages/ManageCateringPage.py | 32 +++++++++++ .../pages/ManageTournamentsPage.py | 32 +++++++++++ src/ez_lan_manager/pages/ManageUsersPage.py | 54 +++++++++---------- src/ez_lan_manager/pages/__init__.py | 2 + 5 files changed, 105 insertions(+), 27 deletions(-) create mode 100644 src/ez_lan_manager/pages/ManageCateringPage.py create mode 100644 src/ez_lan_manager/pages/ManageTournamentsPage.py diff --git a/src/EzLanManager.py b/src/EzLanManager.py index beea489..5c3383f 100644 --- a/src/EzLanManager.py +++ b/src/EzLanManager.py @@ -143,6 +143,18 @@ if __name__ == "__main__": build=pages.ManageUsersPage, guard=team_guard ), + ComponentPage( + name="ManageCateringPage", + url_segment="manage-catering", + build=pages.ManageCateringPage, + guard=team_guard + ), + ComponentPage( + name="ManageTournamentsPage", + url_segment="manage-tournaments", + build=pages.ManageTournamentsPage, + guard=team_guard + ), ComponentPage( name="DbErrorPage", url_segment="db-error", diff --git a/src/ez_lan_manager/pages/ManageCateringPage.py b/src/ez_lan_manager/pages/ManageCateringPage.py new file mode 100644 index 0000000..d1e90bc --- /dev/null +++ b/src/ez_lan_manager/pages/ManageCateringPage.py @@ -0,0 +1,32 @@ +import logging + +from rio import Column, Component, event, TextStyle, Text, Spacer + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox + +logger = logging.getLogger(__name__.split(".")[-1]) + +class ManageCateringPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Catering Verwaltung") + + def build(self) -> Component: + return Column( + MainViewContentBox( + Column( + Text( + text="Catering Verwaltung", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ) + ) + ), + Spacer() + ) diff --git a/src/ez_lan_manager/pages/ManageTournamentsPage.py b/src/ez_lan_manager/pages/ManageTournamentsPage.py new file mode 100644 index 0000000..e7c5956 --- /dev/null +++ b/src/ez_lan_manager/pages/ManageTournamentsPage.py @@ -0,0 +1,32 @@ +import logging + +from rio import Column, Component, event, TextStyle, Text, Spacer + +from src.ez_lan_manager import ConfigurationService +from src.ez_lan_manager.components.MainViewContentBox import MainViewContentBox + +logger = logging.getLogger(__name__.split(".")[-1]) + +class ManageTournamentsPage(Component): + @event.on_populate + async def on_populate(self) -> None: + await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turnier Verwaltung") + + def build(self) -> Component: + return Column( + MainViewContentBox( + Column( + Text( + text="Turnier Verwaltung", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ) + ) + ), + Spacer() + ) diff --git a/src/ez_lan_manager/pages/ManageUsersPage.py b/src/ez_lan_manager/pages/ManageUsersPage.py index ec55e26..fe6e812 100644 --- a/src/ez_lan_manager/pages/ManageUsersPage.py +++ b/src/ez_lan_manager/pages/ManageUsersPage.py @@ -148,33 +148,6 @@ class ManageUsersPage(Component): ) ) ), - MainViewContentBox( - Column( - Text( - text="Allgemeines", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=2, - align_x=0.5 - ) if self.selected_user else Spacer(), - UserEditForm( - is_own_profile=False, - user=self.selected_user - ) if self.selected_user else Text( - text="Bitte Nutzer auswählen...", - style=TextStyle( - fill=self.session.theme.background_color, - font_size=1.2 - ), - margin_top=2, - margin_bottom=2, - align_x=0.5 - ) - ) - ), MainViewContentBox( Column( Text( @@ -277,5 +250,32 @@ class ManageUsersPage(Component): ) ) ) if self.selected_user else Spacer(), + MainViewContentBox( + Column( + Text( + text="Allgemeines", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ) if self.selected_user else Spacer(), + UserEditForm( + is_own_profile=False, + user=self.selected_user + ) if self.selected_user else Text( + text="Bitte Nutzer auswählen...", + style=TextStyle( + fill=self.session.theme.background_color, + font_size=1.2 + ), + margin_top=2, + margin_bottom=2, + align_x=0.5 + ) + ) + ), align_y=0 ) diff --git a/src/ez_lan_manager/pages/__init__.py b/src/ez_lan_manager/pages/__init__.py index 869d6a8..bedca05 100644 --- a/src/ez_lan_manager/pages/__init__.py +++ b/src/ez_lan_manager/pages/__init__.py @@ -17,3 +17,5 @@ from .SeatingPlanPage import SeatingPlanPage from .BuyTicketPage import BuyTicketPage from .ManageNewsPage import ManageNewsPage from .ManageUsersPage import ManageUsersPage +from .ManageCateringPage import ManageCateringPage +from .ManageTournamentsPage import ManageTournamentsPage -- 2.45.2