diff --git a/StellaPay.py b/StellaPay.py index ff0414b..49ea9fc 100644 --- a/StellaPay.py +++ b/StellaPay.py @@ -1,9 +1,9 @@ import asyncio +import logging import subprocess import sys import threading import time -from asyncio import AbstractEventLoop from typing import Optional, Dict, Any import kivy @@ -12,6 +12,7 @@ from kivy.config import ConfigParser, Config from kivy.core.window import Window from kivy.lang import Builder +from kivy.logger import ColoredFormatter from kivy.uix.screenmanager import ScreenManager from kivymd.app import MDApp @@ -19,7 +20,6 @@ from PythonNFCReader.NFCReader import CardConnectionManager from data.DataController import DataController from db.DatabaseManager import DatabaseManager -from ds.NFCCardInfo import NFCCardInfo from scrs.ConfirmedScreen import ConfirmedScreen from scrs.CreditsScreen import CreditsScreen from scrs.DefaultScreen import DefaultScreen @@ -58,7 +58,17 @@ def __init__(self, **kwargs): # Create a data controller that is used to access data of users and products. self.data_controller: DataController = DataController() - StellaPay.build_version = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD']).strip() + self.loop = asyncio.new_event_loop() + self.event_loop_thread = threading.Thread(target=self.run_event_loop, args=(self.loop,), daemon=True) + + # Set millisecond format to use a decimal because I'm in the US + logging.Formatter.default_msec_format = "%s.%03d" + # Add timestamp to log file + Logger.handlers[1].setFormatter(logging.Formatter("%(asctime)s %(message)s")) + # Add timestampt to console output + Logger.handlers[2].setFormatter(ColoredFormatter("[%(levelname)-18s] %(asctime)s %(message)s")) + + StellaPay.build_version = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"]).strip() Logger.debug(f"StellaPayUI: Running build {self.build_version}") def build(self): @@ -85,8 +95,9 @@ def build(self): # Create the section if it does not exist yet. self.config.adddefaultsection(configuration_option.section_name) # Set the configuration option - self.config.setdefault(configuration_option.section_name, configuration_option.config_name, - configuration_option.default_value) + self.config.setdefault( + configuration_option.section_name, configuration_option.config_name, configuration_option.default_value + ) # Update config (if needed) self.config.write() @@ -112,7 +123,6 @@ def build(self): Connections.hostname = hostname except Exception: Logger.warning("StellaPayUI: Using default hostname, since none was provided") - pass if self.get_config_option(config.ConfigurationOption.DEVICE_SHOW_FULLSCREEN) == "True": Logger.info(f"StellaPayUI: Running in fullscreen mode!") @@ -125,11 +135,9 @@ def build(self): Window.show_cursor = self.get_config_option(config.ConfigurationOption.DEVICE_SHOW_CURSOR) == "True" # Load .kv file - Builder.load_file('kvs/DefaultScreen.kv') + Builder.load_file("kvs/DefaultScreen.kv") Logger.debug("StellaPayUI: Starting event loop") - self.loop: AbstractEventLoop = asyncio.new_event_loop() - self.event_loop_thread = threading.Thread(target=self.run_event_loop, args=(self.loop,), daemon=True) self.event_loop_thread.start() Logger.debug("StellaPayUI: Start authentication to backend") @@ -137,10 +145,11 @@ def build(self): # Start thread that keeps track of connection status to the server. self.data_controller.start_connection_update_thread(Connections.connection_status()) + # self.loop.call_later( + # int(self.get_config_option(config.ConfigurationOption.TIME_TO_WAIT_BEFORE_AUTHENTICATING)), + # self.data_controller.start_setup_procedure) # Start the setup procedure in a bit - self.loop.call_later( - int(self.get_config_option(config.ConfigurationOption.TIME_TO_WAIT_BEFORE_AUTHENTICATING)), - self.data_controller.start_setup_procedure) + asyncio.run_coroutine_threadsafe(self.data_controller.start_setup_procedure(), loop=self.loop) # Initialize defaultScreen (to create session cookies for API calls) ds_screen = DefaultScreen(name=Screens.DEFAULT_SCREEN.value) @@ -149,12 +158,10 @@ def build(self): screen_manager.add_widget(StartupScreen(name=Screens.STARTUP_SCREEN.value)) screen_manager.add_widget(ds_screen) screen_manager.add_widget(WelcomeScreen(name=Screens.WELCOME_SCREEN.value)) - screen_manager.add_widget( - RegisterUIDScreen(name=Screens.REGISTER_UID_SCREEN.value)) + screen_manager.add_widget(RegisterUIDScreen(name=Screens.REGISTER_UID_SCREEN.value)) screen_manager.add_widget(ConfirmedScreen(name=Screens.CONFIRMED_SCREEN.value)) screen_manager.add_widget(CreditsScreen(name=Screens.CREDITS_SCREEN.value)) - screen_manager.add_widget( - ProductScreen(name=Screens.PRODUCT_SCREEN.value)) + screen_manager.add_widget(ProductScreen(name=Screens.PRODUCT_SCREEN.value)) screen_manager.add_widget(ProfileScreen(name=Screens.PROFILE_SCREEN.value)) Logger.debug("StellaPayUI: Registering default screen as card listener") @@ -175,71 +182,60 @@ def get_user_by_email(self, email: str) -> Optional[str]: return None def run_event_loop(self, loop): - asyncio.set_event_loop(loop) + # asyncio.set_event_loop(loop) + loop.set_debug(True) loop.run_forever() - def done_loading_authentication(self): + async def done_loading_authentication(self): # The session to the server has been authenticated, so now we can start loading users and products # First load the users, then the categories and products start = time.time() * 1000 - # Callback for loaded user data - def handle_user_data(user_data): - if user_data is None: - Logger.critical("StellaPayUI: Could not retrieve users from the server!") - sys.exit(1) - return - - Logger.info(f"StellaPayUI: Loaded {len(user_data)} users in {time.time() * 1000 - start} ms.") - - # Store the user mapping so other screens can use it. - self.user_mapping = user_data + # Load user data + user_data = await self.data_controller.get_user_data() - screen_manager.get_screen(Screens.STARTUP_SCREEN.value).users_loaded = AsyncResult(True, data=True) + if user_data is None: + Logger.critical("StellaPayUI: Could not retrieve users from the server!") + sys.exit(1) - # Load user data - self.data_controller.get_user_data(callback=handle_user_data) + Logger.info(f"StellaPayUI: Loaded {len(user_data)} users in {time.time() * 1000 - start} ms.") - # Callback for loaded product data - def handle_product_data(product_data): + # Store the user mapping so other screens can use it. + self.user_mapping = {user_entry.real_name: user_entry.email_address for user_entry in user_data} - if product_data is None: - Logger.error(f"StellaPayUI: Something went wrong when fetching product data!") - screen_manager.get_screen(Screens.STARTUP_SCREEN.value).products_loaded = AsyncResult(False) - return + screen_manager.get_screen(Screens.STARTUP_SCREEN.value).users_loaded = AsyncResult(True, data=True) - Logger.info(f"StellaPayUI: Loaded {len(product_data)} products.") + # Get category data (and then retrieve product data) + categories = await self.data_controller.get_category_data() - # Signal to the startup screen that the products have been loaded. - screen_manager.get_screen(Screens.STARTUP_SCREEN.value).products_loaded = AsyncResult(True, data=True) + if len(categories) == 0: + Logger.error(f"StellaPayUI: Something went wrong when fetching category data!") + screen_manager.get_screen(Screens.STARTUP_SCREEN.value).categories_loaded = AsyncResult(False) + return - self.loaded_all_users_and_products() + Logger.info(f"StellaPayUI: Loaded {len(categories)} categories.") - # Callback for loaded category data - def handle_category_data(category_data): + # Signal to the startup screen that the categories have been loaded. + screen_manager.get_screen(Screens.STARTUP_SCREEN.value).categories_loaded = AsyncResult(True, data=True) - if category_data is None: - Logger.error(f"StellaPayUI: Something went wrong when fetching category data!") - screen_manager.get_screen(Screens.STARTUP_SCREEN.value).categories_loaded = AsyncResult(False) - return + product_data = await self.data_controller.get_product_data() - Logger.info(f"StellaPayUI: Loaded {len(category_data)} categories.") + if product_data is None or len(product_data) == 0: + Logger.error(f"StellaPayUI: Something went wrong when fetching product data!") + screen_manager.get_screen(Screens.STARTUP_SCREEN.value).products_loaded = AsyncResult(False) + return - # Signal to the startup screen that the categories have been loaded. - screen_manager.get_screen(Screens.STARTUP_SCREEN.value).categories_loaded = AsyncResult(True, data=True) + Logger.info(f"StellaPayUI: Loaded {len(product_data)} products.") - self.data_controller.get_product_data(callback=handle_product_data) + # Signal to the startup screen that the products have been loaded. + screen_manager.get_screen(Screens.STARTUP_SCREEN.value).products_loaded = AsyncResult(True, data=True) - # Get category data (and then retrieve product data) - self.data_controller.get_category_data(callback=handle_category_data) + self.loaded_all_users_and_products() - # Callback to handle the card info - def handle_card_info(card_info: NFCCardInfo): - Logger.info(f"StellaPayUI: Loaded card info.") + await self.data_controller.get_card_info("test") - # Get card info (on separate thread) - self.data_controller.get_card_info("test", callback=handle_card_info) + Logger.info(f"StellaPayUI: Loaded test card info") def loaded_all_users_and_products(self): # This method is called whenever all users, categories and products are loaded. @@ -249,12 +245,7 @@ def loaded_all_users_and_products(self): # screen_manager.get_screen(Screens.STARTUP_SCREEN.value).on_products_loaded() def build_config(self, config): - config.setdefaults('device', { - 'width': '800', - 'height': '480', - 'show_cursor': 'True', - 'fullscreen': 'True' - }) + config.setdefaults("device", {"width": "800", "height": "480", "show_cursor": "True", "fullscreen": "True"}) def on_start(self): Logger.debug("StellaPayUI: Starting StellaPay!") @@ -264,7 +255,7 @@ def on_stop(self): self.loop.stop() # Stop event loop def get_git_revisions_hash(self): - return subprocess.check_output(['git', 'rev-parse', 'HEAD']) + return subprocess.check_output(["git", "rev-parse", "HEAD"]) @staticmethod def get_app() -> "StellaPay": @@ -285,12 +276,13 @@ def __get_config_option(self, section_name, config_option_name, default_value=No def get_config_option(self, config_option: config.ConfigurationOption): if config_option is None: return None - return self.__get_config_option(config_option.section_name, config_option.config_name, - config_option.default_value) + return self.__get_config_option( + config_option.section_name, config_option.config_name, config_option.default_value + ) -if __name__ == '__main__': - kivy.require('1.11.1') +if __name__ == "__main__": + kivy.require("1.11.1") screen_manager = ScreenManager() StellaPay().run() diff --git a/data/DataController.py b/data/DataController.py index 2b77d71..62c3e0a 100644 --- a/data/DataController.py +++ b/data/DataController.py @@ -1,7 +1,12 @@ +import asyncio import json +import sys +import threading +import time +from datetime import datetime from json import JSONDecodeError from threading import Thread -from typing import Callable, Optional, Dict, List +from typing import Optional, Dict, List import requests from kivy import Logger @@ -11,10 +16,12 @@ from data.ConnectionListener import ConnectionListener from data.OfflineDataStorage import OfflineDataStorage from data.OnlineDataStorage import OnlineDataStorage +from data.user.user_data import UserData from ds.NFCCardInfo import NFCCardInfo from ds.Product import Product from ds.ShoppingCart import ShoppingCart from utils import Connections, ConfigurationOptions +from utils.ConfigurationOptions import ConfigurationOption class DataController: @@ -38,121 +45,145 @@ def __init__(self): def start_connection_update_thread(self, url: str = None): # Create a thread to update connection status - self.connection_update_thread = Thread(target=self.__update_connection_status__, args=(url,)) + self.connection_update_thread = Thread(target=self._update_connection_status, args=(url,), daemon=True) # Start the thread self.connection_update_thread.start() - def get_user_data(self, callback: Callable[[Optional[Dict[str, str]]], None] = None) -> None: + async def get_user_data(self) -> List[UserData]: """ - Get a mapping of all users and their e-mail addresses. Note that you'll need to provide a callback function - that will be called whenever the data is available. The callback will also be called when no data is available. + Get a mapping of all users and their e-mail addresses. Note that is an asynchronous method that needs to be awaited. - The user data is returned as a dictionary, where the key (str) is the username and the corresponding value (str) - is the associated e-mail address. + The user data is as a list of `UserData` objects, containing the user's name and email address. - :param callback: Method called when this method is finished retrieving data. The callback will have one argument - that represents a dictionary of users. This might also be None! - - :return: Nothing. + :return: a list of known users """ - - # Pass the callback to the appropriate method. if self.running_in_online_mode(): - self.online_data_storage.get_user_data(callback=callback) + return await self.online_data_storage.get_user_data() else: - self.offline_data_storage.get_user_data(callback=callback) + return await self.offline_data_storage.get_user_data() - def get_product_data(self, callback: Callable[[Optional[Dict[str, List[Product]]]], None] = None) -> None: + async def get_product_data(self) -> Dict[str, List[Product]]: """ - Get a list of all products (in their categories). Note that you'll need to provide a callback function - that will be called whenever the data is available. The callback will also be called when no data is available. + Get a list of all products in their corresponding categories. Note that this is an asynchronous method and needs + to be awaited! - The product data is returned as a map, where each key is a category name and the value a list of Product objects. - - :param callback: Method called when this method is finished retrieving data. The callback will have one argument - that represents the map. This might also be None! - - :return: Nothing. + :return: a dictionary is returned where the key is a category name and the value a list of products in that category """ - # Pass the callback to the appropriate method. if self.running_in_online_mode(): - self.online_data_storage.get_product_data(callback=callback) + return await self.online_data_storage.get_product_data() else: - self.offline_data_storage.get_product_data(callback=callback) + return await self.offline_data_storage.get_product_data() - def get_category_data(self, callback: Callable[[Optional[List[str]]], None] = None) -> None: + async def get_category_data(self) -> List[str]: """ - Get a list of all categories. Note that you'll need to provide a callback function - that will be called whenever the data is available. The callback will also be called when no data is available. + Get a list of all categories Note that this is an asynchronous method and needs to be awaited! - The category data is returned as a list, where each element is the name of category (as a string). - - :param callback: Method called when this method is finished retrieving data. The callback will have one argument - that represents a list of categories. This might also be None! - - :return: Nothing. + :return: a list of categories """ - # Pass the callback to the appropriate method. if self.running_in_online_mode(): - self.online_data_storage.get_category_data(callback=callback) + return await self.online_data_storage.get_category_data() else: - self.offline_data_storage.get_category_data(callback=callback) + return await self.offline_data_storage.get_category_data() - def get_card_info(self, card_id=None, callback: Callable[[Optional[NFCCardInfo]], None] = None) -> None: + async def get_card_info(self, card_id) -> Optional[NFCCardInfo]: """ - Get info of a particular card. Note that you'll need to provide a callback function - that will be called whenever the data is available. The callback will also be called when no data is available. + Get info of a particular card. Note that this method is asynchronous and thus needs to be awaited. - The card info will be returned as a NFCCardInfo object. + The card info will be returned as a `NFCCardInfo` object. - :param card_id: id of the card - :param callback: Method called when this method is finished retrieving data. The callback will have one argument - that represents the card info. This might also be None! - :return: Nothing. + :param card_id: Id of the card to look for + :return: a `NFCCardInfo` object if matching the given ``card_id`` or None if nothing could be found """ - # Pass the callback to the appropriate method. if self.running_in_online_mode(): - self.online_data_storage.get_card_info(card_id=card_id, callback=callback) + return await self.online_data_storage.get_card_info(card_id=card_id) else: - self.offline_data_storage.get_card_info(card_id=card_id, callback=callback) + return await self.offline_data_storage.get_card_info(card_id=card_id) - def register_card_info(self, card_id: str = None, email: str = None, owner: str = None, - callback: Callable[[bool], None] = None) -> None: + async def register_card_info(self, card_id: str, email: str, owner: str) -> bool: """ - Register a new card for a particular user. + Register a new card for a particular user. Note that this is an asynchronous method and thus must be awaited! + :param card_id: Id of the card to register :param email: E-mail address of the user that you want to match this card with. :param owner: Name of the owner of the card - :param callback: Method called when this method is finished. The callback will have one argument (boolean) - that indicates whether the card has been registered (true) or not (false). - :return: Nothing + :return: Whether the card has successfully been registered. """ # Pass the callback to the appropriate method. if self.running_in_online_mode(): - self.online_data_storage.register_card_info(card_id=card_id, email=email, owner=owner, callback=callback) + return await self.online_data_storage.register_card_info(card_id=card_id, email=email, owner=owner) else: - self.offline_data_storage.register_card_info(card_id=card_id, email=email, owner=owner, callback=callback) + return await self.offline_data_storage.register_card_info(card_id=card_id, email=email, owner=owner) - def create_transactions(self, shopping_cart: ShoppingCart = None, callback: Callable[[bool], None] = None) -> None: + async def create_transactions(self, shopping_cart: ShoppingCart) -> bool: """ - Create a transaction (or multiple) of goods. Note that you'll need to provide a callback function - that will be called whenever the call is completed. + Create a transaction (or multiple) of goods. Note that this is an asynchronous method and thus must be awaited! :param shopping_cart: Shopping cart that has all transactions you want to create - :param callback: Method called when this method is finished. The callback will have one argument (boolean) - that indicates whether the transactions have been registered (true) or not (false). - :return: Nothing + :return: Whether the transactions have been registered (true) or not (false). """ - # Pass the callback to the appropriate method. - if self.running_in_online_mode(): - self.online_data_storage.create_transactions(shopping_cart=shopping_cart, callback=callback) + return await self.online_data_storage.create_transactions(shopping_cart=shopping_cart) else: - self.offline_data_storage.create_transactions(shopping_cart=shopping_cart, callback=callback) + return await self.offline_data_storage.create_transactions(shopping_cart=shopping_cart) + + async def get_recent_users(self, number_of_unique_users: int = 3) -> List[str]: + """ + Get the most recent users that have bought something since the start of the current day. This method + will only work when we are connected to the internet. Note that this method is asynchronous and thus + needs to be awaited. + + :param number_of_unique_users: The number of unique users to return. If there are fewer than + the requested amount, all of them are returned. + :return: a list of names of users that bought something, in order of their purchase time. + """ + # This is not supported when running in offline mode + if not self.running_in_online_mode(): + return [] + + Logger.debug(f"StellaPayUI: ({threading.current_thread().name}) Loading most recent users") + + # Get today, and set time to midnight. + today = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0) + + response = await App.get_running_app().session_manager.do_post_request_async( + url=Connections.get_all_transactions(), json_data={"begin_date": today.strftime("%Y/%m/%d %H:%M:%S")} + ) + + if response is None or not response.ok: + return [] + + try: + body = json.loads(response.content) + except: + Logger.warning("StellaPayUI: Failed to parse most recent users query") + return [] + + ignored_addresses = [ + "onderhoud@solarteameindhoven.nl", + "beheer@solarteameindhoven.nl", + "info@solarteameindhoven.nl", + ] + + recent_users = [] + + for user_dict in reversed(body): + if len(recent_users) >= number_of_unique_users: + break + + mail_address = user_dict["email"] + name = App.get_running_app().get_user_by_email(mail_address) + if mail_address in ignored_addresses: + continue + if name in recent_users: + continue + else: + recent_users.append(name) + + return recent_users @staticmethod - def __is_online_database_reachable__(url: str = Connections.hostname, timeout: int = 5) -> bool: + def _is_online_database_reachable(url: str = Connections.hostname, timeout: int = 5) -> bool: try: req = requests.head(url, timeout=timeout) # HTTP errors are not raised by default, this statement does that @@ -165,39 +196,41 @@ def __is_online_database_reachable__(url: str = Connections.hostname, timeout: i pass return False - def __update_connection_status__(self, url: str = None) -> None: + def _update_connection_status(self, url: str = None) -> None: """ This method continuously checks whether a connection to the online database is possible. Note that this method should be called only once and another thread as it will block periodically. """ - connection_status = DataController.__is_online_database_reachable__(url=url) + while True: + connection_status = DataController._is_online_database_reachable(url=url) - # If we could not connect before, but we did now, let's make sure to tell! - if not self.can_use_online_database and connection_status: - Logger.debug(f"StellaPayUI: Connected to online database (again)!") + # If we could not connect before, but we did now, let's make sure to tell! + if not self.can_use_online_database and connection_status: + Logger.debug(f"StellaPayUI: Connected to online database (again)!") - # Notify all listeners - for listener in self.on_connection_change_listeners: - listener.on_connection_change(True) + # Notify all listeners + for listener in self.on_connection_change_listeners: + listener.on_connection_change(True) - # If we had connection, but lost it, let's tell that as well. - elif self.can_use_online_database and not connection_status: - Logger.warning(f"StellaPayUI: Lost connection to the online database!") + # If we had connection, but lost it, let's tell that as well. + elif self.can_use_online_database and not connection_status: + Logger.warning(f"StellaPayUI: Lost connection to the online database!") - # Notify all listeners - for listener in self.on_connection_change_listeners: - listener.on_connection_change(False) + # Notify all listeners + for listener in self.on_connection_change_listeners: + listener.on_connection_change(False) - self.can_use_online_database = connection_status + self.can_use_online_database = connection_status - from StellaPay import StellaPay - time_until_next_check = int(StellaPay.get_app().get_config_option( - ConfigurationOptions.ConfigurationOption.TIME_BETWEEN_CHECKING_INTERNET_STATUS)) + time_until_next_check = int( + App.get_running_app().get_config_option( + ConfigurationOptions.ConfigurationOption.TIME_BETWEEN_CHECKING_INTERNET_STATUS + ) + ) - # Make sure to run another call soon - App.get_running_app().loop.call_later(time_until_next_check, self.__update_connection_status__, url) + time.sleep(time_until_next_check) - def __update_offline_storage__(self) -> None: + async def _update_offline_storage(self) -> None: """ This method runs in a different thread and runs the updater of the offline storage periodically. It also makes sure to write any pending data to the backend if there is a connection @@ -206,17 +239,9 @@ def __update_offline_storage__(self) -> None: self.offline_data_storage.update_file_from_cache() # After updating the offline storage, let's check if we can send pending data - self.__send_pending_data_to_online_server__() - - from StellaPay import StellaPay - - time_until_we_appear_again = int(StellaPay.get_app().get_config_option( - ConfigurationOptions.ConfigurationOption.TIME_BETWEEN_UPDATING_OFFLINE_STORAGE)) + await self._send_pending_data_to_online_server() - # Make sure to run another call in a few minutes again - App.get_running_app().loop.call_later(time_until_we_appear_again, self.__update_offline_storage__) - - def __send_pending_data_to_online_server__(self) -> None: + async def _send_pending_data_to_online_server(self) -> None: """ This method will look in the pending data file to see if there are entries that need to registered at the online server. If that is the case, the entries will be registered and subsequently removed from the pending data. @@ -262,19 +287,14 @@ def __send_pending_data_to_online_server__(self) -> None: # If we have a valid shopping cart and it's not empty if shopping_cart is not None and len(shopping_cart.basket) > 0: - - def handle_transactions_callback(success: bool): - # We want to alter the variable outside of this function, so we define it to be non-local - nonlocal registered_pending_transactions_successfully - registered_pending_transactions_successfully = success - - # We send it to the online server and handle the callback - self.create_transactions(shopping_cart, handle_transactions_callback) + # We send it to the online server + registered_pending_transactions_successfully = await self.create_transactions(shopping_cart) # Check result if registered_pending_transactions_successfully: Logger.debug( - f"StellaPayUI: Registered {len(shopping_cart.basket)} new transactions from pending transactions") + f"StellaPayUI: Registered {len(shopping_cart.basket)} new transactions from pending transactions" + ) else: Logger.debug(f"StellaPayUI: Failed to register transactions from pending transactions") @@ -305,9 +325,14 @@ def handle_transactions_callback(success: bool): continue # Register card - self.register_card_info(card_id=card_id, email=card_info["email"], owner=None, - callback=lambda success: cards_registered_successfully.append( - card_id if success else None)) + success = await self.register_card_info( + card_id=card_id, email=card_info["email"], owner="Unknown owner" + ) + + if success: + cards_registered_successfully.append(card_id) + else: + Logger.critical(f"StellaPayUI: Could not register card '{card_id}' at server!") # Determine which cards were validly registered (and hence can be removed from the pending data) valid_cards = list(filter((lambda card: card is not None), cards_registered_successfully)) @@ -322,30 +347,54 @@ def handle_transactions_callback(success: bool): json_data_to_write = pending_data_json # Finally write the new JSON data to the pending data file - with open(OfflineDataStorage.PENDING_DATA_FILE_NAME, 'w') as data_file: + with open(OfflineDataStorage.PENDING_DATA_FILE_NAME, "w") as data_file: json.dump(json_data_to_write, data_file, indent=4) - # Get whether we can connect to the backend or not - def running_in_online_mode(self) -> bool: - return self.can_use_online_database - # Run the setup procedure, i.e. check whether we can connect to remote server # If we can connect, we start authentication, otherwise we run in offline mode. # Make sure to run this method in a separate thread because it will be blocking. - def start_setup_procedure(self): + async def start_setup_procedure(self): + + Logger.debug(f"StellaPayUI: Waiting before setting up start procedure") + start_time = time.perf_counter() + + await asyncio.sleep( + int(App.get_running_app().get_config_option(ConfigurationOption.TIME_TO_WAIT_BEFORE_AUTHENTICATING)), + loop=App.get_running_app().loop, + ) + + Logger.debug(f"StellaPayUI: Starting setup procedure after {time.perf_counter() - start_time} seconds") + if self.running_in_online_mode(): Logger.critical(f"StellaPayUI: Running in ONLINE mode!") # Start up authentication - App.get_running_app().loop.call_soon_threadsafe(App.get_running_app().session_manager.setup_session, - App.get_running_app().done_loading_authentication) + authentication_success = await App.get_running_app().session_manager.setup_session_async() + + if authentication_success: + await App.get_running_app().done_loading_authentication() + else: + Logger.critical(f"StellaPayUI: Could not authenticate to backend!") + sys.exit(1) else: # We need to run in offline mode. Do not run authentication to the backend (as it will fail anyway). Logger.critical(f"StellaPayUI: Running in OFFLINE mode!") - App.get_running_app().loop.call_soon_threadsafe(App.get_running_app().done_loading_authentication) + await App.get_running_app().done_loading_authentication() + + time_until_we_appear_again = int( + App.get_running_app().get_config_option( + ConfigurationOptions.ConfigurationOption.TIME_BETWEEN_UPDATING_OFFLINE_STORAGE + ) + ) # Thread to keep updating the offline storage files with data from the caching manager - App.get_running_app().loop.call_later(60, self.__update_offline_storage__) + while True: + await self._update_offline_storage() + await asyncio.sleep(time_until_we_appear_again, loop=App.get_running_app().loop) + + # Get whether we can connect to the backend or not + def running_in_online_mode(self) -> bool: + return self.can_use_online_database def register_connection_listener(self, connection_listener: ConnectionListener) -> None: """ diff --git a/data/DataStorage.py b/data/DataStorage.py index 4e9ddbb..b182a42 100644 --- a/data/DataStorage.py +++ b/data/DataStorage.py @@ -1,94 +1,69 @@ from abc import abstractmethod -from typing import List, Callable, Optional, Dict +from typing import List, Optional, Dict +from data.user.user_data import UserData from ds.NFCCardInfo import NFCCardInfo from ds.Product import Product from ds.ShoppingCart import ShoppingCart class DataStorage: - @abstractmethod - def get_user_data(self, callback: Callable[[Optional[Dict[str, str]]], None] = None) -> None: + async def get_user_data(self) -> List[UserData]: """ - Get a mapping of all users and their e-mail addresses. Note that you'll need to provide a callback function - that will be called whenever the data is available. The callback will also be called when no data is available. - - The user data is returned as a dictionary, where the key (str) is the username and the corresponding value (str) - is the associated e-mail address. + Get a mapping of all users and their e-mail addresses. Note that this method is asynchronous and thus needs to + be awaited. - :param callback: Method called when this method is finished retrieving data. The callback will have one argument - that represents a dictionary of users. This might also be None! - - :return: Nothing. + :return: a list of ``UserData`` objects. """ @abstractmethod - def get_product_data(self, callback: Callable[[Optional[List[Product]]], None] = None) -> None: + async def get_product_data(self) -> Dict[str, List[Product]]: """ - Get a list of all products. Note that you'll need to provide a callback function - that will be called whenever the data is available. The callback will also be called when no data is available. - - The product data is returned as a list, where each element corresponds to a Product object. + Get a list of all products in their corresponding categories. Note that this is an asynchronous method and needs + to be awaited! - :param callback: Method called when this method is finished retrieving data. The callback will have one argument - that represents a list of products. This might also be None! - - :return: Nothing. + :return: a dictionary is returned where the key is a category name and the value a list of products in that category """ raise NotImplementedError @abstractmethod - def get_category_data(self, callback: Callable[[Optional[List[str]]], None] = None) -> None: + async def get_category_data(self) -> List[str]: """ - Get a list of all categories. Note that you'll need to provide a callback function - that will be called whenever the data is available. The callback will also be called when no data is available. - - The category data is returned as a list, where each element is the name of category (as a string). + Get a list of all categories. Note that this is an asynchronous method and needs to be awaited! - :param callback: Method called when this method is finished retrieving data. The callback will have one argument - that represents a list of categories. This might also be None! - - :return: Nothing. + :return: a list of categories """ raise NotImplementedError @abstractmethod - def get_card_info(self, card_id=None, callback: Callable[[Optional[NFCCardInfo]], None] = None) -> None: + async def get_card_info(self, card_id) -> Optional[NFCCardInfo]: """ - Get info of a particular card. Note that you'll need to provide a callback function - that will be called whenever the data is available. The callback will also be called when no data is available. + Get info of a particular card. Note that this method is asynchronous and thus needs to be awaited. - The card info will be returned as a NFCCardInfo object. + The card info will be returned as a `NFCCardInfo` object. - :param card_id: id of the card - :param callback: Method called when this method is finished retrieving data. The callback will have one argument - that represents the card info. This might also be None! - :return: Nothing. + :param card_id: Id of the card to look for + :return: a `NFCCardInfo` object if matching the given ``card_id`` or None if nothing could be found """ raise NotImplementedError - def register_card_info(self, card_id: str = None, email: str = None, owner: str = None, - callback: [[bool], None] = None) -> None: + async def register_card_info(self, card_id: str = None, email: str = None, owner: str = None) -> bool: """ Register a new card for a particular user. + :param card_id: Id of the card that you want to register :param email: E-mail address of the user that you want to match this card with. :param owner: Name of the owner of the card - :param callback: Method called when this method is finished. The callback will have one argument (boolean) - that indicates whether the card has been registered (true) or not (false). - :return: Nothing + :return: Whether the card has been registered (true) or not (false). """ raise NotImplementedError - def create_transactions(self, shopping_cart: ShoppingCart = None, callback: Callable[[bool], None] = None) -> None: + async def create_transactions(self, shopping_cart: ShoppingCart = None) -> bool: """ - Create a transaction (or multiple) of goods. Note that you'll need to provide a callback function - that will be called whenever the call is completed. + Create a transaction (or multiple) of goods. :param shopping_cart: Shopping cart that has all transactions you want to create - :param callback: Method called when this method is finished. The callback will have one argument (boolean) - that indicates whether the transactions have been registered (true) or not (false). - :return: Nothing + :return: Whether the transactions have been registered (true) or not (false). """ raise NotImplementedError diff --git a/data/OfflineDataStorage.py b/data/OfflineDataStorage.py index c509498..d97f1b9 100644 --- a/data/OfflineDataStorage.py +++ b/data/OfflineDataStorage.py @@ -4,12 +4,13 @@ import traceback from collections import OrderedDict from json import JSONDecodeError -from typing import Callable, Optional, List, Dict, Any +from typing import Optional, List, Dict, Any from kivy import Logger from data.CachedDataStorage import CachedDataStorage from data.DataStorage import DataStorage +from data.user.user_data import UserData from ds.NFCCardInfo import NFCCardInfo from ds.Product import Product from ds.ShoppingCart import ShoppingCart @@ -33,18 +34,15 @@ def __init__(self, cached_data_storage: CachedDataStorage): # Load pending data file into memory self.pending_json_data = self.__get_json_data_from_file__(OfflineDataStorage.PENDING_DATA_FILE_NAME) - def get_user_data(self, callback: Callable[[Optional[Dict[str, str]]], None] = None) -> None: - - # We're not doing any work when the result is ignored anyway. - if callback is None: - return - + async def get_user_data(self) -> List[UserData]: # Check if we have a cached version of this somewhere if len(self.cached_data_storage.cached_user_data) > 0: Logger.debug("StellaPayUI: Using offline (cached) user data") # Return cached user data - callback(self.cached_data_storage.cached_user_data) - return + return [ + UserData(real_name=user_name, email_address=user_email) + for user_name, user_email in self.cached_data_storage.cached_user_data.items() + ] Logger.debug(f"StellaPayUI: Loading user mapping on thread {threading.current_thread().name}") @@ -56,27 +54,26 @@ def get_user_data(self, callback: Callable[[Optional[Dict[str, str]]], None] = N # Sort items self.cached_data_storage.cached_user_data = OrderedDict( - sorted(self.cached_data_storage.cached_user_data.items())) + sorted(self.cached_data_storage.cached_user_data.items()) + ) Logger.debug("StellaPayUI: Loaded user data") - callback(self.cached_data_storage.cached_user_data) + return [ + UserData(real_name=user_name, email_address=user_email) + for user_name, user_email in self.cached_data_storage.cached_user_data.items() + ] except Exception as e: Logger.critical(f"StellaPayUI: A problem with retrieving offline user data!") traceback.print_exception(None, e, e.__traceback__) - callback(None) - - def get_product_data(self, callback: Callable[[Optional[Dict[str, List[Product]]]], None] = None) -> None: - # We're not doing any work when the result is ignored anyway. - if callback is None: - return + return [] + async def get_product_data(self) -> Dict[str, List[Product]]: if len(self.cached_data_storage.cached_product_data) > 0: - Logger.debug("StellaPayUI: Using online (cached) product data") + Logger.debug("StellaPayUI: Using offline (cached) product data") # Return cached product data - callback(self.cached_data_storage.cached_product_data) - return + return self.cached_data_storage.cached_product_data Logger.debug(f"StellaPayUI: Loading product data on thread {threading.current_thread().name}") @@ -92,28 +89,24 @@ def get_product_data(self, callback: Callable[[Optional[Dict[str, List[Product]] price = product_data["price"] # Add product to cache - self.cached_data_storage.cached_product_data[category_name] \ - .append(Product().create_from_json(json={"name": product, "price": price, "shown": shown})) + self.cached_data_storage.cached_product_data[category_name].append( + Product().create_from_json(json={"name": product, "price": price, "shown": shown}) + ) Logger.debug(f"StellaPayUI: Retrieved {len(products)} products from offline storage") - callback(self.cached_data_storage.cached_product_data) + return self.cached_data_storage.cached_product_data except Exception as e: Logger.critical(f"StellaPayUI: A problem with retrieving offline product data!") traceback.print_exception(None, e, e.__traceback__) - callback(None) - - def get_category_data(self, callback: Callable[[Optional[List[str]]], None] = None) -> None: - # We're not doing any work when the result is ignored anyway. - if callback is None: - return + return dict() + async def get_category_data(self) -> List[str]: if len(self.cached_data_storage.cached_category_data) > 0: Logger.debug("StellaPayUI: Using offline (cached) category data") # Return cached category data - callback(self.cached_data_storage.cached_category_data) - return + return self.cached_data_storage.cached_category_data Logger.debug(f"StellaPayUI: Loading category data on thread {threading.current_thread().name}") @@ -128,24 +121,21 @@ def get_category_data(self, callback: Callable[[Optional[List[str]]], None] = No Logger.debug(f"StellaPayUI: Retrieved {len(categories)} categories from offline storage") - callback(self.cached_data_storage.cached_category_data) + return self.cached_data_storage.cached_category_data except Exception as e: Logger.critical(f"StellaPayUI: A problem with retrieving offline category data!") traceback.print_exception(None, e, e.__traceback__) - callback(None) - - def get_card_info(self, card_id=None, callback: Callable[[Optional[NFCCardInfo]], None] = None) -> None: + return [] + async def get_card_info(self, card_id) -> Optional[NFCCardInfo]: if card_id is None: - callback(None) - return + return None # Check if we have cached card info already if card_id in self.cached_data_storage.cached_card_info: # Return the cached data - callback(self.cached_data_storage.cached_card_info[card_id]) - return + return self.cached_data_storage.cached_card_info[card_id] Logger.debug(f"StellaPayUI: Loading data of card {card_id} data on thread {threading.current_thread().name}") @@ -155,8 +145,7 @@ def get_card_info(self, card_id=None, callback: Callable[[Optional[NFCCardInfo]] cards = self.cached_json_data["cards"] if len(cards) < 1: - callback(None) - return + return None # Load cards data for card, card_data in cards.items(): @@ -174,34 +163,27 @@ def get_card_info(self, card_id=None, callback: Callable[[Optional[NFCCardInfo]] # Since we've loaded all cards, now check if the one we're looking for is present if card_id in self.cached_data_storage.cached_card_info: # Return the cached data - callback(self.cached_data_storage.cached_card_info[card_id]) - return + return self.cached_data_storage.cached_card_info[card_id] else: # We couldn't find the card - callback(None) - return + return None except Exception as e: Logger.critical(f"StellaPayUI: A problem with retrieving offline card data!") traceback.print_exception(None, e, e.__traceback__) - callback(None) + return None - def register_card_info(self, card_id: str = None, email: str = None, owner: str = None, - callback: Callable[[bool], None] = None) -> None: + async def register_card_info(self, card_id: str = None, email: str = None, owner: str = None) -> bool: # Check if we have a card id and email if card_id is None or email is None: - if callback is not None: - callback(False) - return + return False # Check if they are not empty strings if len(card_id) < 1 or len(email) < 1: - if callback is not None: - callback(False) - return + return False - # Make sure to reload pending data (if it has been updated in the mean time) + # Make sure to reload pending data (if it has been updated in the meantime) self.reload_pending_data() # Check if we have a cards section. If not, create it. @@ -221,29 +203,21 @@ def register_card_info(self, card_id: str = None, email: str = None, owner: str if self.__save_json_data_to_file__(self.pending_json_data, OfflineDataStorage.PENDING_DATA_FILE_NAME): Logger.info(f"StellaPayUI: Registered (offline) new card with id {card_id} for {email}") - if callback is not None: - callback(True) - return + return True else: # User could not be added succesfully, give error 2. Logger.warning(f"StellaPayUI: Could not register (offline) new card with id {card_id} for {email}") - if callback is not None: - callback(False) - return + return False - def create_transactions(self, shopping_cart: ShoppingCart = None, callback: Callable[[bool], None] = None) -> None: + async def create_transactions(self, shopping_cart: ShoppingCart = None) -> bool: # Check if we have a shopping cart if shopping_cart is None: - if callback is not None: - callback(False) - return + return False # Check if the shopping cart is empty. If so, we return true (since all transactions have been registered). if len(shopping_cart.basket) < 1: - if callback is not None: - callback(True) - return + return True try: json_cart = shopping_cart.to_json() @@ -251,9 +225,7 @@ def create_transactions(self, shopping_cart: ShoppingCart = None, callback: Call Logger.warning("StellaPayUI: There was an error while parsing the shopping cart to JSON!") traceback.print_exception(None, e, e.__traceback__) - if callback is not None: - callback(False) - return + return False # Make sure to reload pending data (if it has been updated in the mean time) self.reload_pending_data() @@ -272,16 +244,13 @@ def create_transactions(self, shopping_cart: ShoppingCart = None, callback: Call if self.__save_json_data_to_file__(self.pending_json_data, OfflineDataStorage.PENDING_DATA_FILE_NAME): Logger.info(f"StellaPayUI: Registered {len(shopping_cart.basket)} transactions to the pending data.") - if callback is not None: - callback(True) - return + return True else: Logger.warning( - f"StellaPayUI: Failed to register {len(shopping_cart.basket)} transactions to the pending data.") + f"StellaPayUI: Failed to register {len(shopping_cart.basket)} transactions to the pending data." + ) - if callback is not None: - callback(False) - return + return False def __save_json_data_to_file__(self, json_data: Dict, path_to_file: str = None) -> bool: """ @@ -311,7 +280,8 @@ def update_file_from_cache(self) -> None: """ Logger.debug( - f"StellaPayUI: Updating offline storage from data in the cache (on thread {threading.current_thread().name})!") + f"StellaPayUI: Updating offline storage from data in the cache (on thread {threading.current_thread().name})!" + ) json_data_to_store = dict() diff --git a/data/OnlineDataStorage.py b/data/OnlineDataStorage.py index 210597f..d7b680c 100644 --- a/data/OnlineDataStorage.py +++ b/data/OnlineDataStorage.py @@ -1,13 +1,14 @@ import threading import traceback from collections import OrderedDict -from typing import Callable, Optional, List, Dict +from typing import Optional, List, Dict from kivy import Logger from kivy.app import App from data.CachedDataStorage import CachedDataStorage from data.DataStorage import DataStorage +from data.user.user_data import UserData from ds.NFCCardInfo import NFCCardInfo from ds.Product import Product from ds.ShoppingCart import ShoppingCart @@ -15,140 +16,113 @@ class OnlineDataStorage(DataStorage): - def __init__(self, cached_data_storage: CachedDataStorage): self.cached_data_storage = cached_data_storage - def get_user_data(self, callback: Callable[[Optional[Dict[str, str]]], None] = None) -> None: - - # We're not doing any work when the result is ignored anyway. - if callback is None: - return - - if len(self.cached_data_storage.cached_user_data) > 0: - Logger.debug("StellaPayUI: Using online (cached) user data") - # Return cached user data - callback(self.cached_data_storage.cached_user_data) - return + async def get_user_data(self) -> List[UserData]: + # Check if we have no data in the cache + if len(self.cached_data_storage.cached_user_data) <= 0: + Logger.debug(f"StellaPayUI: Loading user mapping on thread {threading.current_thread().name}") - Logger.debug(f"StellaPayUI: Loading user mapping on thread {threading.current_thread().name}") - - user_data = App.get_running_app().session_manager.do_get_request(url=Connections.get_users()) - - if user_data and user_data.ok: - # convert to json - user_json = user_data.json() + # Fetch data from server and put it into the cache + user_data = await App.get_running_app().session_manager.do_get_request_async(url=Connections.get_users()) - # append json to list and sort the list - for user in user_json: - # store all emails addressed in the sheet_menu - self.cached_data_storage.cached_user_data[user["name"]] = user["email"] + if user_data is not None and user_data.ok: + # convert to json + user_json = user_data.json() - # Sort items - self.cached_data_storage.cached_user_data = OrderedDict( - sorted(self.cached_data_storage.cached_user_data.items())) + # append json to list and sort the list + for user in user_json: + # store all emails addressed in the sheet_menu + self.cached_data_storage.cached_user_data[user["name"]] = user["email"] - Logger.debug("StellaPayUI: Loaded user data") + # Sort items + self.cached_data_storage.cached_user_data = OrderedDict( + sorted(self.cached_data_storage.cached_user_data.items()) + ) - callback(self.cached_data_storage.cached_user_data) - else: - Logger.critical("StellaPayUI: Error: users could not be fetched from the online database") - callback(None) + Logger.debug("StellaPayUI: Loaded user data") + else: + Logger.critical("StellaPayUI: Error: users could not be fetched from the online database") - def get_product_data(self, callback: Callable[[Optional[Dict[str, List[Product]]]], None] = None) -> None: - # We're not doing any work when the result is ignored anyway. - if callback is None: - return + else: # We already have a cache, so use that instead. + Logger.debug("StellaPayUI: Using online (cached) user data") - if len(self.cached_data_storage.cached_product_data) > 0: + # Return cached user data + return [ + UserData(real_name=user_name, email_address=user_email) + for user_name, user_email in self.cached_data_storage.cached_user_data.items() + ] + + async def get_product_data(self) -> Dict[str, List[Product]]: + # Check if we have no data in the cache + if len(self.cached_data_storage.cached_product_data) <= 0: + Logger.debug(f"StellaPayUI: Loading product data on thread {threading.current_thread().name}") + + # Grab products from each category + for category in self.cached_data_storage.cached_category_data: + # Request products from each category + request = Connections.get_products() + category + product_data = await App.get_running_app().session_manager.do_get_request_async(request) + + if product_data and product_data.ok: + # convert to json + products = product_data.json() + + Logger.debug(f"StellaPayUI: Retrieved {len(products)} products for category '{category}'") + + # Create a product object for all products + for product in products: + # Only add the product to the list if the product must be shown + if product["shown"]: + p = Product().create_from_json(product) + self.cached_data_storage.cached_product_data[category].append(p) + + else: + Logger.warning(f"StellaPayUI: Error: could not fetch products for category {category}.") + else: # We already have a cache, so use that instead. Logger.debug("StellaPayUI: Using online (cached) product data") - # Return cached product data - callback(self.cached_data_storage.cached_product_data) - return - # Check if there is category data loaded. We need that, otherwise we can't load the products. - if len(self.cached_data_storage.cached_category_data) < 1: - Logger.warning("StellaPayUI: Cannot load product data because there is no (cached) category data!") - callback(None) - return + # Return cached product data + return self.cached_data_storage.cached_product_data - Logger.debug(f"StellaPayUI: Loading product data on thread {threading.current_thread().name}") + async def get_category_data(self) -> List[str]: + if len(self.cached_data_storage.cached_category_data) <= 0: + Logger.debug(f"StellaPayUI: Loading category data on thread {threading.current_thread().name}") + # Do request to the correct URL + category_data = await App.get_running_app().session_manager.do_get_request_async( + url=Connections.get_categories() + ) - # Grab products from each category - for category in self.cached_data_storage.cached_category_data: - # Request products from each category - request = Connections.get_products() + category - product_data = App.get_running_app().session_manager.do_get_request(request) - - if product_data and product_data.ok: + if category_data and category_data.ok: # convert to json - products = product_data.json() - - Logger.debug(f"StellaPayUI: Retrieved {len(products)} products for category '{category}'") + categories = category_data.json() - # Create a product object for all products - for product in products: - # Only add the product to the list if the product must be shown - if product['shown']: - p = Product().create_from_json(product) - self.cached_data_storage.cached_product_data[category].append(p) + for category in categories: + self.cached_data_storage.cached_category_data.append(str(category["name"])) + Logger.debug("StellaPayUI: Loaded category data") else: - Logger.warning(f"StellaPayUI: Error: could not fetch products for category {category}.") - return - - # Make sure to call the callback with the proper data. (None if we have no data). - if len(self.cached_data_storage.cached_product_data) > 0: - callback(self.cached_data_storage.cached_product_data) + Logger.critical("StellaPayUI: Error: categories could not be fetched from the online database") else: - callback(None) - - def get_category_data(self, callback: Callable[[Optional[List[str]]], None] = None) -> None: - # We're not doing any work when the result is ignored anyway. - if callback is None: - return - - if len(self.cached_data_storage.cached_category_data) > 0: Logger.debug("StellaPayUI: Using online (cached) category data") - # Return cached category data - callback(self.cached_data_storage.cached_category_data) - return - - Logger.debug(f"StellaPayUI: Loading category data on thread {threading.current_thread().name}") - - # Do request to the correct URL - category_data = App.get_running_app().session_manager.do_get_request(url=Connections.get_categories()) - - if category_data and category_data.ok: - # convert to json - categories = category_data.json() - for category in categories: - self.cached_data_storage.cached_category_data.append(str(category['name'])) + return self.cached_data_storage.cached_category_data - Logger.debug("StellaPayUI: Loaded category data") - - callback(self.cached_data_storage.cached_category_data) - else: - Logger.critical("StellaPayUI: Error: categories could not be fetched from the online database") - callback(None) - - def get_card_info(self, card_id=None, callback: Callable[[Optional[NFCCardInfo]], None] = None) -> None: + async def get_card_info(self, card_id=None) -> Optional[NFCCardInfo]: if card_id is None: - callback(None) - return + return None # Check if we have cached card info already if card_id in self.cached_data_storage.cached_card_info: # Return the cached data - callback(self.cached_data_storage.cached_card_info[card_id]) - return + return self.cached_data_storage.cached_card_info[card_id] Logger.debug(f"StellaPayUI: Loading card data on thread {threading.current_thread().name}") # Do request to the correct URL - cards_data = App.get_running_app().session_manager.do_get_request(url=Connections.get_all_cards()) + cards_data = await App.get_running_app().session_manager.do_get_request_async(url=Connections.get_all_cards()) # Check if we have valid card data if cards_data and cards_data.ok: @@ -169,31 +143,26 @@ def get_card_info(self, card_id=None, callback: Callable[[Optional[NFCCardInfo]] self.cached_data_storage.cached_card_info[card_data_id] = card_info Logger.debug("StellaPayUI: Loaded cards data") - - # Return the loaded card data to the user - callback(self.cached_data_storage.cached_card_info.get(card_id, None)) else: Logger.critical("StellaPayUI: Error: cards could not be fetched from the online database") - callback(None) + return None + + # Return the loaded card data to the user + return self.cached_data_storage.cached_card_info.get(card_id, None) - def register_card_info(self, card_id: str = None, email: str = None, owner: str = None, - callback: [[bool], None] = None) -> None: + async def register_card_info(self, card_id: str = None, email: str = None, owner: str = None) -> bool: # Check if we have a card id and email if card_id is None or email is None: - if callback is not None: - callback(False) - return + return False # Check if they are not empty strings if len(card_id) < 1 or len(email) < 1: - if callback is not None: - callback(False) - return + return False # Use a POST command to add connect this UID to the user - request = App.get_running_app().session_manager.do_post_request(url=Connections.add_user_mapping(), - json_data={'card_id': card_id, - 'email': email}) + request = await App.get_running_app().session_manager.do_post_request_async( + url=Connections.add_user_mapping(), json_data={"card_id": card_id, "email": email} + ) Logger.debug(f"StellaPayUI: Registering new card on {threading.current_thread().name}") @@ -201,30 +170,22 @@ def register_card_info(self, card_id: str = None, email: str = None, owner: str if request.ok: Logger.info(f"StellaPayUI: Registered new card with id {card_id} for {email}") - if callback is not None: - callback(True) - return + return True else: # User could not be added succesfully, give error 2. - Logger.warning(f"StellaPayUI: Could not register new card with id {card_id} for {owner}, error: " - f"{request.text}") - - if callback is not None: - callback(False) - return + Logger.warning( + f"StellaPayUI: Could not register new card with id {card_id} for {owner}, error: " f"{request.text}" + ) + return False - def create_transactions(self, shopping_cart: ShoppingCart = None, callback: Callable[[bool], None] = None) -> None: + async def create_transactions(self, shopping_cart: ShoppingCart = None) -> bool: # Check if we have a shopping cart if shopping_cart is None: - if callback is not None: - callback(False) - return + return False # Check if the shopping cart is empty. If so, we return true (since all transactions have been registered). if len(shopping_cart.basket) < 1: - if callback is not None: - callback(True) - return + return True try: json_cart = shopping_cart.to_json() @@ -232,26 +193,21 @@ def create_transactions(self, shopping_cart: ShoppingCart = None, callback: Call Logger.warning("StellaPayUI: There was an error while parsing the shopping cart to JSON!") traceback.print_exception(None, e, e.__traceback__) - if callback is not None: - callback(False) - return + return False # use a POST-request to forward the shopping cart - response = App.get_running_app().session_manager.do_post_request(url=Connections.create_transaction(), - json_data=json_cart) + response = await App.get_running_app().session_manager.do_post_request_async( + url=Connections.create_transaction(), json_data=json_cart + ) # Response was okay. if response and response.ok: Logger.info(f"StellaPayUI: Registered {len(shopping_cart.basket)} transactions to the server.") - if callback is not None: - callback(True) - return + return True elif response is None or not response.ok: Logger.warning(f"StellaPayUI: Failed to register {len(shopping_cart.basket)} transactions to the server.") # Response was wrong - if callback is not None: - callback(False) + return False else: Logger.critical(f"StellaPayUI: Payment could not be made: error: {response.content}") - if callback is not None: - callback(False) + return False diff --git a/data/user/user_data.py b/data/user/user_data.py new file mode 100644 index 0000000..107e6d8 --- /dev/null +++ b/data/user/user_data.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UserData: + """ + A immutable data object representing a user and its data + """ + + real_name: str = "" + email_address: str = "" diff --git a/kvs/RegisterUIDScreen.kv b/kvs/RegisterUIDScreen.kv index 47b83ae..22ec707 100644 --- a/kvs/RegisterUIDScreen.kv +++ b/kvs/RegisterUIDScreen.kv @@ -33,7 +33,7 @@ pos_hint: {'center_x': 0.5, 'center_y': 0.5} pos_hint_x: root.center_x pos_hint_y: root.center_y - on_release: root.on_click_user_list_button() + on_release: root.on_open_user_selector() Label: id: chosen_user diff --git a/requirements.txt b/requirements.txt index acea064..64421f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ smartcard~=0.3 Kivy~=2.1.0 -requests~=2.27.1 +requests==2.28.2 kivymd~=0.104.2 \ No newline at end of file diff --git a/scrs/DefaultScreen.py b/scrs/DefaultScreen.py index 724a6da..a86c314 100644 --- a/scrs/DefaultScreen.py +++ b/scrs/DefaultScreen.py @@ -1,10 +1,10 @@ +import asyncio import datetime -import functools -import json +import threading import time import typing from asyncio import AbstractEventLoop -from typing import Optional, Callable +from typing import Optional, List from kivy import Logger from kivy.app import App @@ -16,17 +16,13 @@ from PythonNFCReader.CardListener import CardListener from PythonNFCReader.NFCReader import CardConnectionManager from data.ConnectionListener import ConnectionListener -from ds.NFCCardInfo import NFCCardInfo -from utils import Connections from utils.Screens import Screens -from utils.SessionManager import SessionManager from ux.SelectUserItem import SelectUserItem from ux.UserPickerDialog import UserPickerDialog class DefaultScreen(Screen): class NFCListener(CardListener): - def __init__(self, default_screen: "DefaultScreen"): self.default_screen = default_screen @@ -89,12 +85,15 @@ def __init__(self, **kwargs): self.users_to_select = [] # Add extra information to footer text - self.ids.copyright.text = self.ids.copyright.text.replace("%year%", str(datetime.datetime.now().year)) \ - .replace("%date%", str(datetime.datetime.now().strftime("%Y/%m/%d @ %H:%M:%S"))) + self.ids.copyright.text = self.ids.copyright.text.replace("%year%", str(datetime.datetime.now().year)).replace( + "%date%", str(datetime.datetime.now().strftime("%Y/%m/%d @ %H:%M:%S")) + ) # Add whether we are running in offline or online mode - self.ids.copyright.text = self.ids.copyright.text.replace("%connection_mode%", - "online mode" if App.get_running_app().data_controller.running_in_online_mode() else "offline mode") + self.ids.copyright.text = self.ids.copyright.text.replace( + "%connection_mode%", + "online mode" if App.get_running_app().data_controller.running_in_online_mode() else "offline mode", + ) connection_state = App.get_running_app().data_controller.running_in_online_mode() self.set_connectivity_icon(connection_state) @@ -113,23 +112,19 @@ def on_enter(self, *args): App.get_running_app().active_user = None self.ids.spinner.active = False - self.user_select_dialog = UserPickerDialog() - self.user_select_dialog.bind( - selected_user=lambda _, selected_user: self.selected_active_user(selected_user)) + self.user_select_dialog = UserPickerDialog(App.get_running_app().user_mapping.keys()) + self.user_select_dialog.bind(selected_user=lambda _, selected_user: self.selected_active_user(selected_user)) # Start loading user data. - self.event_loop.call_soon_threadsafe(self.load_user_data) + asyncio.run_coroutine_threadsafe(self.load_user_data(), loop=App.get_running_app().loop) def on_pre_enter(self, *args): - # Get today, and set time to midnight. - today = datetime.datetime.today().replace(hour=0, minute=0, second=0, microsecond=0) - self.event_loop.call_soon_threadsafe( - functools.partial(self.get_most_recent_users, today)) + asyncio.run_coroutine_threadsafe(self.get_most_recent_users(), loop=App.get_running_app().loop) def to_credits(self): # Only show credits when user select dialog is not showing if not self.user_select_dialog_opened: - self.manager.transition = SlideTransition(direction='left') + self.manager.transition = SlideTransition(direction="left") self.manager.current = Screens.CREDITS_SCREEN.value @mainthread @@ -156,7 +151,8 @@ def clear_recent_user(self, index: int): self.ids.recent_user_two.text = "" self.ids.recent_user_two.size_hint = (0.3, 0) - def set_recent_users(self, names: typing.List[str]): + @mainthread + def set_recent_users(self, names: List[str]): if len(names) > 0: self.ids.title_text_recent_users.text = "[b]Of kies een recente gebruiker:[/b]" else: @@ -169,49 +165,12 @@ def set_recent_users(self, names: typing.List[str]): for i in range(clear_index, 3): self.clear_recent_user(i) - def get_most_recent_users(self, today: datetime.datetime): - response = App.get_running_app().session_manager.do_post_request( - url=Connections.get_all_transactions(), - json_data={ - "begin_date": today.strftime("%Y/%m/%d %H:%M:%S") - } - ) - - if response is None or not response.ok: - return - - try: - body = json.loads(response.content) - except: - Logger.warning("StellaPayUI: Failed to parse most recent users query") - return - - names = self.get_most_recent_names(body) - self.set_recent_users(names) - - def get_most_recent_names(self, body: typing.List[typing.Dict]): - ignored_addresses = ["onderhoud@solarteameindhoven.nl", - "beheer@solarteameindhoven.nl", - "info@solarteameindhoven.nl"] + async def get_most_recent_users(self): + Logger.debug(f"StellaPayUI: Loading recent users on {threading.current_thread().name}") - user_names = [] - - for user_dict in reversed(body): - if len(user_names) >= 3: - break - - mail_address = user_dict["email"] - name = App.get_running_app().get_user_by_email(mail_address) - if mail_address in ignored_addresses: - continue - if name in user_names: - continue - else: - user_names.append( - name - ) + recent_users = await App.get_running_app().data_controller.get_recent_users(number_of_unique_users=3) - return user_names + self.set_recent_users(recent_users) # # gets called when the 'NFC kaart vergeten button' is pressed @@ -230,17 +189,13 @@ def select_recent_user(self, obj): def on_user_select_dialog_close(self, event): self.user_select_dialog_opened = False - def load_user_data(self, callback: Optional[Callable] = None): - def callback_handle(user_data: typing.Dict[str, str]): - if user_data is None: - Logger.warning(f"StellaPayUI: Could not retrieve users!") - - # Make sure to call the original callback when we're done. - if callback is not None: - callback() + async def load_user_data(self): # Try to grab user data - App.get_running_app().data_controller.get_user_data(callback_handle) + user_data = await App.get_running_app().data_controller.get_user_data() + + if user_data is None: + Logger.warning(f"StellaPayUI: Could not retrieve users!") @mainthread def create_user_select_dialog(self, user_mapping: typing.Dict[str, str]): @@ -249,7 +204,8 @@ def create_user_select_dialog(self, user_mapping: typing.Dict[str, str]): for user_name, user_email in user_mapping.items(): # store all users in a list of items that we will open with a dialog self.users_to_select.append( - SelectUserItem(user_email=user_email, callback=self.selected_active_user, text=user_name)) + SelectUserItem(user_email=user_email, callback=self.selected_active_user, text=user_name) + ) # Add a callback so we know when a user has been selected # Create user dialog so we open it later. @@ -259,6 +215,7 @@ def create_user_select_dialog(self, user_mapping: typing.Dict[str, str]): on_dismiss=self.on_user_select_dialog_close, ) + @mainthread # An active user is selected via the dialog def selected_active_user(self, selected_user_name: Optional[str]): # Close the dialog screen @@ -270,7 +227,7 @@ def selected_active_user(self, selected_user_name: Optional[str]): Logger.debug("StellaPayUI: No user selected!") return - self.manager.transition = SlideTransition(direction='left') + self.manager.transition = SlideTransition(direction="left") App.get_running_app().active_user = selected_user_name @@ -287,7 +244,7 @@ def on_leave(self, *args): @mainthread def nfc_card_presented(self, uid: str): - Logger.debug("StellaPayUI: Read NFC card with uid" + uid) + Logger.debug(f"StellaPayUI: Read NFC card with uid '{uid}'") # If we are currently making a transaction, ignore the card reading. if App.get_running_app().active_user is not None: @@ -298,32 +255,33 @@ def nfc_card_presented(self, uid: str): # Show the spinner self.ids.spinner.active = True - start_time = time.time() + # Get card info (on separate thread) + asyncio.run_coroutine_threadsafe(self.identify_presented_card(uid), loop=App.get_running_app().loop) - # Callback to handle the card info - @mainthread - def handle_card_info(card_info: NFCCardInfo): + async def identify_presented_card(self, uid: str): - Logger.debug(f"StellaPayUI: Received card info in {time.time() - start_time} seconds.") + Logger.debug(f"StellaPayUI: Looking up card info of '{uid}'.") - if card_info is None: - # User was not found, proceed to registerUID file - self.manager.transition = SlideTransition(direction='right') - self.manager.get_screen(Screens.REGISTER_UID_SCREEN.value).nfc_id = uid - self.manager.current = Screens.REGISTER_UID_SCREEN.value - else: - # User is found - App.get_running_app().active_user = card_info.owner_name + start_time = time.time() - # Set slide transition correctly. - self.manager.transition = SlideTransition(direction='left') + card_info = await App.get_running_app().data_controller.get_card_info(uid) - # Go to the product screen - self.manager.current = Screens.PRODUCT_SCREEN.value + Logger.debug(f"StellaPayUI: Received card info in {time.time() - start_time} seconds.") - # Get card info (on separate thread) - App.get_running_app().loop.call_soon_threadsafe( - functools.partial(App.get_running_app().data_controller.get_card_info, uid, handle_card_info)) + if card_info is None: + Logger.debug(f"StellaPayUI: Did not find info for card '{uid}'.") + + self.move_to_register_card_screen(uid) + else: + Logger.debug(f"StellaPayUI: Card '{uid}' belongs to {card_info.owner_name}.") + self.selected_active_user(card_info.owner_name) + + @mainthread + def move_to_register_card_screen(self, uid: str): + # User was not found, proceed to registerUID file + self.manager.transition = SlideTransition(direction="right") + self.manager.get_screen(Screens.REGISTER_UID_SCREEN.value).nfc_id = uid + self.manager.current = Screens.REGISTER_UID_SCREEN.value def on_select_guest(self): self.select_special_user("Gast Account") @@ -338,7 +296,7 @@ def select_special_user(self, user: str): # Close the user dialog self.user_select_dialog.close_dialog() - self.manager.transition = SlideTransition(direction='left') + self.manager.transition = SlideTransition(direction="left") App.get_running_app().active_user = user diff --git a/scrs/ProductScreen.py b/scrs/ProductScreen.py index 962e5f8..c1f8c7b 100644 --- a/scrs/ProductScreen.py +++ b/scrs/ProductScreen.py @@ -1,4 +1,5 @@ -import os +import asyncio +import sys import threading import time from asyncio import AbstractEventLoop @@ -9,10 +10,10 @@ from kivy.clock import Clock, mainthread from kivy.lang import Builder from kivy.uix.screenmanager import Screen, SlideTransition -from kivymd.toast import toast from kivymd.uix.button import MDFlatButton, MDRaisedButton from kivymd.uix.dialog import MDDialog +from ds.Product import Product from ds.Purchase import Purchase from ds.ShoppingCart import ShoppingCart, ShoppingCartListener from scrs.TabDisplay import TabDisplay @@ -23,13 +24,13 @@ class OnChangeShoppingCartListener(ShoppingCartListener): - def __init__(self, product_screen): self.product_screen = product_screen def on_change(self): - self.product_screen.ids.shopping_cart_button.disabled = len( - ProductScreen.shopping_cart.get_shopping_cart()) == 0 + self.product_screen.ids.shopping_cart_button.disabled = ( + len(ProductScreen.shopping_cart.get_shopping_cart()) == 0 + ) active_user = App.get_running_app().active_user @@ -37,11 +38,14 @@ def on_change(self): for tab in self.product_screen.tabs: for product_item in tab.ids.container.children: # Find a matching purchase in the shopping cart (by the active user) - matching_purchase = next(( - purchase for purchase in ProductScreen.shopping_cart.get_shopping_cart() - if purchase.product_name == product_item.text - and purchase.purchaser_name == active_user - ), None) + matching_purchase = next( + ( + purchase + for purchase in ProductScreen.shopping_cart.get_shopping_cart() + if purchase.product_name == product_item.text and purchase.purchaser_name == active_user + ), + None, + ) # If this item is not in the shopping cart, we can set the count to zero (as it is not being bought) if matching_purchase is None: @@ -61,7 +65,7 @@ class ProductScreen(Screen): def __init__(self, **kwargs): # Load screen - Builder.load_file('kvs/ProductScreen.kv') + Builder.load_file("kvs/ProductScreen.kv") super(ProductScreen, self).__init__(**kwargs) # Class level variables @@ -94,41 +98,54 @@ def on_touch_up(self, touch): self.timeout_event.cancel() self.on_start_timeout() - # Load tabs (if they are not loaded yet) and load product information afterwards @mainthread - def load_category_data(self): + def show_products(self, products: Dict[str, List[Product]]): + if products is None or len(products) == 0: + return - Logger.debug(f"StellaPayUI: Loading category data on thread {threading.current_thread().name}") + # If tabs have not been created yet, create them first + if self.tabs is None or len(self.tabs) == 0: + Logger.debug(f"StellaPayUI: ({threading.current_thread().name}) Drawing tabs for the first time.") + self.tabs = [] + for tab_name in products.keys(): + tab = TabDisplay(text=tab_name) + self.ids.android_tabs.add_widget(tab) + self.tabs.append(tab) + else: + Logger.debug(f"StellaPayUI: Not drawing products again.") start_time = time.time() - if len(self.tabs) > 0: - Logger.debug("StellaPayUI: Don't load tabs as we already have that information.") - - Logger.debug( - f"StellaPayUI: Loaded category data and tabs (after skipping) in {time.time() - start_time} seconds") - - # Load product items (because we still need to reload them) - self.load_products() - - return - - Logger.debug("StellaPayUI: Loading category view") + for tab in self.tabs: + for product in products[tab.text]: + # Get fun fact description of database + product_description = App.get_running_app().database_manager.get_random_fun_fact(product.get_name()) - def handle_product_data(product_data: Dict[str, List["Product"]]): - for category in product_data.keys(): - # Create tab display - tab = TabDisplay(text=category) - self.ids.android_tabs.add_widget(tab) - self.tabs.append(tab) + # Add item to the tab + tab.ids.container.add_widget( + ProductListItem( + text=product.get_name(), + secondary_text=product_description, + secondary_theme_text_color="Custom", + secondary_text_color=[0.509, 0.509, 0.509, 1], + price="€" + product.get_price(), + ) + ) - Logger.debug( - f"StellaPayUI: Loaded category data and tabs (no skipping) in {time.time() - start_time} seconds") + # Add last item to the products (for each category) that is empty. This improves readability. + tab.ids.container.add_widget( + ProductListItem( + text="", + secondary_text="", + secondary_theme_text_color="Custom", + secondary_text_color=[0.509, 0.509, 0.509, 1], + price=None, + ) + ) - # Load product items - self.load_products() + Logger.debug(f"Loaded products of category {tab.text} (no skipping) in {time.time() - start_time} seconds") - App.get_running_app().data_controller.get_product_data(callback=handle_product_data) + Logger.debug(f"Loaded all products (no skipping) in {time.time() - start_time} seconds") def on_pre_enter(self, *args): # Initialize timeouts @@ -140,57 +157,22 @@ def on_pre_enter(self, *args): def on_enter(self, *args): # Set name of the active user in the toolbar (if there is one) - self.ids.toolbar.title = \ + self.ids.toolbar.title = ( App.get_running_app().active_user if App.get_running_app().active_user is not None else "Stella Pay" + ) - # Load product data - self.event_loop.call_soon_threadsafe(self.load_category_data) + if len(self.tabs) <= 0: + # Load product data + asyncio.run_coroutine_threadsafe(self.load_products(), loop=App.get_running_app().loop) # Load product information and set up product view - @mainthread - def load_products(self): + async def load_products(self): Logger.debug(f"StellaPayUI: Loading product data on thread {threading.current_thread().name}") - start_time = time.time() + product_data = await App.get_running_app().data_controller.get_product_data() - # Check if we have tabs loaded - if len(self.tabs) < 1: - toast("There are no loaded tabs!") - return - - if len(self.tabs[0].ids.container.children) > 0: - Logger.debug("StellaPayUI: Don't load products view again as it's already there..") - Logger.debug(f"Loaded products (after skipping) in {time.time() - start_time} seconds") - return - - Logger.debug(f"StellaPayUI: Setting up product view") - - @mainthread - def handle_product_data(product_data: Dict[str, List["Product"]]): - for tab in self.tabs: - for product in product_data[tab.text]: - # Get fun fact description of database - product_description = App.get_running_app().database_manager.get_random_fun_fact(product.get_name()) - - # Add item to the tab - tab.ids.container.add_widget( - ProductListItem(text=product.get_name(), secondary_text=product_description, - secondary_theme_text_color="Custom", - secondary_text_color=[0.509, 0.509, 0.509, 1], - price="€" + product.get_price())) - - # Add last item to the products (for each category) that is empty. This improves readability. - tab.ids.container.add_widget( - ProductListItem(text="", secondary_text="", secondary_theme_text_color="Custom", - secondary_text_color=[0.509, 0.509, 0.509, 1], - price=None)) - - Logger.debug( - f"Loaded products of category {tab.text} (no skipping) in {time.time() - start_time} seconds") - Logger.debug(f"Loaded all products (no skipping) in {time.time() - start_time} seconds") - - App.get_running_app().data_controller.get_product_data(callback=handle_product_data) + self.show_products(product_data) # # timeout callback function @@ -219,7 +201,7 @@ def on_leave(self, *args): # Called when the user wants to leave this active session. def on_leave_product_screen_button(self): self.end_user_session() - self.manager.transition = SlideTransition(direction='right') + self.manager.transition = SlideTransition(direction="right") self.manager.current = Screens.DEFAULT_SCREEN.value # @@ -243,11 +225,13 @@ def show_shopping_cart(self): # Retrieve all items from shopping cart and store in local shopping cart list for purchase in self.shopping_cart.get_shopping_cart(): - item = ShoppingCartItem(purchase=purchase, - text=purchase.product_name, - secondary_text="", - secondary_theme_text_color="Custom", - secondary_text_color=[0.509, 0.509, 0.509, 1]) + item = ShoppingCartItem( + purchase=purchase, + text=purchase.product_name, + secondary_text="", + secondary_theme_text_color="Custom", + secondary_text_color=[0.509, 0.509, 0.509, 1], + ) shopping_cart_items.append(item) # If there are items in the shopping cart, display them @@ -257,14 +241,8 @@ def show_shopping_cart(self): auto_dismiss=False, list_content=shopping_cart_items, buttons=[ - MDFlatButton( - text="Nee", - on_release=self.on_close_shoppingcart - ), - MDRaisedButton( - text="Ja", - on_release=self.on_confirm_payment - ), + MDFlatButton(text="Nee", on_release=self.on_close_shoppingcart), + MDRaisedButton(text="Ja", on_release=self.on_confirm_payment), ], ) @@ -292,48 +270,47 @@ def on_cancel_payment(self, dt): def on_confirm_payment(self, dt=None): Logger.info(f"StellaPayUI: Payment was confirmed by the user.") - @mainthread - def handle_transaction_result(success: bool): - # Reset instance variables - self.end_user_session() + asyncio.run_coroutine_threadsafe(self.submit_payment(), loop=App.get_running_app().loop) - if self.shopping_cart_dialog is not None: - self.shopping_cart_dialog.dismiss() + async def submit_payment(self): + Logger.info(f"StellaPayUI: Submitting payment of user!") - if success: + successfully_created_transactions = await App.get_running_app().data_controller.create_transactions( + self.shopping_cart + ) - self.timeout_event.cancel() + # Reset instance variables + self.end_user_session() - self.final_dialog = MDDialog( - text="Gelukt! Je aankoop is geregistreerd", - buttons=[ - MDRaisedButton( - text="Thanks", - on_release=self.on_thanks - ), - ] - ) + if self.shopping_cart_dialog is not None: + self.shopping_cart_dialog.dismiss() - self.timeout_event = Clock.schedule_once(self.on_thanks, 5) - self.final_dialog.open() - else: - - self.final_dialog = MDDialog( - text="Het is niet gelukt je aankoop te registreren. Herstart de app svp.", - buttons=[ - MDRaisedButton( - text="Herstart", - on_release=( - os._exit(1) - ) - ), - ] - ) - self.final_dialog.open() + if successfully_created_transactions: + self.show_thanks_dialog() + else: + self.show_failure_dialog() + + @mainthread + def show_thanks_dialog(self): + self.timeout_event.cancel() + + self.final_dialog = MDDialog( + text="Gelukt! Je aankoop is geregistreerd!", + on_dismiss=self.on_thanks + ) - # Make request to create transactions (on separate thread) - App.get_running_app().loop.call_soon_threadsafe( - App.get_running_app().data_controller.create_transactions, self.shopping_cart, handle_transaction_result) + self.timeout_event = Clock.schedule_once(self.on_thanks, 5) + self.final_dialog.open() + + @mainthread + def show_failure_dialog(self): + self.final_dialog = MDDialog( + text="Het is niet gelukt je aankoop te registreren. Herstart de app svp.", + buttons=[ + MDRaisedButton(text="Herstart", on_release=lambda: sys.exit(1)), + ], + ) + self.final_dialog.open() def on_thanks(self, _): if self.manager.current == Screens.PRODUCT_SCREEN.value: diff --git a/scrs/RegisterUIDScreen.py b/scrs/RegisterUIDScreen.py index ab354c8..88688ad 100644 --- a/scrs/RegisterUIDScreen.py +++ b/scrs/RegisterUIDScreen.py @@ -1,5 +1,5 @@ +import asyncio import threading -from asyncio import AbstractEventLoop from kivy import Logger from kivy.app import App @@ -7,9 +7,9 @@ from kivy.lang import Builder from kivy.uix.screenmanager import Screen from kivymd.toast import toast -from kivymd.uix.bottomsheet import MDListBottomSheet from utils.Screens import Screens +from ux.UserPickerDialog import UserPickerDialog class RegisterUIDScreen(Screen): @@ -23,30 +23,23 @@ def __init__(self, **kwargs): # call to user with arguments super(RegisterUIDScreen, self).__init__(**kwargs) - # local list that stores all mailadresses currently retrieved from the database - self.mail_list = [] - # Timeout variables self.timeout_event = None self.timeout_time = 30 - # Create the bottom menu - self.bottom_sheet_menu = None - - self.event_loop: AbstractEventLoop = App.get_running_app().loop - # # Function is called when the product screen is entered # def on_enter(self, *args): self.timeout_event = Clock.schedule_once(self.on_timeout, self.timeout_time) + self.user_select_dialog = UserPickerDialog(App.get_running_app().user_mapping.keys()) + self.user_select_dialog.bind(selected_user=lambda _, selected_user: self.on_user_selected(selected_user)) + # # Timeout callback function # def on_timeout(self, dt): - if self.bottom_sheet_menu: - self.bottom_sheet_menu.dismiss() self.timeout_event.cancel() self.on_cancel() @@ -58,10 +51,13 @@ def on_touch_up(self, touch): self.on_enter() # Return to default screen when cancelled + @mainthread def on_cancel(self): + if self.user_select_dialog is not None: + self.user_select_dialog.close_dialog() + self.manager.current = Screens.DEFAULT_SCREEN.value - # Saves user-card-mapping to the database def on_save_user(self): # Validate whether a correct user was selected. if self.ids.chosen_user.text not in App.get_running_app().user_mapping.keys(): @@ -71,51 +67,51 @@ def on_save_user(self): selected_user_name = self.ids.chosen_user.text selected_user_email = App.get_running_app().user_mapping[selected_user_name] - App.get_running_app().loop.call_soon_threadsafe( - self.register_card_mapping, selected_user_name, selected_user_email) + asyncio.run_coroutine_threadsafe(self.register_card_mapping(selected_user_name, selected_user_email), + loop=App.get_running_app().loop) + + async def register_card_mapping(self, selected_user_name, selected_user_email: str): - def register_card_mapping(self, selected_user_name, selected_user_email: str): + card_registered = await App.get_running_app() \ + .data_controller.register_card_info(card_id=self.nfc_id, email=selected_user_email, + owner=selected_user_name) - @mainthread - def handle_card_registration(success: bool): - Logger.debug( - f"StellaPayUI: Received callback of new card registration on {threading.current_thread().name}") + if card_registered: + self.card_registration_succeeded(selected_user_name) + else: + self.card_registration_failed(selected_user_name) - if success: - # Store the active user in the app so other screens can use it. - App.get_running_app().active_user = selected_user_name - self.manager.current = Screens.WELCOME_SCREEN.value - else: - toast( - f"Could not register this card to {selected_user_name}. Try selecting your name on the home screen instead.", - duration=5) - self.on_cancel() + Logger.debug( + f"StellaPayUI: ({threading.current_thread().name}) " + f"Registration of card '{self.nfc_id}' successful: {card_registered}") - # Try to register the card. - self.event_loop.call_soon_threadsafe(App.get_running_app().data_controller.register_card_info, self.nfc_id, - selected_user_email, selected_user_name, handle_card_registration) + @mainthread + def card_registration_succeeded(self, user: str): + # Store the active user in the app so other screens can use it. + App.get_running_app().active_user = user + self.manager.current = Screens.PRODUCT_SCREEN.value + + @mainthread + def card_registration_failed(self, user: str): + toast( + f"Could not register this card to {user}. Try selecting your name on the home screen instead.", + duration=5) + self.on_cancel() # # Whenever the user wants to show the list of users to register the card to. # - def on_click_user_list_button(self): + def on_open_user_selector(self): # Triggered whenever the user wants to start selecting a person # Restart timeout procedure self.timeout_event.cancel() - self.on_enter() - # Add items to the bottom list - self.bottom_sheet_menu = MDListBottomSheet(height="200dp") - for user_name, user_email in sorted(App.get_running_app().user_mapping.items()): - # store all emails addresses in the sheet_menu - self.bottom_sheet_menu.add_item(user_name, self.on_user_selected) - # open the bottom sheet menu - self.bottom_sheet_menu.open() + self.user_select_dialog.show_user_selector() - # When the user selects a user to register for this card. - def on_user_selected(self, item): + def on_user_selected(self, selected_user: str): # Triggered when the user selects a person from the dialog self.timeout_event.cancel() - self.on_enter() - self.ids.chosen_user.text = item.text + self.ids.chosen_user.text = selected_user + + self.user_select_dialog.close_dialog() def on_leave(self, *args): # Stop the timer @@ -123,4 +119,3 @@ def on_leave(self, *args): # Hide name of selected user self.ids.chosen_user.text = "" - diff --git a/scrs/StartupScreen.py b/scrs/StartupScreen.py index 7f9f91b..fa31bd7 100644 --- a/scrs/StartupScreen.py +++ b/scrs/StartupScreen.py @@ -1,8 +1,7 @@ import sys from kivy import Logger -from kivy.app import App -from kivy.clock import Clock +from kivy.clock import Clock, mainthread from kivy.lang import Builder from kivy.properties import ObjectProperty from kivy.uix.screenmanager import Screen @@ -11,7 +10,7 @@ from utils.Screens import Screens from utils.async_requests.AsyncResult import AsyncResult -Builder.load_file('kvs/StartupScreen.kv') +Builder.load_file("kvs/StartupScreen.kv") class StartupScreen(Screen): @@ -26,7 +25,7 @@ def __init__(self, **kwargs): # Calls upon entry of this screen # def on_enter(self, *args): - App.get_running_app().loop.call_soon_threadsafe(self.check_if_all_data_is_loaded) + self.check_if_all_data_is_loaded() # Called when all data has loaded def finished_loading(self, dt): @@ -41,8 +40,8 @@ def on_categories_loaded(self, _, _2): def on_products_loaded(self, _, _2): self.check_if_all_data_is_loaded() + @mainthread def check_if_all_data_is_loaded(self) -> None: - self.set_loading_text("Waiting for data to load... (0/3)") if self.users_loaded is None or self.users_loaded.result is None: diff --git a/utils/SessionManager.py b/utils/SessionManager.py index a60885f..11f902e 100644 --- a/utils/SessionManager.py +++ b/utils/SessionManager.py @@ -1,10 +1,14 @@ +import functools import json import os +import sys +import threading import traceback from typing import Optional import requests from kivy import Logger +from kivy.app import App from utils import Connections @@ -28,36 +32,29 @@ def parse_to_json(file): def create_authentication_file(): # Create the authentication file if it doesn't exist. if not os.path.exists(SessionManager.AUTHENTICATION_FILE): - open(SessionManager.AUTHENTICATION_FILE, 'w').close() - - # This method authenticates to the backend and makes the session ready for use - def setup_session(self, on_finish=None): + open(SessionManager.AUTHENTICATION_FILE, "w").close() + async def setup_session_async(self) -> bool: self.session = requests.Session() - self.__setup_authentication() - - # Call callback if defined - if on_finish is not None: - on_finish() + return await self._setup_authentication_async() - def __setup_authentication(self) -> bool: + async def _setup_authentication_async(self) -> bool: # Convert authentication.json to json dict - - json_credentials = None - try: json_credentials = self.parse_to_json(SessionManager.AUTHENTICATION_FILE) except Exception: Logger.critical( - "StellaPayUI: You need to provide an 'authenticate.json' file for your backend credentials.") - os._exit(1) - - response = None + "StellaPayUI: You need to provide an 'authenticate.json' file for your backend credentials." + ) + sys.exit(1) # Attempt to log in try: - response = self.session.post(url=Connections.authenticate(), json=json_credentials, timeout=5) + post_future = App.get_running_app().loop.run_in_executor( + None, functools.partial(self.session.post, Connections.authenticate(), json=json_credentials, timeout=5) + ) + response = await post_future except Exception: Logger.critical(f"StellaPayUI: Something went wrong while setting up authentication to the backend server!") return False @@ -65,73 +62,77 @@ def __setup_authentication(self) -> bool: # Break control flow if the user cannot identify himself if response is None or (response is not None and not response.ok): Logger.critical( - "StellaPayUI: Could not correctly authenticate, error code 8. Check your username and password") + "StellaPayUI: Could not correctly authenticate, error code 8. Check your username and password" + ) return False else: - Logger.debug("StellaPayUI: Authenticated correctly to backend.") + Logger.debug("StellaPayUI: Authenticated correctly to backend (async).") return True - # Perform a get request to the given url. You can provide a callback to receive the result. - def do_get_request(self, url: str) -> Optional[requests.Response]: + async def do_get_request_async(self, url: str) -> Optional[requests.Response]: + Logger.debug(f"StellaPayUI: ({threading.current_thread().name}) Async GET request to {url}") + if self.session is None: Logger.warning(f"StellaPayUI: No session was found, so initializing a session.") - self.session = requests.Session() - # Could not reauthenticate to the server - if not self.__setup_authentication(): + if not await self.setup_session_async(): Logger.critical(f"StellaPayUI: Could not authenticate in new session!") return None try: - response = self.session.get(url, timeout=5) + get_future = App.get_running_app().loop.run_in_executor( + None, functools.partial(self.session.get, url, timeout=5) + ) + response = await get_future return response except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e1: - Logger.debug("StellaPayUI: Connection was reset, so reauthenticating...") - - self.session = requests.Session() - - # Could not reauthenticate to the server - if not self.__setup_authentication(): - return None - Logger.critical(f"StellaPayUI: Timeout on get request {e1}") + Logger.debug("StellaPayUI: Connection was reset, trying to reauthenticate...") + await self.setup_session_async() + + get_future = App.get_running_app().loop.run_in_executor( + None, functools.partial(self.session.get, url, timeout=5) + ) + response = await get_future - return self.session.get(url) + return response except Exception as e2: Logger.critical(f"StellaPayUI: A problem with a GET request") traceback.print_exception(None, e2, e2.__traceback__) return None - # Perform a post request to the given url. You can give do functions as callbacks (which will return the response) - def do_post_request(self, url: str, json_data=None) -> Optional[requests.Response]: + async def do_post_request_async(self, url: str, json_data=None) -> Optional[requests.Response]: + Logger.debug(f"StellaPayUI: ({threading.current_thread().name}) Async POST request to {url}") if self.session is None: Logger.warning(f"StellaPayUI: No session was found, so initializing a session.") - self.session = requests.Session() - # Could not reauthenticate to the server - if not self.__setup_authentication(): + if not await self.setup_session_async(): Logger.critical(f"StellaPayUI: Could not authenticate in new session!") return None try: - response = self.session.post(url, json=json_data, timeout=5) + post_future = App.get_running_app().loop.run_in_executor( + None, functools.partial(self.session.post, url, timeout=5, json=json_data) + ) + response = await post_future return response except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e1: - Logger.debug("StellaPayUI: Connection was reset, so reauthenticating...") - self.session = requests.Session() - - # Could not reauthenticate to the server - if not self.__setup_authentication(): - return None - Logger.critical(f"StellaPayUI: Timeout on post request {e1}") + Logger.debug("StellaPayUI: Connection was reset, trying to reauthenticate...") + await self.setup_session_async() + + post_future = App.get_running_app().loop.run_in_executor( + None, functools.partial(self.session.post, url, timeout=5, json=json_data) + ) + response = await post_future - return self.session.post(url, json=json_data) + return response except Exception as e2: - Logger.critical(f"StellaPayUI: A problem with a POST request:") + Logger.critical(f"StellaPayUI: A problem with a POST request") traceback.print_exception(None, e2, e2.__traceback__) + return None diff --git a/ux/ProductListItem.py b/ux/ProductListItem.py index 8ff3b26..fa8c44f 100644 --- a/ux/ProductListItem.py +++ b/ux/ProductListItem.py @@ -46,7 +46,7 @@ def __init__(self, price: Optional[str], **kwargs): # Create dialog if it wasn't created before. if price is not None: - App.get_running_app().loop.call_soon_threadsafe(self.load_dialog_screen) + self.load_dialog_screen() def on_add_product(self): # Let caller know that we want to add an item to the shopping cart diff --git a/ux/UserPickerDialog.py b/ux/UserPickerDialog.py index f5c58d7..44e34dd 100644 --- a/ux/UserPickerDialog.py +++ b/ux/UserPickerDialog.py @@ -1,20 +1,21 @@ import datetime +from typing import List -from kivy.app import App from kivy.lang import Builder from kivy.properties import StringProperty from kivy.uix.boxlayout import BoxLayout from kivymd.uix.dialog import MDDialog -Builder.load_file('kvs/UserPickerContent.kv') +Builder.load_file("kvs/UserPickerContent.kv") class UserPickerContent(BoxLayout): picked_user = StringProperty() - def __init__(self, **kwargs): + def __init__(self, selectable_users: List[str], **kwargs): super(UserPickerContent, self).__init__(**kwargs) - self.eligible_users_to_select = list(App.get_running_app().user_mapping.keys()) + self.eligible_users_to_select = selectable_users + # list(App.get_running_app().user_mapping.keys()) self.last_click_registered = datetime.datetime.now() @@ -26,10 +27,12 @@ def on_text_updated(self, typed_text: str): user_name: str for user_name in self.eligible_users_to_select: if typed_text.lower() in user_name.lower(): - self.ids.matched_users.data.append({ - "viewclass": "OneLineIconListItem", - "text": user_name, - }) + self.ids.matched_users.data.append( + { + "viewclass": "OneLineIconListItem", + "text": user_name, + } + ) # Make sure to add listeners so we get alerted whenever a child is clicked. for shown_user_element in self.ids.matched_users.children[0].children: @@ -69,9 +72,9 @@ class UserPickerDialog(MDDialog): If no person is selected, this property will be None. """ - def __init__(self, **kwargs): + def __init__(self, selectable_users: List[str], **kwargs): self.type = "custom" - self.content_cls = UserPickerContent() + self.content_cls = UserPickerContent(selectable_users=selectable_users) self.title = "Select a name" super(UserPickerDialog, self).__init__(**kwargs)