implementing POST-request input validation for shipcalls. Creating many tests and applying slight updates to the Notifier (not implemented yet)

This commit is contained in:
Max Metz 2024-04-29 16:46:50 +02:00
parent d0753f0b32
commit b7078f8d8e
7 changed files with 165 additions and 37 deletions

View File

@ -4,7 +4,7 @@ from marshmallow import Schema, fields, ValidationError
from ..schemas import model
from .. import impl
from ..services.auth_guard import auth_guard, check_jwt
from BreCal.validators.input_validation import check_if_user_is_bsmd_type, check_if_user_data_has_valid_ship_id, check_if_user_data_has_valid_berth_id, check_if_user_data_has_valid_participant_id
from BreCal.validators.input_validation import validate_posted_shipcall_data
import logging
import json
@ -41,35 +41,15 @@ def PostShipcalls():
try:
content = request.get_json(force=True)
loadedModel = model.ShipcallSchema().load(data=content, many=False, partial=True)
logging.log(20, loadedModel)
logging.log(20, "dev. above: loaded model, below: content")
logging.log(20, content)
# read the user data from the JWT token (set when login is performed)
user_data = check_jwt()
# check, whether the user belongs to a participant, which is of type ParticipantType.BSMD
# as ParticipantType is an IntFlag, a user belonging to multiple groups is properly evaluated.
is_bsmd = check_if_user_is_bsmd_type(user_data)
if not is_bsmd:
raise ValidationError(f"current user does not belong to BSMD. Cannot post shipcalls. Found user data: {user_data}")
import logging
logging.log(20, loadedModel)
logging.log(20, "metz development")
"""
# loadedModel ...
loadedModel.get("ship_id", 0)
2024-04-22 18:21:03,982 | root | INFO | {'ship_id': 1,
'type': 1, 'eta': datetime.datetime(2023, 7, 23, 7, 18, 19),
'voyage': '43B', 'tug_required': False, 'pilot_required': True,
'flags': 0, 'pier_side': False, 'bunkering': True, 'recommended_tugs': 2,
'type_value': 1, 'evaluation_value': 0}
valid_ship_id = check_if_user_data_has_valid_ship_id(ship_id)
valid_berth_id = check_if_user_data_has_valid_berth_id(berth_id)
valid_participant_id = check_if_user_data_has_valid_participant_id(participant_id)
"""
# validate the posted shipcall data
validate_posted_shipcall_data(user_data, loadedModel, content)
except ValidationError as ex:
logging.error(ex)

View File

