From 863d2656691005fdd498262d9aabd0210b7e0839 Mon Sep 17 00:00:00 2001 From: scopesorting Date: Fri, 19 Jan 2024 14:22:54 +0100 Subject: [PATCH] implementing notifications, working on input validation. rebase. --- src/server/BreCal/database/enums.py | 9 ++ src/server/BreCal/notifications/__init__.py | 0 .../notifications/notification_functions.py | 115 ++++++++++++++++++ src/server/BreCal/schemas/model.py | 9 ++ .../BreCal/services/schedule_routines.py | 5 + src/server/BreCal/stubs/shipcall.py | 4 +- .../BreCal/validators/validation_rules.py | 98 +++++++-------- 7 files changed, 182 insertions(+), 58 deletions(-) create mode 100644 src/server/BreCal/notifications/__init__.py create mode 100644 src/server/BreCal/notifications/notification_functions.py diff --git a/src/server/BreCal/database/enums.py b/src/server/BreCal/database/enums.py index fe6b37f..7f8fc3d 100644 --- a/src/server/BreCal/database/enums.py +++ b/src/server/BreCal/database/enums.py @@ -26,6 +26,8 @@ class ParticipantwiseTimeDelta(): TUG = 960.0 # 16 h * 60 min/h = 960 min TERMINAL = 960.0 # 16 h * 60 min/h = 960 min + NOTIFICATION = 10.0 # after n minutes, an evaluation may rise a notification + class StatusFlags(Enum): """ these enumerators ensure that each traffic light validation rule state corresponds to a value, which will be used in the ValidationRules object to identify @@ -39,3 +41,10 @@ class StatusFlags(Enum): class PierSide(IntEnum): PORTSIDE = 0 # Port/Backbord STARBOARD_SIDE = 1 # Starboard / Steuerbord + +class NotificationType(IntFlag): + """determines the method by which a notification is distributed to users. Flagging allows selecting multiple notification types.""" + UNDEFINED = 0 + EMAIL = 1 + POPUP = 2 + MESSENGER = 4 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/notification_functions.py b/src/server/BreCal/notifications/notification_functions.py new file mode 100644 index 0000000..9bbc373 --- /dev/null +++ b/src/server/BreCal/notifications/notification_functions.py @@ -0,0 +1,115 @@ +import datetime +import pandas as pd +from BreCal.schemas.model import Notification +from BreCal.database.enums import NotificationType, ParticipantType, ShipcallType, StatusFlags + +def create_notification(id, times_id, message, level, notification_type:NotificationType, created=None, modified=None): + 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 + + + + + +#### 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 + + +class Notifier(): + """An object that helps with the logic of selecting eligible shipcalls to create the correct notifications for the respective users.""" + def __init__(self)->None: + pass + + def determine_notification_state(self, state_old, state_new): + """ + 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) + """ + # 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 + return evaluation_notifications_sent + + def identify_notification_state_change(self, state_old, state_new) -> 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 + """ + # state_old is always considered at least 'Green' (1) + if state_old is None: + state_old = StatusFlags.NONE.value + state_old = max(int(state_old), StatusFlags.GREEN.value) + 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))] + 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.notifier.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)] + return evaluation_notifications_sent + diff --git a/src/server/BreCal/schemas/model.py b/src/server/BreCal/schemas/model.py index f6957cc..c429777 100644 --- a/src/server/BreCal/schemas/model.py +++ b/src/server/BreCal/schemas/model.py @@ -184,11 +184,18 @@ class ShipcallSchema(Schema): anchored = fields.Bool(Required = False, allow_none=True) moored_lock = fields.Bool(Required = False, allow_none=True) canceled = fields.Bool(Required = False, allow_none=True) +<<<<<<< HEAD evaluation = fields.Enum(EvaluationType, required=False, allow_none=True, default=EvaluationType.undefined) evaluation_message = fields.Str(allow_none=True, metadata={'Required':False}) # Solving: RemovedInMarshmallow4Warning: Passing field metadata as keyword arguments is deprecated. Use the explicit `metadata=...` argument instead. Additional metadata: {'Required': False} evaluation_time = fields.DateTime(Required = False, allow_none=True) evaluation_notifications_sent = fields.Bool(Required = False, allow_none=True) time_ref_point = fields.Int(Required = False, allow_none=True) +======= + evaluation = fields.Integer(Required = False, allow_none=True) + evaluation_message = fields.String(allow_none=True, metadata={'Required':False}) # Solving: RemovedInMarshmallow4Warning: Passing field metadata as keyword arguments is deprecated. Use the explicit `metadata=...` argument instead. Additional metadata: {'Required': False} + evaluation_time = fields.DateTime(Required = False, allow_none=True) + evaluation_notifications_sent = fields.Bool(Required = False, allow_none=True) +>>>>>>> a5284a4 (implementing notifications, working on input validation) participants = fields.List(fields.Nested(ParticipantAssignmentSchema)) created = fields.DateTime(Required = False, allow_none=True) modified = fields.DateTime(Required = False, allow_none=True) @@ -251,6 +258,8 @@ class Shipcall: created: datetime modified: datetime participants: List[Participant_Assignment] = field(default_factory=list) + evaluation_time : datetime = None + evaluation_notifications_sent : bool = None def to_json(self): return { diff --git a/src/server/BreCal/services/schedule_routines.py b/src/server/BreCal/services/schedule_routines.py index 542b213..848364d 100644 --- a/src/server/BreCal/services/schedule_routines.py +++ b/src/server/BreCal/services/schedule_routines.py @@ -57,6 +57,11 @@ 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): + schedule.every(interval_in_minutes).minutes.do(vr.notifier.send_notifications) + return + + def setup_schedule(update_shipcalls_interval_in_minutes:int=60): logging.getLogger('schedule').setLevel(logging.INFO); # set the logging level of the schedule module to INFO diff --git a/src/server/BreCal/stubs/shipcall.py b/src/server/BreCal/stubs/shipcall.py index e86d379..2e4e154 100644 --- a/src/server/BreCal/stubs/shipcall.py +++ b/src/server/BreCal/stubs/shipcall.py @@ -37,12 +37,12 @@ def get_shipcall_simple(): recommended_tugs = 2 # assert 0pd.DataFrame: - """apply 'evaluate_shipcall_from_df' to each individual shipcall in {shipcall_df}. Returns shipcall_df ('evaluation' and 'evaluation_message' are updated)""" - results = shipcall_df.apply(lambda x: self.evaluate_shipcall_from_df(x), axis=1).values + """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"]] + results = shipcall_df.apply(lambda x: self.evaluate_shipcall_from_df(x), axis=1).values # returns tuple (state, message) - # unbundle individual results. evaluation_state becomes an integer, violation - evaluation_state = [StatusFlags(res[0]).value for res in results] + # unbundle individual results. evaluation_states becomes an integer, violation + evaluation_states_new = [StatusFlags(res[0]).value for res in results] violations = [",\r\n".join(res[1]) if len(res[1])>0 else None for res in results] violations = [self.concise_evaluation_message_if_too_long(violation) for violation in violations] - shipcall_df.loc[:,"evaluation"] = evaluation_state + # build the list of evaluation times ('now', as isoformat) + evaluation_times = self.notifier.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) + + shipcall_df.loc[:,"evaluation"] = evaluation_states_new shipcall_df.loc[:,"evaluation_message"] = violations + shipcall_df.loc[:,"evaluation_times"] = evaluation_times + shipcall_df.loc[:,"evaluation_notifications_sent"] = evaluation_notifications_sent return shipcall_df def concise_evaluation_message_if_too_long(self, violation): @@ -100,53 +108,31 @@ class ValidationRules(ValidationRuleFunctions): # e.g.: Evaluation message too long. Violated Rules: ['Rule #0001C', 'Rule #0001H', 'Rule #0001F', 'Rule #0001G', 'Rule #0001L', 'Rule #0001M', 'Rule #0001J', 'Rule #0001K'] violation = f"Evaluation message too long. Violated Rules: {concise}" return violation - - def determine_validation_state(self) -> str: - """ - this method determines the validation state of a shipcall. The state is either ['green', 'yellow', 'red'] and signals, - whether an entry causes issues within the workflow of users. - - returns: validation_state_new (str) - """ - (validation_state_new, description) = self.undefined_method() - # should there also be notifications for critical validation states? In principle, the traffic light itself provides that notification. - self.validation_state = validation_state_new - return validation_state_new - - def determine_notification_state(self) -> (str, bool): - """ - this method determines state changes in the notification state. When the state is changed to yellow or red, - a user is notified about it. The only exception for this rule is when the state was yellow or red before, - as the user has then already been notified. - - returns: notification_state_new (str), should_notify (bool) - """ - (state_new, description) = self.undefined_method() # determine the successor - should_notify = self.identify_notification_state_change(state_new) - self.notification_state = state_new # overwrite the predecessor - return state_new, should_notify - - def identify_notification_state_change(self, state_new) -> bool: - """ - determines, whether the observed state change should trigger a notification. - internally, this function maps a color string 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 - """ - # state_old is always considered at least 'Green' (1) - state_old = max(copy.copy(self.notification_state) if "notification_state" in list(self.__dict__.keys()) else StatusFlags.NONE, StatusFlags.GREEN.value) - return state_new.value > state_old.value - + def undefined_method(self) -> str: """this function should apply the ValidationRules to the respective .shipcall, in regards to .times""" - # #TODO_traffic_state return (StatusFlags.GREEN, False) # (state:str, should_notify:bool) + + +def inspect_shipcall_evaluation(vr, sql_handler, shipcall_id): + """ + # debug only! + + a simple debugging function, which serves in inspecting an evaluation function for a single shipcall id. It returns the result and all related data. + returns: result, shipcall_df (filtered by shipcall id), shipcall, spm (shipcall participant map, filtered by shipcall id), times_df (filtered by shipcall id) + """ + shipcall_df = sql_handler.df_dict.get("shipcall").loc[shipcall_id:shipcall_id,:] + + shipcall = Shipcall(**{**{"id":shipcall_id},**sql_handler.df_dict.get("shipcall").loc[shipcall_id].to_dict()}) + result = vr.evaluate(shipcall=shipcall) + notification_state = vr.identify_notification_state_change(state_old=int(shipcall.evaluation), state_new=int(result[0])) + print(f"Previous state: {int(shipcall.evaluation)}, New State: {result[0]}, Notification State: {notification_state}") + + times_df = sql_handler.df_dict.get("times") + times_df = times_df.loc[times_df["shipcall_id"]==shipcall_id] + + + spm = sql_handler.df_dict["shipcall_participant_map"] + spm = spm.loc[spm["shipcall_id"]==shipcall_id] + + return result, shipcall_df, shipcall, spm, times_df