diff --git a/src/server/BreCal/database/sql_handler.py b/src/server/BreCal/database/sql_handler.py index 2d85175..05c9296 100644 --- a/src/server/BreCal/database/sql_handler.py +++ b/src/server/BreCal/database/sql_handler.py @@ -33,7 +33,8 @@ def get_synchronous_shipcall_times_standalone(query_time:pd.Timestamp, all_df_ti returns: counts """ - assert isinstance(query_time,pd.Timestamp) + assert isinstance(query_time,pd.Timestamp) or pd.isnull(query_time), f"expected query_time to be a pd.Timestamp or pd.NaT. Found: {type(query_time)}" + assert isinstance(all_df_times,pd.DataFrame) # get a timedelta for each valid (not Null) time entry time_deltas_eta = [(query_time.to_pydatetime()-time_.to_pydatetime()) for time_ in all_df_times.loc[:,"eta_berth"] if not pd.isnull(time_)] @@ -439,4 +440,5 @@ class SQLHandler(): def count_synchronous_shipcall_times(self, query_time:pd.Timestamp, all_df_times:pd.DataFrame, delta_threshold=900)->int: """count all times entries, which are too close to the query_time. The {delta_threshold} determines the threshold. returns counts (int)""" + assert isinstance(all_df_times, pd.DataFrame) return get_synchronous_shipcall_times_standalone(query_time, all_df_times, delta_threshold) diff --git a/src/server/BreCal/database/sql_queries.py b/src/server/BreCal/database/sql_queries.py index 147ee02..244c62f 100644 --- a/src/server/BreCal/database/sql_queries.py +++ b/src/server/BreCal/database/sql_queries.py @@ -222,7 +222,7 @@ class SQLQuery(): "api_key, notify_email, notify_whatsapp, notify_signal, notify_popup, created, modified FROM user " +\ "WHERE user_name = ?username? OR user_email = ?username?" return query - + @staticmethod def get_notifications()->str: query = "SELECT id, shipcall_id, level, type, message, created, modified FROM notification " + \ @@ -266,6 +266,11 @@ class SQLQuery(): query = "SELECT id, name, imo, callsign, participant_id, length, width, is_tug, bollard_pull, eni, created, modified, deleted FROM ship ORDER BY name" return query + @staticmethod + def get_ship_by_id()->str: + query = "SELECT id, name, imo, callsign, participant_id, length, width, is_tug, bollard_pull, eni, created, modified, deleted FROM ship WHERE id = ?id?" + return query + @staticmethod def get_times()->str: query = "SELECT id, eta_berth, eta_berth_fixed, etd_berth, etd_berth_fixed, lock_time, lock_time_fixed, " + \ @@ -321,6 +326,22 @@ class SQLQuery(): query = prefix+stage1+bridge+stage2+suffix return query + + @staticmethod + def get_notifications_post(schemaModel:dict)->str: + param_keys = {key:key for key in schemaModel.keys()} + + prefix = "INSERT INTO notification (" + bridge = ") VALUES (" + suffix = ")" + + non_dynamic_keys = ["id", "created", "modified"] + + stage1 = ",".join([key for key in schemaModel.keys() if not key in non_dynamic_keys]) + stage2 = ",".join([f"?{param_keys.get(key)}?" for key in schemaModel.keys() if not key in non_dynamic_keys]) + + query = prefix+stage1+bridge+stage2+suffix + return query @staticmethod def get_last_insert_id()->str: diff --git a/src/server/BreCal/notifications/accounts.py b/src/server/BreCal/notifications/accounts.py new file mode 100644 index 0000000..0e3bbd0 --- /dev/null +++ b/src/server/BreCal/notifications/accounts.py @@ -0,0 +1,7 @@ +"""This file contains login information to register into distinct notification accounts.""" + + +mail_server = 'w01d5503.kasserver.com' +mail_port=465 +mail_address="max.metz@scope-sorting.com" +mail_pwd = b'gAAAAABmqJlkXbtJTL1tFiyQNHhF_Y7sgtVI0xEx07ybwbX70Ro1Vp73CLDq49eFSYG-1SswIDQ2JBSORYlWaR-Vh2kIwPHy_lX8SxkySrRvBRzkyZP5x0I=' diff --git a/src/server/BreCal/notifications/notifier.py b/src/server/BreCal/notifications/notifier.py index dc74b91..9968fc6 100644 --- a/src/server/BreCal/notifications/notifier.py +++ b/src/server/BreCal/notifications/notifier.py @@ -1,7 +1,19 @@ import typing +import datetime 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 + +eta_etd_type_dict = { + model.ShipcallType.arrival : "Ankunft", + model.ShipcallType.departure : "Abfahrt", + model.ShipcallType.shifting : "Wechselnd" +} class Notifier(): """ @@ -35,112 +47,82 @@ class Notifier(): - iterate over each remaining shipcall and apply .send_notification - those which are unsent, shall be sent by the respective type """ - raise NotImplementedError("skeleton") + # set a threshold, when alarm event notifications become eligible + time_diff_threshold = float(ParticipantwiseTimeDelta.NOTIFICATION)*60 # m minutes, converted to seconds + + debug = is_test # if is_test, the Emails will not be issued. Only a print message will be created. + 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. - # get all shipcalls - all_shipcalls = NotImplementedError + email_handler = EmailHandler(mail_server=mail_server, mail_port=mail_port, mail_address=mail_address) - shipcalls = [shipcall for shipcall in all_shipcalls if not shipcall.evaluation_notifications_sent] - for shipcall in shipcalls: - notification_list = Notifier.send_notification(shipcall, is_test=is_test) + # 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(time_diff_threshold) - # #TODO: get all notifications - # #TODO: get matching shipcall (based on shipcall_id) + # get a list of tuples, where (shipcall, users) are combined for easier iterations. + notification_instructions = Notifier.build_notification_tuples(eligible_shipcalls) - # #TODO: filter: consider only those, which are not yet sent + 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]) - # identify necessity - # #TODO: get the 'evaluation_notifications_sent' field from all shipcalls (based on shipcall_id) - # if not -> return - # USE shipcall.evaluation_notifications_sent + # 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) - # #TODO: those which are unsent, shall be created&sent by the respective type -- Note: consider the is_test argument - # iterate over the list of Notifier.build_notification_type_list - # one might use Notifier.create(..., update_database=True) - # use the History (GetHistory -- by shipcall_id) to identify all subscribed users + if len(eligible_users)>0: + Notifier.create_and_send_notification_mapper(notification_type, shipcall, eligible_users, email_handler, mail_pwd, debug=debug) - # #TODO: update the shipcall dataset ('evaluation_notifications_sent') -- Note: consider the is_test argument + if not debug: + # populate notifications within the database to keep track + Notifier.generate_notification(shipcall, notification_type) - # #TODO_clarify: how to handle the 'evaluation_notifications_sent', when there is no recipient? + # 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 send_notification(shipcall:model.Shipcall, is_test:bool=False)->list[model.Notification]: - """ - Complex-function, which is responsible of creating notification messages, issuing them to users and optionally updating - the database. The requirement is, that the notification is required and passes through an internal set of filters. + 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)] - Steps: - - get all notifications of shipcall_id - - identify the assigned list of users - - apply all filters. When a filter triggers, exit. If not, create and send a notification. - """ - update_database = False if is_test else True - # #TODO: the concept of old state and new state must be refactored. - # old state: read shipcall_id from notifications and look for the latest finding (if None -> EvaluationType.undefined) - # new state: read shipcall_id from shipcalls and look for the *current* 'evaluation' (-> EvaluationType(value)) - - # get existing notifications by shipcall_id (list) - existing_notifications = Notifier.get_existing_notifications(shipcall_id=shipcall.id) - old_state = NotImplementedError - - new_state = shipcall.evaluation - - # get User by querying all History objects of a shipcall_id - users = Notifier.get_users_via_history(shipcall_id=shipcall.id) - - # identify necessity - # state-check: Did the 'evaluation' shift to a higher level of severity? - severity_bool = Notifier.check_higher_severity(old_state, new_state) - if not severity_bool: - return None - - - # #TODO: time-based filter. There shall be 'enough' time between the evaluation time and NOW - evaluation_time = shipcall.evaluation_time - # latency_bool = #TODO_DIFFERENCE_FROM_NOW_TO_EVALUATION_TIME____THIS_METHOD_ALREADY_EXISTS(evaluation_time) - # careful: what is True, what is False? - # if latency_booL: - # return None - - notification_list = [] - for user in users: - notification = Notifier.create( - shipcall.id, - old_state, - new_state, - user, - update_database=update_database, - is_test=is_test - ) - notification_list.append(notification) - return notification_list - - @staticmethod - def publish(shipcall_id, old_state, new_state, user, update_database:bool=False)->typing.Optional[model.Notification]: - """ - Complex-function, which creates, sends and documents a notification. It serves as a convenience function. - The method does not apply internal filters to identify, whether a notification should be created in the first place. - - options: - update_database: bool. - # #TODO: instead of update_database, one may also use is_test - """ - # 1.) create - # ... = Notifier.create(shipcall_id, old_state, new_state, user) # e.g., might return a dictionary of dict[model.NotificationType, str], where str is the message - - # 2.) send - # ... = Notifier.send(...) # should contain internal 'logistics', which user the respective handlers to send notifications - - # 3.) document (mysql database) - # if update_database - # ... = Notifier.document(...) - raise NotImplementedError("skeleton") - return + # filter: consider only those users, where an Email is set + # #TODO: this is Email-specific and should not be a filter for other notifications + eligible_users = [user for user in eligible_users if user.user_email is not None] + return eligible_users @staticmethod def create(shipcall_id, old_state, new_state, user, update_database:bool=False)->typing.Optional[model.Notification]: """ + # #TODO_refactor: drastically change this method. It should only generate notifications, but not send them. + Standalone function, which creates a Notification for a specific user. Steps: @@ -173,6 +155,7 @@ class Notifier(): # get a list of all subscribed notification types and track the state (success or failure) + raise NotImplementedError("skeleton") successes = {} notification_type_list = Notifier.build_notification_type_list(user) for notification_type in notification_type_list: @@ -183,7 +166,6 @@ class Notifier(): success_state = Notifier.send_notification_by_type(notification_type, message) successes[notification_type] = success_state - raise NotImplementedError("skeleton") notification = ... return notification @@ -197,11 +179,10 @@ class Notifier(): def get_users_via_history(shipcall_id:int)->list[model.User]: """using the History objects, one can infer the user_id, which allows querying the Users""" histories = execute_sql_query_standalone(query=SQLQuery.get_history(), param={"shipcallid" : shipcall_id}, model=model.History, command_type="query") - user_ids = [ - history.user_id - for history in histories - ] - users = [Notifier.get_user(user_id) for user_id in user_ids] + assert isinstance(histories,list) + assert all([isinstance(history,model.History) for history in histories]) + + users = [Notifier.get_user(history.user_id) for history in histories] return users @staticmethod @@ -306,8 +287,249 @@ class Notifier(): else: raise ValueError(notification_type) return + + @staticmethod + 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) + criterion b) evaluation state is yellow or red (type 2 or 3) + """ + 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 + def get_eligible_notifications_of_shipcall(shipcall:model.Shipcall, time_diff_threshold:float)->list[model.Notification]: + """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_level_matches_shipcall_entry(notification, shipcall)] + return eligible_notifications + + @staticmethod + 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 (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): + """ + a notification may only be sent, when the shipcall entry matches the notification level. + otherwise, a user may have adapted the shipcall in the mean-time, so a notification would no longer be useful. + """ + return int(shipcall.evaluation) == int(notification.level) + + @staticmethod + 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) + 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) + notification_type_list.extend(user_notification_type_list) + + # get the unique notification types + notification_type_list = list(set(notification_type_list)) + for notification_type in notification_type_list: + schemaModel = dict(shipcall_id = shipcall.id, level = int(shipcall.evaluation), type = notification_type, message = "", 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 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) + """ + shipcall = Notifier.get_shipcall(shipcall_id) + notifications = execute_sql_query_standalone(query=SQLQuery.get_notifications(), param={"scid" : shipcall_id}, model=model.Notification, command_type="query") + latest_notification = Notifier.find_latest_notification(notifications) + + old_state = model.EvaluationType(latest_notification.level) if latest_notification is not None else model.EvaluationType.undefined + new_state = shipcall.evaluation + + # identify, whether the severity of the shipcall has increased to see, whether a notification is required + severity_increase = Notifier.check_higher_severity(old_state=old_state, new_state=new_state) + + # when the severity increases, set the 'evaluation_notifications_sent' argument to 0 (False) + if severity_increase: + ### UPDATE Shipcall ### + # prepare and create a query + evaluation_notifications_sent = 0 + schemaModel = {"id":shipcall.id, "evaluation_notifications_sent":evaluation_notifications_sent} # #TODO: should this require the 'modified' tag to be adapted? + query = SQLQuery.get_shipcall_put(schemaModel) + + # execute the PUT-Request + schemas = execute_sql_query_standalone(query=query, param=schemaModel, command_type="execute") + + ### Generate Notifications ### + # find all attached users of the shipcall (checks the history, then reads out the user ids and builds the users) + users = Notifier.get_users_via_history(shipcall_id=shipcall.id) + + # for each user, identify the notification_types, which must be generated. Finally, create those + # notifications with a POST-request + Notifier.create_notifications_for_user_list(shipcall, users) + return + + @staticmethod + def create_etaetd_string(eta, etd): # #TODO_rename: function name is improvable + eta = eta.strftime("%d.%m.%Y %H:%M") if eta is not None else None + etd = etd.strftime("%d.%m.%Y %H:%M") if etd is not None else None + + eta_etd = "" + + if eta is not None and etd is not None: + eta_etd = f"{eta} - {etd}" + + if eta is not None and etd is None: + eta_etd = f"{eta}" + + if etd is None and etd is not None: + eta_etd = f"{etd}" + return eta_etd + + @staticmethod + 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" : 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 + ship_name = ship.name + eta_etd = Notifier.create_etaetd_string(shipcall.eta, shipcall.etd) + eta_etd_type = eta_etd_type_dict[model.ShipcallType(shipcall.type)] + evaluation_message = shipcall.evaluation_message + return (ship_name, evaluation_message, eta_etd, eta_etd_type) + + @staticmethod + def shipcall_put_update_evaluation_notifications_sent_flag(shipcall): + # change the 'evaluation_notifications_sent' flag to 1 + evaluation_notifications_sent = 1 + 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") + return + + @staticmethod + def build_email_targets_validation_notification(users)->list[str]: + # readout the email address of all users + email_tgts = [user.user_email for user in users if user.user_email is not None] + + # additionally, always inform the BSMD + email_tgts.append("bremencalling@bsmd.de") # #TODO: for testing, use "bremencalling@bsmd.de". For live system, use "report@bsmd.de" + + # #TODO_development: overwrite the recipients. Only send to 'bremencalling@bsmd.de' until the testing phase has succeeded. + email_tgts = ["bremencalling@bsmd.de" for tgt in email_tgts] + + # avoid multi-mails, when (for some reason) multiple users share the same email address. + email_tgts = list(set(email_tgts)) + return email_tgts + + @staticmethod + 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 + + this 'naive' method creates a message and simply sends it to all users in a list of users. + Afterwards, the database will be updated, so the shipcall no longer requires a notification. + """ + # get a list of all recipients + email_tgts = Notifier.build_email_targets_validation_notification(users) + + # 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(shipcall) + + msg_multipart,msg_content = create_shipcall_evaluation_notification( + email_handler, ship_name, evaluation_message, eta_etd, eta_etd_type, content, files=files + ) + + # send the messages via smtlib's SSL functions + send_notification(email_handler, email_tgts, msg_multipart, pwd, debug=debug) + return + + @staticmethod + def check_user_is_subscribed_to_notification_type(user,notification_type): + """given a notification, one can check, whether the current user has subscribed to the respective notification_type. Returns a boolean""" + if int(notification_type) == int(model.NotificationType.email): + return user.notify_email + + elif int(notification_type) == int(model.NotificationType.push): + return user.notify_popup + + elif int(notification_type) == int(model.NotificationType.undefined): + pass + + ### placeholders: + #elif int(notification_type) == int(model.NotificationType.whatsapp): + #return user.notify_whatsapp + + #elif int(notification_type) == int(model.NotificationType.signal): + #return user.notify_signal + + 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()) -"""# build the list of evaluation times ('now', as isoformat)""" -#evaluation_times = [datetime.datetime.now().isoformat() for _i in range(len(evaluation_states_new))] \ No newline at end of file diff --git a/src/server/BreCal/resources/__init__.py b/src/server/BreCal/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/BreCal/resources/logo_bremen_calling.png b/src/server/BreCal/resources/logo_bremen_calling.png new file mode 100644 index 0000000..bb97bf5 Binary files /dev/null and b/src/server/BreCal/resources/logo_bremen_calling.png differ diff --git a/src/server/BreCal/resources/warning_notification_email_template.txt b/src/server/BreCal/resources/warning_notification_email_template.txt new file mode 100644 index 0000000..819c668 --- /dev/null +++ b/src/server/BreCal/resources/warning_notification_email_template.txt @@ -0,0 +1,159 @@ + + + + + + Simple Transactional Email + + + + + + + + + + + + \ No newline at end of file diff --git a/src/server/BreCal/services/email_handling.py b/src/server/BreCal/services/email_handling.py index ba21027..923085f 100644 --- a/src/server/BreCal/services/email_handling.py +++ b/src/server/BreCal/services/email_handling.py @@ -1,6 +1,8 @@ import os import typing +import datetime import smtplib +from socket import gaierror from getpass import getpass from email.message import EmailMessage import mimetypes @@ -42,7 +44,10 @@ class EmailHandler(): self.mail_port = mail_port self.mail_address = mail_address - self.server = smtplib.SMTP_SSL(self.mail_server, self.mail_port) # alternatively, use smtplib.SMTP + try: + self.server = smtplib.SMTP_SSL(self.mail_server, self.mail_port) # alternatively, use smtplib.SMTP + except gaierror: + raise Exception(f"'socket.gaierror' raised. This commonly happens, when there is no access to the server (e.g., by not having an internet connection)") def check_state(self): """check, whether the server login took place and is open.""" @@ -226,4 +231,118 @@ class EmailHandler(): time.sleep(delete_after_s_seconds) if os.path.exists(temp_filename): os.remove(temp_filename) - return \ No newline at end of file + return + +import typing +from email.mime.application import MIMEApplication +import mimetypes + +import os + +def find_warning_notification_email_template()->str: + """ + dynamically finds the 'default_email_template.txt' file within the module. + """ + # __file__ is BreCal/stubs/email_template.py + # parent of email_template.py is stubs + # parent of stubs is BreCal + brecal_root_folder = os.path.dirname(os.path.dirname(__file__)) # .../BreCal + resource_root_folder = os.path.join(brecal_root_folder, "resources") # .../BreCal/resources + html_filepath = os.path.join(resource_root_folder,"warning_notification_email_template.txt") # .../BreCal/resources/warning_notification_email_template.txt + assert os.path.exists(html_filepath), f"could not find default email template file at path: {html_filepath}" + return html_filepath + +def get_default_html_email()->str: + """ + dynamically finds the 'default_email_template.txt' file within the module. It opens the file and returns the content. + + __file__ returns to the file, where this function is stored (e.g., within BreCal.stubs.email_template) + using the dirname refers to the directory, where __file__ is stored. + finally, the 'default_email_template.txt' is stored within that folder + """ + html_filepath = find_warning_notification_email_template() + with open(html_filepath,"r", encoding="utf-8") as file: # encoding = "utf-8" allows for German Umlaute + content = file.read() + return content + +def find_bremen_calling_logo(): + """ + find the path towards the logo file (located at 'brecal\src\BreCalClient\Resources\logo_bremen_calling.png') + """ + # __file__ is services/email_handling.py + # parent of __file__ is services + # parent of services is BreCal + src_root_folder = os.path.dirname(os.path.dirname(__file__)) # .../BreCal + resource_root_folder = os.path.join(src_root_folder, "resources") + + path = os.path.join(resource_root_folder, "logo_bremen_calling.png") + assert os.path.exists(path), f"cannot find logo of bremen calling at path: {os.path.abspath(path)}" + return path + +def add_bremen_calling_logo(msg_multipart): + """ + The image is not attached automatically when it is embedded to the content. To circumvent this, + one commonly creates attachments, which are referred to in the email content. + + The content body refers to 'LogoBremenCalling', which the 'Content-ID' of the logo is assigned as. + """ + path = find_bremen_calling_logo() + + with open(path, 'rb') as file: + attachment = MIMEApplication(file.read(), _subtype=mimetypes.MimeTypes().guess_type(path), Name="bremen_calling.png") + + attachment.add_header('Content-Disposition','attachment',filename=str(os.path.basename(path))) + attachment.add_header('Content-ID', '') + msg_multipart.attach(attachment) + return msg_multipart + + +def create_shipcall_evaluation_notification(email_handler, ship_name:str, evaluation_message:str, eta_etd_str:str, eta_etd_type:str, content:str, files:typing.Optional[list[str]]): + """ + email_handler : EmailHandler. Contains meta-level information about the mail server and sender's Email. + + ship_name : str. Name of the referenced ship, so the user knows the context. + evaluation_message : str. Brief description of the current evaluation state + eta_etd_str : str. Readable format of a datetime.datetime object, which is either ETA, ETD or both. Informs the user about when the shipcall is due. + eta_etd_type : str. Reference to the time, whether it arrives/leaves/shifts. + + content : str (or filepath). Should refer to the template, which defines the content. This file contains HTML-structured text. + + files: (optional). List of file paths, which are included as attachments. + """ + subject = f"{ship_name} (vorauss. {eta_etd_type}: {eta_etd_str})" + + # create message_body + message_body = content # "Hello World." + evaluation_message_reformatted = evaluation_message.replace("\n", "
") + adaptive_content = f'
Betrifft: {ship_name} ({eta_etd_str})
{evaluation_message_reformatted}
' + message_body = message_body.replace("#ADAPTIVECONTENT", adaptive_content) + + msg = email_handler.create_email(subject=subject, message_body=message_body, subtype="html") + msg_multipart = email_handler.translate_mail_to_multipart(msg=msg) + + if files is not None: + for path in files: + assert os.path.exists(path), f"cannot find attachment at path: {path}" + email_handler.attach_file(path, msg=msg_multipart) + + # add the bremen calling logo, which is referred to in the email body + msg_multipart = add_bremen_calling_logo(msg_multipart) + return (msg_multipart,content) + +def send_notification(email_handler, email_tgts, msg, pwd, debug=False): + already_logged_in = email_handler.check_login() + if not already_logged_in: + email_handler.login(interactive=False, pwd=pwd) + + try: + assert email_handler.check_login() + if not debug: + email_handler.send_email(msg, email_tgts) + else: + print(f"(send_notification INFO): debugging state. Would have sent an Email to: {email_tgts}") + + finally: + if not already_logged_in: + email_handler.close() + return diff --git a/src/server/BreCal/services/schedule_routines.py b/src/server/BreCal/services/schedule_routines.py index 2e0be3e..5725594 100644 --- a/src/server/BreCal/services/schedule_routines.py +++ b/src/server/BreCal/services/schedule_routines.py @@ -51,7 +51,7 @@ def add_function_to_schedule__update_shipcalls(interval_in_minutes:int, options: schedule.every(interval_in_minutes).minutes.do(UpdateShipcalls, **kwargs_) return -def add_function_to_schedule__send_notifications(vr, interval_in_minutes:int=10): +def add_function_to_schedule__send_notifications(interval_in_minutes:int=15): schedule.every(interval_in_minutes).minutes.do(Notifier.send_notifications) return @@ -65,8 +65,8 @@ def setup_schedule(update_shipcalls_interval_in_minutes:int=60): # update the evaluation state in every recent shipcall add_function_to_schedule__update_shipcalls(update_shipcalls_interval_in_minutes) - # placeholder: create/send notifications - # add_function_to_schedule__send_notifications(...) + # create/send notifications + add_function_to_schedule__send_notifications(15) return diff --git a/src/server/BreCal/validators/validation_rule_functions.py b/src/server/BreCal/validators/validation_rule_functions.py index 24bbc62..693d253 100644 --- a/src/server/BreCal/validators/validation_rule_functions.py +++ b/src/server/BreCal/validators/validation_rule_functions.py @@ -929,6 +929,9 @@ class ValidationRuleFunctions(ValidationRuleBaseFunctions): query_time = times_agency.iloc[0].eta_berth # count the number of times, where a times entry is very close to the query time (uses an internal threshold, such as 15 minutes) + if all_times_agency is None: + all_times_agency = self.sql_handler.get_times_for_agency(non_null_column="eta_berth") + counts = self.sql_handler.count_synchronous_shipcall_times(query_time, all_df_times=all_times_agency) violation_state = counts > maximum_threshold @@ -952,6 +955,9 @@ class ValidationRuleFunctions(ValidationRuleBaseFunctions): times_agency = df_times.loc[df_times["participant_type"]==ParticipantType.AGENCY.value] query_time = times_agency.iloc[0].etd_berth + if all_times_agency is None: + all_times_agency = self.sql_handler.get_times_for_agency(non_null_column="etd_berth") + # count the number of times, where a times entry is very close to the query time (uses an internal threshold, such as 15 minutes) counts = self.sql_handler.count_synchronous_shipcall_times(query_time, all_df_times=all_times_agency) violation_state = counts > maximum_threshold diff --git a/src/server/BreCal/validators/validation_rules.py b/src/server/BreCal/validators/validation_rules.py index 03ffc1f..d813676 100644 --- a/src/server/BreCal/validators/validation_rules.py +++ b/src/server/BreCal/validators/validation_rules.py @@ -1,3 +1,4 @@ +import typing import copy import logging import re @@ -74,6 +75,9 @@ class ValidationRules(ValidationRuleFunctions): """apply 'evaluate_shipcall_from_df' to each individual shipcall in {shipcall_df}. Returns shipcall_df ('evaluation', 'evaluation_message', 'evaluation_time' and 'evaluation_notifications_sent' are updated)""" evaluation_states_old = [state_old for state_old in shipcall_df.loc[:,"evaluation"]] evaluation_states_old = [state_old if not pd.isna(state_old) else 0 for state_old in evaluation_states_old] + + evaluation_notifications_sent_old = [ens for ens in shipcall_df.loc[:,"evaluation_notifications_sent"]] + results = shipcall_df.apply(lambda x: self.evaluate_shipcall_from_df(x), axis=1).values # returns tuple (state, message) # unbundle individual results. evaluation_states becomes an integer, violation @@ -82,6 +86,15 @@ class ValidationRules(ValidationRuleFunctions): violations = [self.concise_evaluation_message_if_too_long(violation) for violation in violations] # build the list of evaluation times ('now', as isoformat) + evaluation_time = self.get_notification_times(evaluation_states_new) + + # build the list of 'evaluation_notifications_sent'. The value is 'False', when a notification should be created + evaluation_notifications_sent = self.get_notification_states(evaluation_states_old, evaluation_states_new, evaluation_notifications_sent_old) + + shipcall_df.loc[:,"evaluation"] = evaluation_states_new + shipcall_df.loc[:,"evaluation_message"] = violations + shipcall_df.loc[:,"evaluation_time"] = evaluation_time + shipcall_df.loc[:,"evaluation_notifications_sent"] = evaluation_notifications_sent #evaluation_time = self.get_notification_times(evaluation_states_new) # build the list of 'evaluation_notifications_sent'. The value is 'False', when a notification should be created @@ -112,16 +125,24 @@ class ValidationRules(ValidationRuleFunctions): """this function should apply the ValidationRules to the respective .shipcall, in regards to .times""" return (StatusFlags.GREEN, False) # (state:str, should_notify:bool) - def determine_notification_state(self, state_old, state_new): + def determine_notification_state(self, state_old, state_new, evaluation_notifications_sent)->typing.Optional[bool]: """ this method determines state changes in the notification state. When the state increases, a user is notified about it. state order: (NONE = GREEN < YELLOW < RED) + + If a notification shall be sent, this method returns False. If no notification shall be sent, this method returns None or the prior state. + The method *never* returns True, as it shall only be called on novel shipcalls. + + args: + evaluation_notifications_sent: the PREVIOUS state (if any) of this boolean. When no notification is required, the prior bool is used (e.g., None, False, True). """ + previous_state = evaluation_notifications_sent + # identify a state increase should_notify = self.identify_notification_state_change(state_old=state_old, state_new=state_new) # when a state increases, a notification must be sent. Thereby, the field should be set to False ({evaluation_notifications_sent}) - evaluation_notifications_sent = False if bool(should_notify) else None + evaluation_notifications_sent = False if bool(should_notify) else previous_state return evaluation_notifications_sent def identify_notification_state_change(self, state_old, state_new) -> bool: @@ -147,13 +168,13 @@ class ValidationRules(ValidationRuleFunctions): return int(state_new) > int(state_old) def get_notification_times(self, evaluation_states_new)->list[datetime.datetime]: - """# build the list of evaluation times ('now', as isoformat)""" - evaluation_times = [datetime.datetime.now().isoformat() for _i in range(len(evaluation_states_new))] + """# build the list of evaluation times ('now'-datetime)""" + evaluation_times = [datetime.datetime.now() for _i in range(len(evaluation_states_new))] # .isoformat() return evaluation_times - def get_notification_states(self, evaluation_states_old, evaluation_states_new)->list[bool]: - """# build the list of 'evaluation_notifications_sent'. The value is 'False', when a notification should be created""" - evaluation_notifications_sent = [self.determine_notification_state(state_old=int(state_old), state_new=int(state_new)) for state_old, state_new in zip(evaluation_states_old, evaluation_states_new)] + def get_notification_states(self, evaluation_states_old, evaluation_states_new, evaluation_notifications_sent_old)->list[typing.Optional[bool]]: + """# build the list of 'evaluation_notifications_sent'. The value is 'False', when a notification should be created and None, when not""" + evaluation_notifications_sent = [self.determine_notification_state(state_old=int(state_old), state_new=int(state_new), evaluation_notifications_sent=ens) for state_old, state_new, ens in zip(evaluation_states_old, evaluation_states_new, evaluation_notifications_sent_old)] return evaluation_notifications_sent diff --git a/src/server/tests/notifications/__init__.py b/src/server/tests/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/tests/notifications/test_accounts.py b/src/server/tests/notifications/test_accounts.py new file mode 100644 index 0000000..8cf21fe --- /dev/null +++ b/src/server/tests/notifications/test_accounts.py @@ -0,0 +1,21 @@ +import pytest +from BreCal.notifications.accounts import mail_server, mail_port, mail_address, mail_pwd + + +def test_mail_server(): + assert isinstance(mail_server, str) + assert not "@" in mail_server + return + +def test_mail_port(): + assert isinstance(mail_port, int) + return + +def test_mail_address(): + assert isinstance(mail_address, str) + assert "@" in mail_address + return + +def test_mail_pwd(): + assert isinstance(mail_pwd, bytes), f"must be a bytes-encoded password to protect the account" + return diff --git a/src/server/tests/resources/__init__.py b/src/server/tests/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server/tests/resources/test_find_defaults.py b/src/server/tests/resources/test_find_defaults.py new file mode 100644 index 0000000..214f0d7 --- /dev/null +++ b/src/server/tests/resources/test_find_defaults.py @@ -0,0 +1,14 @@ +import pytest +import os + +def test_find_bremen_calling_logo(): + from BreCal.services.email_handling import find_bremen_calling_logo + path = find_bremen_calling_logo() + assert os.path.exists(path), f"cannot find the bremen calling logo file, which is needed for notifications (e.g., Email). Searched at path: \n\t{path}" + return + +def test_find_warning_notification_email_template(): + from BreCal.services.email_handling import find_warning_notification_email_template + path = find_warning_notification_email_template() + assert os.path.exists(path), f"cannot find the required email template, which is needed for warning notifications. Searched at path: \n\t{path}" + return