@ -55,6 +55,7 @@ def update_all_shipcalls_in_mysql_database(sql_connection, sql_handler:SQLHandle
sql_handler: an SQLHandler instance
shipcall_df: dataframe, which stores the data that is used to retrieve the shipcall data models (that are then updated in the database)
"""
print(shipcall_df)
for shipcall_id in shipcall_df.index:
shipcall = sql_handler.df_loc_to_data_model(df=shipcall_df, id=shipcall_id, model_str="shipcall")
update_shipcall_in_mysql_database(sql_connection, shipcall=shipcall, relevant_keys = ["evaluation", "evaluation_message"])

View File

@ -110,6 +110,6 @@ class Notifier():
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)]
evaluation_notifications_sent = [self.determine_notification_state(state_old=int(state_old), state_new=int(state_new)) for state_old, state_new in zip(evaluation_states_old, evaluation_states_new)]
return evaluation_notifications_sent

View File

@ -188,9 +188,9 @@ class ShipcallSchema(Schema):
pass
id = fields.Integer()
ship_id = fields.Integer()
ship_id = fields.Integer(metadata={'required':True})
#type = fields.Enum(ShipcallType, default=ShipcallType.undefined) # type = fields.Integer() # make enum: shipcall type. add validator
type = fields.Integer() # make enum: shipcall type. add validator # type = fields.Enum(ShipcallType, default=ShipcallType.undefined) # type = fields.Integer() # make enum: shipcall type. add validator
type = fields.Integer(metadata={'required':True}) # make enum: shipcall type. add validator # type = fields.Enum(ShipcallType, default=ShipcallType.undefined) # type = fields.Integer() # make enum: shipcall type. add validator
eta = fields.DateTime(metadata={'required':False}, allow_none=True)
voyage = fields.String(allow_none=True, metadata={'Required':False}, validate=[validate.Length(max=16)]) # Solving: RemovedInMarshmallow4Warning: Passing field metadata as keyword arguments is deprecated. Use the explicit `metadata=...` argument instead. Additional metadata: {'Required': False}
etd = fields.DateTime(metadata={'required':False}, allow_none=True)
@ -207,7 +207,7 @@ class ShipcallSchema(Schema):
tidal_window_from = fields.DateTime(metadata={'required':False}, allow_none=True)
tidal_window_to = fields.DateTime(metadata={'required':False}, allow_none=True)
rain_sensitive_cargo = fields.Bool(metadata={'required':False}, allow_none=True)
recommended_tugs = fields.Integer(metadata={'required':False}, allow_none=True)
recommended_tugs = fields.Integer(metadata={'required':False}, allow_none=True, validate=[validate.Range(min=0, max=10, min_inclusive=True, max_inclusive=True)])
anchored = fields.Bool(metadata={'required':False}, allow_none=True)
moored_lock = fields.Bool(metadata={'required':False}, allow_none=True)
canceled = fields.Bool(metadata={'required':False}, allow_none=True)
@ -251,6 +251,9 @@ class Participant_Assignment:
participant_id: int
type: int # a variant would be to use the IntFlag type (with appropriate serialization)
def to_json(self):
return self.__dict__
@dataclass
class Shipcall:

View File

@ -85,4 +85,22 @@ def get_shipcall_simple():
)
return shipcall
def create_postman_stub_shipcall():
"""
this function returns the common stub, which is used to POST data to shipcalls via POSTMAN. However,
the stub-function is updated with a dynamic ETA in the future, so the POST-request does not fail.
"""
shipcall = {
'ship_id': 1,
'type': 1,
'eta': (datetime.datetime.now()+datetime.timedelta(hours=3)).isoformat(),
'voyage': '43B',
'tug_required': False,
'pilot_required': True,
'flags': 0,
'pier_side': False,
'bunkering': True,
'recommended_tugs': 2
}
return shipcall

View File

@ -2,14 +2,31 @@
####################################### InputValidation #######################################
import json
import datetime
from abc import ABC, abstractmethod
from BreCal.schemas.model import Ship, Shipcall, Berth, User, Participant
from marshmallow import ValidationError
from string import ascii_letters, digits
from BreCal.schemas.model import Ship, Shipcall, Berth, User, Participant, ShipcallType
from BreCal.impl.participant import GetParticipant
from BreCal.impl.ships import GetShips
from BreCal.impl.berths import GetBerths
from BreCal.database.enums import ParticipantType
def check_if_string_has_special_characters(text:str):
"""
check, whether there are any characters within the provided string, which are not found in the ascii letters or digits
ascii_letters: abcd (...) and ABCD (...)
digits: 0123 (...)
Source: https://stackoverflow.com/questions/57062794/is-there-a-way-to-check-if-a-string-contains-special-characters
User: https://stackoverflow.com/users/10035985/andrej-kesely
returns bool
"""
return bool(set(text).difference(ascii_letters + digits))
def get_participant_id_dictionary():
# get all participants
response,status_code,header = GetParticipant(options={})
@ -61,7 +78,11 @@ def check_if_user_is_bsmd_type(user_data:dict)->bool:
is_bsmd = ParticipantType.BSMD in ParticipantType(participant.get("type",0))
return is_bsmd
def check_if_user_data_has_valid_ship_id(ship_id):
def check_if_ship_id_is_valid(ship_id):
"""check, whether the provided ID is valid. If it is 'None', it will be considered valid. This is, because a shipcall POST-request, does not have to include all IDs at once"""
if ship_id is None:
return True
# build a dictionary of id:item pairs, so one can select the respective participant
ships = get_ship_id_dictionary()
@ -69,7 +90,11 @@ def check_if_user_data_has_valid_ship_id(ship_id):
ship_id_is_valid = ship_id in list(ships.keys())
return ship_id_is_valid
def check_if_user_data_has_valid_berth_id(berth_id):
def check_if_berth_id_is_valid(berth_id):
"""check, whether the provided ID is valid. If it is 'None', it will be considered valid. This is, because a shipcall POST-request, does not have to include all IDs at once"""
if berth_id is None:
return True
# build a dictionary of id:item pairs, so one can select the respective participant
berths = get_berth_id_dictionary()
@ -77,7 +102,11 @@ def check_if_user_data_has_valid_berth_id(berth_id):
berth_id_is_valid = berth_id in list(berths.keys())
return berth_id_is_valid
def check_if_user_data_has_valid_participant_id(participant_id):
def check_if_participant_id_is_valid(participant_id):
"""check, whether the provided ID is valid. If it is 'None', it will be considered valid. This is, because a shipcall POST-request, does not have to include all IDs at once"""
if participant_id is None:
return True
# build a dictionary of id:item pairs, so one can select the respective participant
participants = get_participant_id_dictionary()
@ -85,6 +114,102 @@ def check_if_user_data_has_valid_participant_id(participant_id):
participant_id_is_valid = participant_id in list(participants.keys())
return participant_id_is_valid
def check_if_participant_ids_are_valid(participant_ids):
# check each participant id individually
valid_participant_ids = [check_if_participant_id_is_valid(participant_id) for participant_id in participant_ids]
# boolean check, whether all participant ids are valid
return all(valid_participant_ids)
def validate_posted_shipcall_data(user_data:dict, loadedModel:dict, content:dict):
"""this function applies more complex validation functions to data, which is sent to a post-request of shipcalls"""
# #TODO_refactor: this function is pretty complex. One may instead build an object, which calls the methods separately.
import logging
logging.log(20, "dev")
logging.log(20, user_data)
logging.log(20, loadedModel)
logging.log(20, content)
##### Section 1: check user_data #####
# check, whether the user belongs to a participant, which is of type ParticipantType.BSMD
# as ParticipantType is an IntFlag, a user belonging to multiple groups is properly evaluated.
is_bsmd = check_if_user_is_bsmd_type(user_data)
if not is_bsmd:
raise ValidationError(f"current user does not belong to BSMD. Cannot post shipcalls. Found user data: {user_data}")
##### Section 2: check loadedModel #####
valid_ship_id = check_if_ship_id_is_valid(ship_id=loadedModel.get("ship_id", None))
if not valid_ship_id:
raise ValidationError(f"provided an invalid ship id, which is not found in the database: {loadedModel.get('ship_id', None)}")
valid_arrival_berth_id = check_if_berth_id_is_valid(berth_id=loadedModel.get("arrival_berth_id", None))
if not valid_arrival_berth_id:
raise ValidationError(f"provided an invalid arrival berth id, which is not found in the database: {loadedModel.get('arrival_berth_id', None)}")
valid_departure_berth_id = check_if_berth_id_is_valid(berth_id=loadedModel.get("departure_berth_id", None))
if not valid_departure_berth_id:
raise ValidationError(f"provided an invalid departure berth id, which is not found in the database: {loadedModel.get('departure_berth_id', None)}")
valid_participant_ids = check_if_participant_ids_are_valid(participant_ids=loadedModel.get("participants",[]))
if not valid_participant_ids:
raise ValidationError(f"one of the provided participant ids is invalid. Could not find one of these in the database: {loadedModel.get('participants', None)}")
##### Section 3: check content #####
# loadedModel fills missing values, sometimes using optional values. Hence, check content
# the following keys should not be set in a POST-request.
for forbidden_key in ["canceled", "evaluation", "evaluation_message"]:
value = content.get(forbidden_key, None)
if value is not None:
raise ValidationError(f"'{forbidden_key}' may not be set on POST. Found: {value}")
voyage_str_is_invalid = check_if_string_has_special_characters(text=content.get("voyage",""))
if voyage_str_is_invalid:
raise ValidationError(f"there are invalid characters in the 'voyage'-string. Please use only digits and ASCII letters. Allowed: {ascii_letters+digits}. Found: {content.get('voyage')}")
##### Section 4: check loadedModel & content #####
# #TODO_refactor: these methods should be placed in separate locations
# existance checks in content
# datetime checks in loadedModel (datetime.datetime objects). Dates should be in the future.
time_now = datetime.datetime.now()
type_ = loadedModel.get("type", int(ShipcallType.undefined))
if int(type_)==int(ShipcallType.undefined):
raise ValidationError(f"providing 'type' is mandatory. Missing key!")
elif int(type_)==int(ShipcallType.arrival):
eta = loadedModel.get("eta")
if (content.get("eta", None) is None):
raise ValidationError(f"providing 'eta' is mandatory. Missing key!")
if content.get("arrival_berth_id", None) is None:
raise ValidationError(f"providing 'arrival_berth_id' is mandatory. Missing key!")
if not eta >= time_now:
raise ValidationError(f"'eta' must be in the future. Incorrect datetime provided.")
elif int(type_)==int(ShipcallType.departure):
etd = loadedModel.get("etd")
if (content.get("etd", None) is None):
raise ValidationError(f"providing 'etd' is mandatory. Missing key!")
if content.get("departure_berth_id", None) is None:
raise ValidationError(f"providing 'departure_berth_id' is mandatory. Missing key!")
if not etd >= time_now:
raise ValidationError(f"'etd' must be in the future. Incorrect datetime provided.")
elif int(type_)==int(ShipcallType.shifting):
eta = loadedModel.get("eta")
etd = loadedModel.get("etd")
# * arrival_berth_id / departure_berth_id (depending on type, see above)
if (content.get("eta", None) is None) or (content.get("etd", None) is None):
raise ValidationError(f"providing 'eta' and 'etd' is mandatory. Missing one of those keys!")
if (content.get("arrival_berth_id", None) is None) or (content.get("departure_berth_id", None) is None):
raise ValidationError(f"providing 'arrival_berth_id' & 'departure_berth_id' is mandatory. Missing key!")
if (not eta >= time_now) or (not etd >= time_now) or (not eta >= etd):
raise ValidationError(f"'eta' and 'etd' must be in the future. Incorrect datetime provided.")
# #TODO: len of participants > 0, if agency
# * assigned participant for agency
return
class InputValidation():

View File

@ -75,6 +75,7 @@ class ValidationRules(ValidationRuleFunctions):
def evaluate_shipcalls(self, shipcall_df:pd.DataFrame)->pd.DataFrame:
"""apply 'evaluate_shipcall_from_df' to each individual shipcall in {shipcall_df}. Returns shipcall_df ('evaluation', 'evaluation_message', 'evaluation_time' and 'evaluation_notifications_sent' are updated)"""
evaluation_states_old = [state_old for state_old in shipcall_df.loc[:,"evaluation"]]
evaluation_states_old = [state_old if not pd.isna(state_old) else 0 for state_old in evaluation_states_old]
results = shipcall_df.apply(lambda x: self.evaluate_shipcall_from_df(x), axis=1).values # returns tuple (state, message)
# unbundle individual results. evaluation_states becomes an integer, violation
@ -83,14 +84,14 @@ class ValidationRules(ValidationRuleFunctions):
violations = [self.concise_evaluation_message_if_too_long(violation) for violation in violations]
# build the list of evaluation times ('now', as isoformat)
evaluation_times = self.notifier.get_notification_times(evaluation_states_new)
evaluation_time = 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)
evaluation_notifications_sent = self.notifier.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_time"] = evaluation_time
shipcall_df.loc[:,"evaluation_notifications_sent"] = evaluation_notifications_sent
return shipcall_df