diff --git a/src/server/BreCal/notifications/notifier.py b/src/server/BreCal/notifications/notifier.py index 1e100c2..9968fc6 100644 --- a/src/server/BreCal/notifications/notifier.py +++ b/src/server/BreCal/notifications/notifier.py @@ -4,6 +4,7 @@ from BreCal.database.sql_handler import execute_sql_query_standalone from BreCal.database.sql_queries import SQLQuery from BreCal.schemas import model from BreCal.brecal_utils.time_handling import difference_to_then +from BreCal.notifications.accounts import mail_server, mail_port, mail_address, mail_pwd from BreCal.services.email_handling import EmailHandler, create_shipcall_evaluation_notification, send_notification, get_default_html_email from BreCal.database.enums import ParticipantwiseTimeDelta @@ -14,7 +15,6 @@ eta_etd_type_dict = { model.ShipcallType.shifting : "Wechselnd" } - class Notifier(): """ This class provides quick access to different notification functions. @@ -54,42 +54,64 @@ class Notifier(): update_database = True if not is_test else False # if_test, the database will not be updated. time_diff_threshold = time_diff_threshold if not is_test else 0.0 # 0.0 delay when is_test is set. - - from BreCal.notifications.accounts import mail_server, mail_port, mail_address, mail_pwd email_handler = EmailHandler(mail_server=mail_server, mail_port=mail_port, mail_address=mail_address) # get candidates: find all eligible shipcalls, where the evaluation state is yellow or red & the notifications are not yet sent - eligible_shipcalls = Notifier.get_eligible_shipcalls() + eligible_shipcalls = Notifier.get_eligible_shipcalls(time_diff_threshold) - # find all notifications, which belong to the shipcall ids of the eligible_shipcall list - # a time_diff_threshold is used to block those notifications, which are still fairly novel - eligible_notifications = Notifier.get_eligible_notifications(eligible_shipcalls, time_diff_threshold) + # get a list of tuples, where (shipcall, users) are combined for easier iterations. + notification_instructions = Notifier.build_notification_tuples(eligible_shipcalls) - if len(eligible_notifications) > 0: # only perform a login when there are eligible notifications + if len(notification_instructions)>0: # only perform a login when there are any notification_instructions try: # login in advance, so the email handler uses a shared connection. It disconnects only once at the end of the call. email_handler.login(interactive=False, pwd=mail_pwd) + + for shipcall, users in notification_instructions: + assert isinstance(shipcall, model.Shipcall) + assert isinstance(users,list) + assert all([isinstance(user,model.User) for user in users]) - for notification in eligible_notifications: - eligible_users = Notifier.get_eligible_users(notification) + # iterate over each notification type + for notification_type in Notifier.get_all_notification_types(): + # consider only those users, which have subscribed to the respective notification type + eligible_users = Notifier.get_eligible_users(users, notification_type) - # create an Email and send it to each eligible_user. - # #TODO: this method must be a distributor. It should send emails for those, who want emails, and provide placeholders for other types of notifications - Notifier.create_and_send_email_notification(email_handler, mail_pwd, eligible_users, notification, update_database=update_database, debug=debug) + if len(eligible_users)>0: + Notifier.create_and_send_notification_mapper(notification_type, shipcall, eligible_users, email_handler, mail_pwd, debug=debug) - # #TODO: except... logging? + if not debug: + # populate notifications within the database to keep track + Notifier.generate_notification(shipcall, notification_type) + + # update the database entries, so notifications are only sent once. + if update_database: + Notifier.shipcall_put_update_evaluation_notifications_sent_flag(shipcall) + + # #TODO: except... create log? finally: email_handler.close() return + + @staticmethod + def build_notification_tuples(eligible_shipcalls)->list[typing.Tuple[model.Shipcall,list[model.User]]]: + """ + creates tuples, where shipcall and the list of attached users are grouped. One can iterate over the tuples + to perform actions, such as issueing notifications + """ + notification_instructions = [] + for shipcall in eligible_shipcalls: + # get all users, which are attached to the shipcall (uses the History dataset) + users = Notifier.get_users_via_history(shipcall.id) + + # create and store tuples of (shipcall, users) for each eligible shipcall + notification_instructions.append((shipcall, users)) + return notification_instructions @staticmethod - def get_eligible_users(notification): - # get all users, which are attached to the shipcall (uses the History dataset) - users = Notifier.get_users_via_history(notification.shipcall_id) - - # filter: only consider the users, which have subscribed to the notification type - eligible_users = [user for user in users if Notifier.check_user_is_subscribed_to_notification_type(user,notification_type=notification.type)] + def get_eligible_users(users, notification_type): + eligible_users = [user for user in users if Notifier.check_user_is_subscribed_to_notification_type(user,notification_type=notification_type)] # filter: consider only those users, where an Email is set # #TODO: this is Email-specific and should not be a filter for other notifications @@ -267,7 +289,7 @@ class Notifier(): return @staticmethod - def get_eligible_shipcalls(): + def get_eligible_shipcalls(time_diff_threshold:float): """ get all eligible shipcalls, which do not have a sent notification yet criterion a) notification shall not be sent yet (evaluation_notifications_sent = 0) @@ -275,7 +297,15 @@ class Notifier(): """ query = 'SELECT * FROM shipcall WHERE (evaluation_notifications_sent = ?evaluation_notifications_sent?) AND (evaluation = 2 OR evaluation = 3)' evaluation_notifications_sent = 0 + + # filter out the shipcalls, where the notifications were already sent (ignores null values, which is as expected) eligible_shipcalls = execute_sql_query_standalone(query=query, model=model.Shipcall, param={"evaluation_notifications_sent" : evaluation_notifications_sent}) + + # filter out the shipcalls, where evaluation_time is not set + eligible_shipcalls = [shipcall for shipcall in eligible_shipcalls if shipcall.evaluation_time is not None] + + # filter out the shipcalls, where the evaluation_time is too recent. We expect a minimum difference, so user input errors do not cause notifications + eligible_shipcalls = [shipcall for shipcall in eligible_shipcalls if Notifier.check_shipcall_evaluation_time_exceeds_minimum_time_difference(shipcall, time_diff_threshold)] return eligible_shipcalls @staticmethod @@ -283,21 +313,17 @@ class Notifier(): """obtain all notifications, which belong to the shipcall id""" query = SQLQuery.get_notifications() eligible_notifications = execute_sql_query_standalone(query=query, model=model.Notification, param={"scid" : shipcall.id}) - eligible_notifications = [notification for notification in eligible_notifications if Notifier.check_notification_exceeds_minimum_time_difference(notification, time_diff_threshold)] eligible_notifications = [notification for notification in eligible_notifications if Notifier.check_notification_level_matches_shipcall_entry(notification, shipcall)] return eligible_notifications @staticmethod - def check_notification_exceeds_minimum_time_difference(notification:model.Notification, time_diff_threshold:float): + def check_shipcall_evaluation_time_exceeds_minimum_time_difference(shipcall:model.Shipcall, time_diff_threshold:float): """ a notification may only be sent, when the created notification has been created or modified {time_diff_threshold} seconds ago. """ - assert (notification.created is not None) or (notification.modified is not None), f"must provide either 'created' or 'modified'" - if notification.modified is not None: - return difference_to_then(notification.modified)>time_diff_threshold - else: - return difference_to_then(notification.created)>time_diff_threshold - + assert (shipcall.evaluation_time is not None), f"must provide 'evaluation_time'" + return difference_to_then(shipcall.evaluation_time)>=time_diff_threshold + @staticmethod def check_notification_level_matches_shipcall_entry(notification, shipcall): """ @@ -307,16 +333,18 @@ class Notifier(): return int(shipcall.evaluation) == int(notification.level) @staticmethod - def get_eligible_notifications(shipcalls:list[model.Shipcall], time_diff_threshold:float): + def get_eligible_notifications(shipcalls:list[model.Shipcall]): """obtain a list of all notifications of each element of the shipcall list.""" eligible_notifications = [] for shipcall in shipcalls: - eligible_notification = Notifier.get_eligible_notifications_of_shipcall(shipcall, time_diff_threshold) + eligible_notification = Notifier.get_eligible_notifications_of_shipcall(shipcall) eligible_notifications.extend(eligible_notification) + raise NotImplementedError("refactoring!") return eligible_notifications @staticmethod def create_notifications_for_user_list(shipcall, users:list[model.User]): + raise NotImplementedError("deprecated") notification_type_list = [] for user in users: user_notification_type_list = Notifier.build_notification_type_list(user) @@ -331,8 +359,26 @@ class Notifier(): return @staticmethod - def generate_notifications(shipcall_id): + def generate_notification(shipcall:model.Shipcall, notification_type:model.NotificationType): """ + This one-line method creates a notification for the provided shipcall and notification type + """ + schemaModel = dict( + shipcall_id = shipcall.id, + level = int(shipcall.evaluation), + type = notification_type, + message = "", # #TODO_messsage: what should be stored here? The HTML-template appears to be too long. + created = datetime.datetime.now(), + modified=None) + query = SQLQuery.get_notifications_post(schemaModel) + schemas = execute_sql_query_standalone(query=query, param=schemaModel, command_type="execute") + return + + @staticmethod + def DEPRECATED_generate_notifications(shipcall_id): + """ + # #TODO_delete: this method can be safely removed after 15.08.2024 + This one-line method creates all notifications for the provided shipcall id. It does so by obtaining the shipcall, looking up its history, and finding all attached users. For each user, a notification will be created for each subscribed notification type (e.g., Email) @@ -385,9 +431,9 @@ class Notifier(): return eta_etd @staticmethod - def prepare_notification_body(notification): + def prepare_notification_body(shipcall:model.Shipcall): # obtain the respective shipcall and ship - shipcall = execute_sql_query_standalone(query=SQLQuery.get_shipcall_by_id(), model=model.Shipcall, param={"id" : notification.shipcall_id}, command_type="single") + shipcall = execute_sql_query_standalone(query=SQLQuery.get_shipcall_by_id(), model=model.Shipcall, param={"id" : shipcall.id}, command_type="single") ship = execute_sql_query_standalone(query=SQLQuery.get_ship_by_id(), model=model.Ship, param={"id" : shipcall.ship_id}, command_type="single") # use ship & shipcall data models to prepare the body @@ -398,10 +444,10 @@ class Notifier(): return (ship_name, evaluation_message, eta_etd, eta_etd_type) @staticmethod - def shipcall_put_update_evaluation_notifications_sent_flag(notification): - # change the 'evaluation_notifications_sent' flag + def shipcall_put_update_evaluation_notifications_sent_flag(shipcall): + # change the 'evaluation_notifications_sent' flag to 1 evaluation_notifications_sent = 1 - schemaModel = {"id":notification.shipcall_id, "evaluation_notifications_sent":evaluation_notifications_sent} + schemaModel = {"id":shipcall.id, "evaluation_notifications_sent":evaluation_notifications_sent} query = SQLQuery.get_shipcall_put(schemaModel) schemas = execute_sql_query_standalone(query=query, param=schemaModel, command_type="execute") @@ -423,7 +469,20 @@ class Notifier(): return email_tgts @staticmethod - def create_and_send_email_notification(email_handler:EmailHandler, pwd:bytes, users:list[model.User], notification:model.Notification, update_database:bool=True, debug:bool=False): + def create_and_send_notification_mapper(notification_type:model.NotificationType, shipcall:model.Shipcall, eligible_users:list[model.User], email_handler:EmailHandler, mail_pwd:bytes, debug:bool=False): + # #TODO_refactor: instead create a method, which contains the 'distribution-logic' for all notification types + if int(notification_type)==int(model.NotificationType.email): + # create an Email and send it to each eligible_user. + # #TODO: this method must be a distributor. It should send emails for those, who want emails, and provide placeholders for other types of notifications + Notifier.create_and_send_email_notification(email_handler, mail_pwd, eligible_users, shipcall, debug=debug) + + elif int(notification_type)==int(model.NotificationType.undefined): pass + elif int(notification_type)==int(model.NotificationType.push): pass + else: pass + return + + @staticmethod + def create_and_send_email_notification(email_handler:EmailHandler, pwd:bytes, users:list[model.User], shipcall:model.Shipcall, debug:bool=False): """ # #TODO_rename: when there is more than one type of notification, this should be renamed. This method refers to a validation-state notification @@ -436,7 +495,7 @@ class Notifier(): # prepare and build the Email content content = get_default_html_email() files = [] # optional attachments - ship_name, evaluation_message, eta_etd, eta_etd_type = Notifier.prepare_notification_body(notification) + ship_name, evaluation_message, eta_etd, eta_etd_type = Notifier.prepare_notification_body(shipcall) msg_multipart,msg_content = create_shipcall_evaluation_notification( email_handler, ship_name, evaluation_message, eta_etd, eta_etd_type, content, files=files @@ -444,11 +503,6 @@ class Notifier(): # send the messages via smtlib's SSL functions send_notification(email_handler, email_tgts, msg_multipart, pwd, debug=debug) - - # #TODO_refactor: when there are multiple notification types, it makes sense to decouple updating the database - # from this method. Hence, an update would be done after *all* notifications are sent - if update_database: - Notifier.shipcall_put_update_evaluation_notifications_sent_flag(notification) return @staticmethod @@ -473,4 +527,9 @@ class Notifier(): else: # placeholder: whatsapp/signal raise NotImplementedError(notification_type) + @staticmethod + def get_all_notification_types(): + from BreCal.schemas import model + return list(model.NotificationType._member_map_.values()) + diff --git a/src/server/BreCal/services/schedule_routines.py b/src/server/BreCal/services/schedule_routines.py index e46ead9..5725594 100644 --- a/src/server/BreCal/services/schedule_routines.py +++ b/src/server/BreCal/services/schedule_routines.py @@ -39,7 +39,6 @@ def UpdateShipcalls(options:dict = {'past_days':2}): for shipcall_id in shipcall_ids: # apply 'Traffic Light' evaluation to obtain 'GREEN', 'YELLOW' or 'RED' evaluation state. The function internally updates the mysql database evaluate_shipcall_state(mysql_connector_instance=pooledConnection, shipcall_id=shipcall_id) # new_id (last insert id) refers to the shipcall id - Notifier.generate_notifications(shipcall_id) pooledConnection.close()