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_queries.py b/src/server/BreCal/database/sql_queries.py index 51a3dea..3f84d75 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 @@ -402,14 +402,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/notification_functions.py b/src/server/BreCal/notifications/notification_functions.py index 78bd6bd..c1c2e45 100644 --- a/src/server/BreCal/notifications/notification_functions.py +++ b/src/server/BreCal/notifications/notification_functions.py @@ -48,6 +48,8 @@ class NotifierFunctions(): returns bool, whether a notification should be triggered """ + # #TODO_delete: has been refactored to Notifier.check_higher_severity(old_state:model.EvaluationType, new_state:model.EvaluationType) + # state_old is always considered at least 'Green' (1) if state_old is None: state_old = StatusFlags.NONE.value @@ -65,6 +67,6 @@ class NotifierFunctions(): return evaluation_notifications_sent -class Notifier(NotifierFunctions): +class LegacyNotifier(NotifierFunctions): def __init__(self): super().__init__() diff --git a/src/server/BreCal/notifications/notifier.py b/src/server/BreCal/notifications/notifier.py new file mode 100644 index 0000000..dc74b91 --- /dev/null +++ b/src/server/BreCal/notifications/notifier.py @@ -0,0 +1,313 @@ +import typing +from BreCal.database.sql_handler import execute_sql_query_standalone +from BreCal.database.sql_queries import SQLQuery +from BreCal.schemas import model + +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 + """ + raise NotImplementedError("skeleton") + + # get all shipcalls + all_shipcalls = NotImplementedError + + 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) + + # #TODO: get all notifications + # #TODO: get matching shipcall (based on shipcall_id) + + # #TODO: filter: consider only those, which are not yet sent + + # identify necessity + # #TODO: get the 'evaluation_notifications_sent' field from all shipcalls (based on shipcall_id) + # if not -> return + # USE shipcall.evaluation_notifications_sent + + # #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 + + # #TODO: update the shipcall dataset ('evaluation_notifications_sent') -- Note: consider the is_test argument + + # #TODO_clarify: how to handle the 'evaluation_notifications_sent', when there is no recipient? + return + + @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. + + 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 + + @staticmethod + def create(shipcall_id, old_state, new_state, user, update_database:bool=False)->typing.Optional[model.Notification]: + """ + 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) + 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 + + raise NotImplementedError("skeleton") + 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") + user_ids = [ + history.user_id + for history in histories + ] + users = [Notifier.get_user(user_id) for user_id in user_ids] + 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 + + + +"""# 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/schemas/model.py b/src/server/BreCal/schemas/model.py index 92232e2..395905b 100644 --- a/src/server/BreCal/schemas/model.py +++ b/src/server/BreCal/schemas/model.py @@ -85,10 +85,11 @@ class ShipcallType(IntEnum): @dataclass class History: - def __init__(self, id, participant_id, shipcall_id, timestamp, eta, type, operation): + def __init__(self, id, participant_id, shipcall_id, user_id, timestamp, eta, type, operation): self.id = id self.participant_id = participant_id self.shipcall_id = shipcall_id + self.user_id = user_id self.timestamp = timestamp self.eta = eta self.type = type @@ -98,6 +99,7 @@ class History: id: int participant_id: int shipcall_id: int + user_id: int timestamp: datetime eta: datetime type: ObjectType @@ -108,6 +110,7 @@ class History: "id": self.id, "participant_id": self.participant_id, "shipcall_id": self.shipcall_id, + "user_id": self.user_id, "timestamp": self.timestamp.isoformat() if self.timestamp else "", "eta": self.eta.isoformat() if self.eta else "", "type": self.type.name, @@ -115,8 +118,8 @@ class History: } @classmethod - def from_query_row(self, id, participant_id, shipcall_id, timestamp, eta, type, operation): - return self(id, participant_id, shipcall_id, timestamp, eta, ObjectType(type), OperationType(operation)) + def from_query_row(self, id, participant_id, shipcall_id, user_id, timestamp, eta, type, operation): + return self(id, participant_id, shipcall_id, user_id, timestamp, eta, ObjectType(type), OperationType(operation)) class Error(Schema): message = fields.String(metadata={'required':True}) @@ -135,7 +138,7 @@ class Notification: """ id: int shipcall_id: int # 'shipcall record that caused the notification' - level: int # 'severity of the notification' + level: int # 'severity of the notification'. #TODO_determine: Should this be identical to EvaluationType? type: NotificationType # 'type of the notification' message: str # 'individual message' created: datetime diff --git a/src/server/BreCal/services/schedule_routines.py b/src/server/BreCal/services/schedule_routines.py index a36a1fc..2e0be3e 100644 --- a/src/server/BreCal/services/schedule_routines.py +++ b/src/server/BreCal/services/schedule_routines.py @@ -4,6 +4,7 @@ from BreCal.schemas import model from BreCal.local_db import getPoolConnection from BreCal.database.update_database import evaluate_shipcall_state from BreCal.database.sql_queries import create_sql_query_shipcall_get +from BreCal.notifications.notifier import Notifier import threading import schedule @@ -51,7 +52,7 @@ def add_function_to_schedule__update_shipcalls(interval_in_minutes:int, options: 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) + schedule.every(interval_in_minutes).minutes.do(Notifier.send_notifications) return diff --git a/src/server/BreCal/stubs/notification.py b/src/server/BreCal/stubs/notification.py index 8971a76..b1d42c0 100644 --- a/src/server/BreCal/stubs/notification.py +++ b/src/server/BreCal/stubs/notification.py @@ -1,21 +1,21 @@ import datetime from BreCal.stubs import generate_uuid1_int -from BreCal.schemas.model import Notification +from BreCal.schemas.model import Notification, NotificationType def get_notification_simple(): """creates a default notification, where 'created' is now, and modified is now+10 seconds""" notification_id = generate_uuid1_int() # uid? - times_id = generate_uuid1_int() # uid? - level = 10 - type = 0 + shipcall_id = 85 + level = 2 + type = NotificationType.email message = "hello world" created = datetime.datetime.now() modified = created+datetime.timedelta(seconds=10) notification = Notification( notification_id, - times_id, + shipcall_id, level, type, message, diff --git a/src/server/BreCal/validators/validation_rules.py b/src/server/BreCal/validators/validation_rules.py index 7d5448c..eea5780 100644 --- a/src/server/BreCal/validators/validation_rules.py +++ b/src/server/BreCal/validators/validation_rules.py @@ -7,7 +7,7 @@ import datetime from BreCal.database.enums import StatusFlags from BreCal.validators.validation_rule_functions import ValidationRuleFunctions from BreCal.schemas.model import Shipcall -from BreCal.notifications.notification_functions import Notifier +from BreCal.notifications.notification_functions import LegacyNotifier class ValidationRules(ValidationRuleFunctions): @@ -19,7 +19,7 @@ class ValidationRules(ValidationRuleFunctions): """ def __init__(self, sql_handler): # use the entire data that is provided for this query (e.g., json input) super().__init__(sql_handler) - self.notifier = Notifier() + self.notifier = LegacyNotifier() return def evaluate(self, shipcall): diff --git a/src/server/tests/database/test_sql_queries.py b/src/server/tests/database/test_sql_queries.py index f490eed..0f3c8c8 100644 --- a/src/server/tests/database/test_sql_queries.py +++ b/src/server/tests/database/test_sql_queries.py @@ -3,12 +3,12 @@ import pytest import os import bcrypt import pydapper -from BreCal import local_db from BreCal.database.sql_handler import execute_sql_query_standalone from BreCal.database.sql_queries import SQLQuery from BreCal.schemas import model from BreCal.stubs.user import get_user_simple +from BreCal import local_db instance_path = os.path.join(os.path.expanduser('~'), "brecal", "src", "server", "instance", "instance") local_db.initPool(os.path.dirname(instance_path), connection_filename="connection_data_local.json") @@ -36,13 +36,10 @@ def test_sql_query_get_user(): return def test_sql_get_notifications(): - import mysql.connector - # unfortunately, there currently is *no* notification in the database. - with pytest.raises(mysql.connector.errors.ProgrammingError, match="Unknown column 'shipcall_id' in 'field list'"): - options = {"shipcall_id":222} - notifications = execute_sql_query_standalone(query=SQLQuery.get_notifications(), param={"scid" : options["shipcall_id"]}, model=model.Notification.from_query_row) - assert all([isinstance(notification,model.Notification) for notification in notifications]) + options = {"shipcall_id":222} + notifications = execute_sql_query_standalone(query=SQLQuery.get_notifications(), param={"scid" : options["shipcall_id"]}, model=model.Notification.from_query_row) + assert all([isinstance(notification,model.Notification) for notification in notifications]) return def test_sql_get_participants(): @@ -458,7 +455,12 @@ def test_sql__shipcall_post__get_last_insert_id__get_spm__update_participants__v ### proxy data ### # loop across passed participant ids, creating entries for those not present in pdata - schemaModel = {'id': new_id, "participants":[{'id': 128, 'participant_id': 2, 'type': 4}, {'id': 129, 'participant_id': 3, 'type': 1}, {'id': 130, 'participant_id': 4, 'type': 2}, {'id': 131, 'participant_id': 6, 'type': 8}]} + schemaModel = {'id': new_id, + "participants":[{'id': 128, 'participant_id': 2, 'type': 4}, + {'id': 129, 'participant_id': 3, 'type': 1}, + {'id': 130, 'participant_id': 4, 'type': 2}, + {'id': 131, 'participant_id': 5, 'type': 8}, + {'id': 131, 'participant_id': 6, 'type': 8}]} # 4.) assign the participants for participant_assignment in schemaModel["participants"]: @@ -522,6 +524,26 @@ def test_sql_query_put_ship_is_identical_to_legacy_query(): assert query_refactored == query_legacy, f"the refactored code to generate the query must be absolutely identical to the legacy version" return +def test_sql_query_notification_post(): + with pytest.raises(NotImplementedError, match="skeleton"): + # not yet defined. Test also not created yet. + SQLQuery.get_notification_post() + return + +def test_sql_query_notification_put(): + with pytest.raises(NotImplementedError, match="skeleton"): + # not yet defined. Test also not created yet. + SQLQuery.get_shipcall_put_notification_state() + return + +def test_sql_query_notifications_get(): + shipcall_id = 85 + existing_notifications = execute_sql_query_standalone(query=SQLQuery.get_notifications(), param={"scid" : shipcall_id}, model=model.Notification, command_type="query") + if len(existing_notifications)>0: + assert isinstance(existing_notifications[0], model.Notification), f"returned an incorrect data model. Should have been model.Notification. Found: {type(existing_notifications)} --- {existing_notifications}" + return + + #schemas = execute_sql_query_standalone(query=SQLQuery.get_berth(), param={}) 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_notifier.py b/src/server/tests/notifications/test_notifier.py new file mode 100644 index 0000000..37bee02 --- /dev/null +++ b/src/server/tests/notifications/test_notifier.py @@ -0,0 +1,256 @@ +import pytest +from BreCal.notifications.notifier import Notifier +from BreCal.schemas.model import Notification + +from BreCal.database.sql_queries import SQLQuery +from BreCal.database.sql_handler import execute_sql_query_standalone +from BreCal.schemas import model +from BreCal.stubs.user import get_user_simple + +import os +from BreCal import local_db +instance_path = os.path.join(os.path.expanduser('~'), "brecal", "src", "server", "instance", "instance") +local_db.initPool(os.path.dirname(instance_path), connection_filename="connection_data_local.json") + +@pytest.fixture(scope="session") +def default_ids(): + # #TODO: use a shipcall_id, where a notification actually exists. Otherwise, this is not testable.. + shipcall_id = 85 + user_id = 29 + return locals() + + +def test_notifier_send_notifications(): + """ + the Notifier's .send_notifications() method will be called by the schedule module as part of a routine. + The method shall work without issues or outside arguments. + """ + with pytest.raises(NotImplementedError, match="skeleton"): + Notifier.send_notifications(is_test=True) + return + +def test_notifier_create_notification(default_ids): + shipcall_id, user_id = default_ids["shipcall_id"], default_ids["user_id"] + old_state, new_state = model.EvaluationType(1),model.EvaluationType(3) + user = Notifier.get_user(user_id) + user.notify_email = 1 + user.notify_popup = 1 + user.notify_signal = 1 + user.notify_whatsapp = 1 + + with pytest.raises(NotImplementedError, match="skeleton"): + notification = Notifier.publish(shipcall_id, old_state, new_state, user, update_database=False) + assert isinstance(notification, Notification) + return + +def test_notifier_create_notification_update_database(default_ids): + # generate a shipcall + # get the shipcall + # create notifications + + shipcall_id, user_id = default_ids["shipcall_id"], default_ids["user_id"] + old_state, new_state = model.EvaluationType(1),model.EvaluationType(3) + user = Notifier.get_user(user_id) + user.notify_email = 1 + user.notify_popup = 1 + user.notify_signal = 1 + user.notify_whatsapp = 1 + + with pytest.raises(NotImplementedError, match="skeleton"): + notification = Notifier.publish(shipcall_id, old_state, new_state, user, update_database=True) + + # #TODO: apply GetNotifications(*args) and identify, whether the notification exists now. Use an obvious 'message' to identify pytest creations. + return + +def test_notifier_get_user(default_ids): + user_id = default_ids["user_id"] + user_data = get_user_simple().__dict__ + user_data["id"] = user_id + user = Notifier.get_user(user_data["id"]) + + assert hasattr(user, "notify_email") + assert hasattr(user, "notify_popup") + assert hasattr(user, "notify_whatsapp") + assert hasattr(user, "notify_signal") + return + +def test_notification_type(): + """this pytest serves as a warning method to inform the developers, once novel NotificationType s have been included.""" + members = [key for key in model.NotificationType.__members__] + expected_member_count = 3 + assert len(members)==expected_member_count, f"the BreCal.model.NotificationType has been changed. Initially, there were only {expected_member_count} members. Now that there are more members, the Notifier.build_notification_type_list and its pytest can be updated. This pytest shall also be updated!" + return + +def test_notifier_build_notification_type_list(default_ids): + user_id = default_ids["user_id"] + user = Notifier.get_user(user_id) + user.notify_email = 1 + user.notify_popup = 1 + user.notify_whatsapp = 1 + user.notify_signal = 1 + + notification_type_list = Notifier.build_notification_type_list(user) + assert len(notification_type_list) == 4, f"there must be n=4 notifications, as there are fours types of notifications." + assert len([ntype for ntype in notification_type_list if ntype==model.NotificationType.email])==1, f"the EMAIL notification instruction was not created" + assert len([ntype for ntype in notification_type_list if ntype==model.NotificationType.push])==1, f"the POPUP/PUSH notification instruction was not created" + assert len([ntype for ntype in notification_type_list if ntype==model.NotificationType.undefined])==2, f"there must currently be two notifications, which have an undefined type (SIGNAL and WHATSAPP)" + return + +def test_notifier_build_notification_type_list__behaves_as_expected(default_ids): + user_id = default_ids["user_id"] + user = Notifier.get_user(user_id) + # only email + user.notify_email = 1 + user.notify_popup = 0 + user.notify_whatsapp = 0 + user.notify_signal = 0 + + notification_type_list = Notifier.build_notification_type_list(user) + assert len(notification_type_list) == 1 + assert int(notification_type_list[0])==int(model.NotificationType.email), f"should've returned email, but did not" + + # only popup + user.notify_email = 0 + user.notify_popup = 1 + user.notify_whatsapp = 0 + user.notify_signal = 0 + + notification_type_list = Notifier.build_notification_type_list(user) + assert len(notification_type_list) == 1 + assert int(notification_type_list[0])==int(model.NotificationType.push), f"should've returned push, but did not" + + # only whatsapp + user.notify_email = 0 + user.notify_popup = 0 + user.notify_whatsapp = 1 + user.notify_signal = 0 + + notification_type_list = Notifier.build_notification_type_list(user) + assert len(notification_type_list) == 1 + # #TODO: adapt the NotificationType.undefined once a data model exists for whatsapp + assert int(notification_type_list[0])==int(model.NotificationType.undefined), f"should've returned whatsapp, but did not" + + # only signal + user.notify_email = 0 + user.notify_popup = 0 + user.notify_whatsapp = 0 + user.notify_signal = 1 + + notification_type_list = Notifier.build_notification_type_list(user) + assert len(notification_type_list) == 1 + # #TODO: adapt the NotificationType.undefined once a data model exists for signal + assert int(notification_type_list[0])==int(model.NotificationType.undefined), f"should've returned signal, but did not" + return + +def test_notifier_get_shipcall(): + shipcall_id = 85 + shipcall = Notifier.get_shipcall(shipcall_id=shipcall_id) + assert shipcall.id==85 + assert shipcall.arrival_berth_id == 194 + return + +def test_notifier_get_existing_notifications(default_ids): + shipcall_id = default_ids["shipcall_id"] + existing_notifications = Notifier.get_existing_notifications(shipcall_id) + + assert isinstance(existing_notifications, list) + if len(existing_notifications)>0: + assert all([isinstance(existing_notification, model.Notification) for existing_notification in existing_notifications]) + return + +def test_notifier_check_notification_type_and_level_exists(default_ids): + """this method has been deprecated, because the conceptual-logic does not add up.""" + with pytest.raises(Exception, match="deprecated"): + shipcall_id = default_ids["shipcall_id"] + + from BreCal.stubs.notification import get_notification_simple + notification = get_notification_simple().__dict__ + notification["shipcall_id"] = shipcall_id + level = 2 + existing_notifications = Notifier.get_existing_notifications(shipcall_id=notification.get("shipcall_id")) + exists = Notifier.check_notification_type_and_level_exists(shipcall_id=shipcall_id, notification_type=notification.get("type"), level=level, existing_notifications=existing_notifications) + # assert exists==True, f"for the given notification, the method should return, that the notification already exists." + return + +def test_notifier_check_higher_severity(): + """ + Checks all possible combinations of model.EvaluationType within Notifier.check_higher_severity + It should prove, that a boolean True is only returned, when the severity grows. In particular, + undefined or green -> yellow + undefined or green -> red + yellow -> red + """ + # [FALSE] cases: should not trigger the creation of a Notification + old_state, new_state = model.EvaluationType.green, model.EvaluationType.undefined + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + old_state, new_state = model.EvaluationType.yellow, model.EvaluationType.green + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + old_state, new_state = model.EvaluationType.yellow, model.EvaluationType.undefined + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + old_state, new_state = model.EvaluationType.red, model.EvaluationType.yellow + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + old_state, new_state = model.EvaluationType.red, model.EvaluationType.green + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + old_state, new_state = model.EvaluationType.red, model.EvaluationType.undefined + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + old_state, new_state = model.EvaluationType.undefined, model.EvaluationType.green + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + + # [FALSE] SAME EVALUATION as before + old_state, new_state = model.EvaluationType.undefined, model.EvaluationType.undefined + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + old_state, new_state = model.EvaluationType.green, model.EvaluationType.green + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + old_state, new_state = model.EvaluationType.yellow, model.EvaluationType.yellow + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + old_state, new_state = model.EvaluationType.red, model.EvaluationType.red + assert not Notifier.check_higher_severity(old_state, new_state), f"this case should not create a Notification!" + + # [TRUE] cases: should then trigger the creation of a Notification + old_state, new_state = model.EvaluationType.green, model.EvaluationType.yellow + assert Notifier.check_higher_severity(old_state, new_state), f"this case should create a Notification, but the method did not recognize it." + + old_state, new_state = model.EvaluationType.yellow, model.EvaluationType.red + assert Notifier.check_higher_severity(old_state, new_state), f"this case should create a Notification, but the method did not recognize it." + + old_state, new_state = model.EvaluationType.green, model.EvaluationType.red + assert Notifier.check_higher_severity(old_state, new_state), f"this case should create a Notification, but the method did not recognize it." + + old_state, new_state = model.EvaluationType.undefined, model.EvaluationType.yellow + assert Notifier.check_higher_severity(old_state, new_state), f"this case should create a Notification, but the method did not recognize it." + + old_state, new_state = model.EvaluationType.undefined, model.EvaluationType.red + assert Notifier.check_higher_severity(old_state, new_state), f"this case should create a Notification, but the method did not recognize it." + return + +def test_notifier_loop_generate_notification_message_by_type(default_ids): + user_id = default_ids["user_id"] + user = execute_sql_query_standalone(query=SQLQuery.get_user_by_id(), param={"id" : user_id}, model=model.User, command_type="single") + + # enable all notification types + user.notify_email=1 + user.notify_whatsapp=1 + user.notify_signal=1 + user.notify_popup=1 + + evaluation_message = "abc" + + with pytest.raises(NotImplementedError, match="skeleton"): + # create a list of notification types and create a message for each of them + notification_type_list = Notifier.build_notification_type_list(user) + for notification_type in notification_type_list: + message = Notifier.generate_notification_message_by_type(notification_type, evaluation_message, user) + assert isinstance(message, str), f"Message must be a string. Found: {type(message)} --- {message}" + + assert not evaluation_message=="abc", f"#TODO_metz: lazy development. have to update the message body" + return