|
+ Ahoi, +ein Schiffsanlauf benötigt Ihre Aufmerksamkeit. Bei der Prüfung der Daten haben wir wahrgenommen, dass ein Problem aufgetreten sein könnte. +
#ADAPTIVECONTENT +
|
+
diff --git a/src/server/BreCal/api/user.py b/src/server/BreCal/api/user.py index 2c3c1a0..14a03f8 100644 --- a/src/server/BreCal/api/user.py +++ b/src/server/BreCal/api/user.py @@ -11,6 +11,10 @@ bp = Blueprint('user', __name__) @bp.route('/user', methods=['put']) @auth_guard() # no restriction by role def PutUser(): + # #TODO: user validation should be extended by the notifications. When someone wants to set + # notify_email = 1, the email must be either present or part of the loadedModel + # notify_whatsapp = 1, there must be a phone number (same for notify_signal) + # notify_push = 1, there must be a phone number (#TODO_determine ... or an app-id? Unclear still) try: content = request.get_json(force=True) 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 9888ada..244c62f 100644 --- a/src/server/BreCal/database/sql_queries.py +++ b/src/server/BreCal/database/sql_queries.py @@ -213,7 +213,7 @@ class SQLQuery(): @staticmethod def get_history()->str: - query = "SELECT id, participant_id, shipcall_id, timestamp, eta, type, operation FROM history WHERE shipcall_id = ?shipcallid?" + query = "SELECT id, participant_id, shipcall_id, user_id, timestamp, eta, type, operation FROM history WHERE shipcall_id = ?shipcallid?" return query @staticmethod @@ -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: @@ -412,14 +433,14 @@ class SQLQuery(): @staticmethod def get_notification_post()->str: - raise NotImplementedError() + raise NotImplementedError("skeleton") # #TODO: this query is wrong and just a proxy for a POST request query = "INSERT INTO shipcall_participant_map (shipcall_id, participant_id, type) VALUES (?shipcall_id?, ?participant_id?, ?type?)" return query @staticmethod def get_shipcall_put_notification_state()->str: - raise NotImplementedError() + raise NotImplementedError("skeleton") # #TODO: use evaluation_notifications_sent here and consider only the shipcall_id # #TODO: query query = ... diff --git a/src/server/BreCal/impl/history.py b/src/server/BreCal/impl/history.py index 53f159c..a2e1cb0 100644 --- a/src/server/BreCal/impl/history.py +++ b/src/server/BreCal/impl/history.py @@ -23,7 +23,7 @@ def GetHistory(options): if "shipcall_id" in options and options["shipcall_id"]: # query = SQLQuery.get_history() # data = commands.query(query, model=History.from_query_row, param={"shipcallid" : options["shipcall_id"]}) - data = commands.query("SELECT id, participant_id, shipcall_id, timestamp, eta, type, operation FROM history WHERE shipcall_id = ?shipcallid?", + data = commands.query("SELECT id, participant_id, shipcall_id, user_id, timestamp, eta, type, operation FROM history WHERE shipcall_id = ?shipcallid?", model=History.from_query_row, param={"shipcallid" : options["shipcall_id"]}) diff --git a/src/server/BreCal/notifications/__init__.py b/src/server/BreCal/notifications/__init__.py new file mode 100644 index 0000000..e69de29 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/notification_content_email.py b/src/server/BreCal/notifications/notification_content_email.py new file mode 100644 index 0000000..ce8d653 --- /dev/null +++ b/src/server/BreCal/notifications/notification_content_email.py @@ -0,0 +1,53 @@ +import pandas as pd +from server.BreCal.database.enums import ParticipantType, ShipcallType, StatusFlags + + + +#### Verbosity Functions #### +def get_default_header()->str: + # HEADER (greeting and default message) + header = "Dear Sir or Madam\n\nThank you for participating in the project 'Bremen Calling'. During analysis, our software has identified an event, which may be worth a second look. Here is the summary. \n\n" + return header + + +def get_default_footer()->str: + # FOOTER (signature) + footer = "\n\nWe would kindly ask you to have a look at the shipcall and verify, if any action is required from your side. \n\nKind regards\nThe 'Bremen Calling' Team" + return footer + + +def get_agency_name(sql_handler, times_df): + times_agency = times_df.loc[times_df["participant_type"]==ParticipantType.AGENCY.value,"participant_id"] + if len(times_agency)==0: + agency_name = "" + else: + agency_participant_id = times_agency.iloc[0] + agency_name = sql_handler.df_dict.get("participant").loc[agency_participant_id,"name"] + return agency_name + + +def get_ship_name(sql_handler, shipcall): + ship = sql_handler.df_dict.get("ship").loc[shipcall.ship_id] + ship_name = ship.loc["name"] # when calling ship.name, the ID is returned (pandas syntax) + return ship_name + + +def create_notification_body(sql_handler, times_df, shipcall, result)->str: + # #TODO: add 'Link zum Anlauf' + # URL: https://trello.com/c/qenZyJxR/75-als-bsmd-m%C3%B6chte-ich-%C3%BCber-gelbe-und-rote-ampeln-informiert-werden-um-die-systembeteiligung-zu-st%C3%A4rken + header = get_default_header() + footer = get_default_footer() + + agency_name = get_agency_name(sql_handler, times_df) + ship_name = get_ship_name(sql_handler, shipcall) + + verbosity_introduction = f"Respective Shipcall:\n" + traffic_state_verbosity = f"\tTraffic Light State: {StatusFlags(result[0]).name}\n" + ship_name_verbosity = f"\tShip: {ship_name} (the ship is {ShipcallType(shipcall.type).name.lower()})\n" + agency_name_verbosity = f"\tResponsible Agency: {agency_name}\n" + eta_verbosity = f"\tEstimated Arrival Time: {shipcall.eta.isoformat()}\n" if not pd.isna(shipcall.eta) else "" + etd_verbosity = f"\tEstimated Departure Time: {shipcall.etd.isoformat()}\n" if not pd.isna(shipcall.etd) else "" + error_verbosity = f"\nError Description:\n\t" + "\n\t".join(result[1]) + + message_body = "".join([header, verbosity_introduction, traffic_state_verbosity, ship_name_verbosity, agency_name_verbosity, eta_verbosity, etd_verbosity, error_verbosity, footer]) + return message_body \ No newline at end of file diff --git a/src/server/BreCal/notifications/notification_functions.py b/src/server/BreCal/notifications/notification_functions.py new file mode 100644 index 0000000..e61bfcd --- /dev/null +++ b/src/server/BreCal/notifications/notification_functions.py @@ -0,0 +1,13 @@ +import datetime +from BreCal.schemas.model import Notification +from BreCal.database.enums import NotificationType, StatusFlags + +def create_notification(id, times_id, message, level, notification_type:NotificationType, created=None, modified=None): + # #TODO_determine: determine, whether this function is still in active use. The data-model seems outdated. + created = (datetime.datetime.now()).isoformat() or created + + notification = Notification( + id=id, + times_id=times_id, acknowledged=False, level=level, type=notification_type.value, message=message, created=created, modified=modified + ) + return notification diff --git a/src/server/BreCal/notifications/notifier.py b/src/server/BreCal/notifications/notifier.py new file mode 100644 index 0000000..9968fc6 --- /dev/null +++ b/src/server/BreCal/notifications/notifier.py @@ -0,0 +1,535 @@ +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(): + """ + This class provides quick access to different notification functions. + Each method is callable without initializing the Notifier object. + + Example: + Notifier.send_notifications(*args) + + The Notifier has three main methods. + Notifier.send_notifications() --- can be called routinely. Identifies all candidates and notifies the users + Notifier.send_notification(shipcall) --- applies filters to identify, whether a notification is desired. If so, notifies the users + Notifer.create(user) --- 'naive' method, which simply creates a message and sends it to the user's preferred choice + + # #TODO_determine: it makes sense to go one step finer. .create could produce messages and recipients, whereas .publish may then issue those and .document may update the SQL dataset + ## naming idea: Notifier.send_notifications, Notifier.send_notification, Notifier.send (which may contain .create, .publish, .document) + """ + def __init__(self) -> None: + pass + + @staticmethod + def send_notifications(is_test:bool=False) -> None: + """ + This method is used in BreCal.services.schedule_routines and will issue notifications, once they are due. + It is purposely defined in a way, where no external dependencies or arguments are required. The only exception is the + 'is_test' boolean, as it prevents the notifications from being *actually* sent as part of the pytests. + + Steps: + - get all shipcalls + - filter: consider only those, which are not yet sent (uses shipcall.evaluation_notifications_sent) + - iterate over each remaining shipcall and apply .send_notification + - those which are unsent, shall be sent by the respective type + """ + # 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. + + 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(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(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]) + + # 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) + + if len(eligible_users)>0: + Notifier.create_and_send_notification_mapper(notification_type, shipcall, eligible_users, email_handler, mail_pwd, debug=debug) + + 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(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 + 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: + - identify a list of notification_types, which shall be issued (based on the user's 'notify_*' settings) + - create messages based on the respective NotificationType, which the user has enabled + - send the messages + - update the shipcall dataset ('evaluation_notifications_sent') + + args: + update_database: whether to update the MySQL database by posting the notification. + """ + assert user.id is not None + assert shipcall_id is not None + assert old_state is not None + assert new_state is not None + + # get Shipcall by shipcall_id + shipcall = Notifier.get_shipcall(shipcall_id=shipcall_id) + + """ + ## TODO: this might simply be removed due to incorrect concept + ## could also relocate this to the generation function, which identifies the notifications to be created + ## should be unnecessary due to shipcall.evaluation_notifications_sent + + # a) filter existing notifictions and consider only the dataset, where type (notification_type) and level (new_state) are suitable + notification_exists = Notifier.check_notification_type_and_level_exists(shipcall_id=shipcall_id, notification_type=notification_type, level=new_state, existing_notifications=existing_notifications) + if notification_exists: + return None + """ + + + # 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: + # generate message based on the notification type + message = Notifier.generate_notification_message_by_type(notification_type, evaluation_message=shipcall.evaluation_message, user=user) + + # send the message + success_state = Notifier.send_notification_by_type(notification_type, message) + successes[notification_type] = success_state + + notification = ... + return notification + + @staticmethod + def find_latest_notification(notifications:list[model.Notification])->typing.Optional[model.Notification]: + """given a list of notification objects, this method returns the object, where the .created field corresponds to the *latest* notification object""" + latest_notification = sorted(notifications, key=lambda notification: notification.created, reverse=False)[-1] if len(notifications)>0 else None + return latest_notification + + @staticmethod + 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") + 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 + def get_user(user_id:int)->model.User: + """Given a user_id, this method executes an SQL query to return a User""" + user = execute_sql_query_standalone(query=SQLQuery.get_user_by_id(), param={"id" : user_id}, model=model.User, command_type="single") + return user + + @staticmethod + def get_shipcall(shipcall_id:int)->model.Shipcall: + """Given a shipcall_id, this method executes an SQL query to return a Shipcall""" + shipcall = execute_sql_query_standalone(query=SQLQuery.get_shipcall_by_id(), param={"id" : shipcall_id}, model=model.Shipcall.from_query_row, command_type="single") + return shipcall + + @staticmethod + def get_existing_notifications(shipcall_id:int)->list[model.Notification]: + existing_notifications = execute_sql_query_standalone(query=SQLQuery.get_notifications(), param={"scid" : shipcall_id}, model=model.Notification, command_type="query") + return existing_notifications + + @staticmethod + def build_notification_type_list(user:model.User)->list[model.NotificationType]: + """ + based on a User, this method generates a list of notification types. These can be used as instructions to + generate the respective Notification datasets. + """ + notification_type_list = [] + + if user.notify_email: + notification_type_list.append(model.NotificationType.email) + + if user.notify_popup: + notification_type_list.append(model.NotificationType.push) + + if user.notify_whatsapp: + # currently not defined as a data model. Must be included / changed, once the data model of NotificationType is updated + notification_type_list.append(model.NotificationType.undefined) + + if user.notify_signal: + # currently not defined as a data model. Must be included / changed, once the data model of NotificationType is updated + notification_type_list.append(model.NotificationType.undefined) + return notification_type_list + + @staticmethod + def check_notification_type_and_level_exists(shipcall_id:int, notification_type:model.NotificationType, level:model.EvaluationType, existing_notifications:list[model.Notification])->bool: + """This method checks, whether one of the Notification elements in the provided list is a perfect match to the arguments shipcall_id, notification_type and level""" + # #TODO_determine: should a notification be *skipped*, when there already is a dataset with + # identical level and type? ---> currently enabled. + + # check, if any of the existing notifications is a perfect match for notification type & level & shipcall + matches = [note for note in existing_notifications if (int(level)==int(note.level)) and (int(note.type)==int(notification_type)) and (shipcall_id==note.shipcall_id)] + + # bool: whether there is a perfect match + exists = len(matches)>0 + raise Exception("deprecated") + return exists + + @staticmethod + def check_higher_severity(old_state:model.EvaluationType, new_state:model.EvaluationType)->bool: + """ + determines, whether the observed state change should trigger a notification. + internally, this function maps StatusFlags to an integer and determines, if the successor state is more severe than the predecessor. + + state changes trigger a notification in the following cases: + green -> yellow + green -> red + yellow -> red + + (none -> yellow) or (none -> red) + due to the values in the enumeration objects, the states are mapped to provide this function. + green=1, yellow=2, red=3, none=1. Hence, critical changes can be observed by simply checking with "greater than". + + returns bool, whether a notification should be triggered + """ + # undefined previous state: .undefined (0) + if old_state is None: + old_state = model.EvaluationType.undefined + + # old_state is always considered at least .green (1) (hence, .undefined becomes .green) + old_state = max(int(old_state), model.EvaluationType.green) + + # the IntEnum values are correctly sequenced. .red > .yellow > .green > .undefined + # as .undefined becomes .green, an old_state is always *at least* green. + severity_grew = int(new_state) > int(old_state) + return severity_grew + + @staticmethod + def generate_notification_message_by_type(notification_type:model.NotificationType, evaluation_message:str, user:model.User): + assert isinstance(user, model.User) + + if int(notification_type) == int(model.NotificationType.undefined): + raise NotImplementedError("skeleton") + elif int(notification_type) == int(model.NotificationType.email): + raise NotImplementedError("skeleton") + elif int(notification_type) == int(model.NotificationType.push): + raise NotImplementedError("skeleton") + #elif int(notification_type) == int(model.NotificationType.whatsapp): + #raise NotImplementedError("skeleton") + #elif int(notification_type) == int(model.NotificationType.signal): + #raise NotImplementedError("skeleton") + elif int(notification_type) == int(model.NotificationType.undefined): + raise NotImplementedError("skeleton") + 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()) + + 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 @@ + + +
+ + +| + |
+
+
+
+
+
+
|
+ + |