403 lines
22 KiB
Python
403 lines
22 KiB
Python
import typing
|
|
import json
|
|
import datetime
|
|
from abc import ABC, abstractmethod
|
|
from marshmallow import ValidationError
|
|
from string import ascii_letters, digits
|
|
|
|
from BreCal.schemas.model import Ship, Shipcall, Berth, User, Participant, ShipcallType, Times
|
|
from BreCal.impl.participant import GetParticipant
|
|
from BreCal.impl.ships import GetShips
|
|
from BreCal.impl.berths import GetBerths
|
|
from BreCal.impl.times import GetTimes
|
|
|
|
from BreCal.database.enums import ParticipantType, ParticipantFlag
|
|
from BreCal.validators.input_validation_utils import check_if_user_is_bsmd_type, check_if_ship_id_is_valid, check_if_berth_id_is_valid, check_if_participant_ids_are_valid, check_if_participant_ids_and_types_are_valid, check_if_shipcall_id_is_valid, get_shipcall_id_dictionary, get_participant_type_from_user_data, get_participant_id_dictionary, check_if_participant_id_is_valid_standalone
|
|
from BreCal.database.sql_handler import execute_sql_query_standalone
|
|
from BreCal.validators.validation_base_utils import check_if_int_is_valid_flag, check_if_string_has_special_characters
|
|
import werkzeug
|
|
|
|
def build_post_data_type_dependent_required_fields_dict()->dict[ShipcallType,dict[ParticipantType,typing.Optional[list[str]]]]:
|
|
"""
|
|
The required fields of a POST-request depend on ShipcallType and ParticipantType. This function creates
|
|
a dictionary, which maps those types to a list of required fields.
|
|
|
|
The participant types 'undefined' and 'bsmd' should not be used in POST-requests. They return 'None'.
|
|
"""
|
|
post_data_type_dependent_required_fields_dict = {
|
|
ShipcallType.arrival:{
|
|
ParticipantType.undefined:None, # should not be set in POST requests
|
|
ParticipantType.BSMD:None, # should not be set in POST requests
|
|
ParticipantType.TERMINAL:["operations_start"],
|
|
ParticipantType.AGENCY:["eta_berth"],
|
|
ParticipantType.MOORING:["eta_berth"],
|
|
ParticipantType.PILOT:["eta_berth"],
|
|
ParticipantType.PORT_ADMINISTRATION:["eta_berth"],
|
|
ParticipantType.TUG:["eta_berth"],
|
|
},
|
|
ShipcallType.departure:{
|
|
ParticipantType.undefined:None, # should not be set in POST requests
|
|
ParticipantType.BSMD:None, # should not be set in POST requests
|
|
ParticipantType.TERMINAL:["operations_end"],
|
|
ParticipantType.AGENCY:["etd_berth"],
|
|
ParticipantType.MOORING:["etd_berth"],
|
|
ParticipantType.PILOT:["etd_berth"],
|
|
ParticipantType.PORT_ADMINISTRATION:["etd_berth"],
|
|
ParticipantType.TUG:["etd_berth"],
|
|
},
|
|
ShipcallType.shifting:{
|
|
ParticipantType.undefined:None, # should not be set in POST requests
|
|
ParticipantType.BSMD:None, # should not be set in POST requests
|
|
ParticipantType.TERMINAL:["operations_start", "operations_end"],
|
|
ParticipantType.AGENCY:["eta_berth", "etd_berth"],
|
|
ParticipantType.MOORING:["eta_berth", "etd_berth"],
|
|
ParticipantType.PILOT:["eta_berth", "etd_berth"],
|
|
ParticipantType.PORT_ADMINISTRATION:["eta_berth", "etd_berth"],
|
|
ParticipantType.TUG:["eta_berth", "etd_berth"],
|
|
},
|
|
}
|
|
return post_data_type_dependent_required_fields_dict
|
|
|
|
|
|
|
|
class InputValidationTimes():
|
|
"""
|
|
This class combines a complex set of individual input validation functions into a joint object.
|
|
It uses static methods, so the object does not need to be instantiated, but functions can be called immediately.
|
|
|
|
Example:
|
|
InputValidationTimes.evaluate(user_data, loadedModel, content)
|
|
|
|
When the data violates one of the rules, a marshmallow.ValidationError is raised, which details the issues.
|
|
"""
|
|
def __init__(self) -> None:
|
|
pass
|
|
|
|
@staticmethod
|
|
def evaluate_post_data(user_data:dict, loadedModel:dict, content:dict):
|
|
# 0.) Check for the presence of required fields
|
|
InputValidationTimes.check_times_required_fields_post_data(loadedModel, content)
|
|
|
|
# 1.) datasets may only be created, if the current user fits the appropriate type in the ShipcallParticipantMap
|
|
InputValidationTimes.check_if_user_fits_shipcall_participant_map(user_data, loadedModel, content)
|
|
|
|
# 2.) datasets may only be created, if the respective participant type did not already create one.
|
|
InputValidationTimes.check_if_entry_already_exists_for_participant_type(user_data, loadedModel, content)
|
|
|
|
# 3.) only users who are *not* of type BSMD may post times datasets.
|
|
InputValidationTimes.check_user_is_not_bsmd_type(user_data)
|
|
|
|
# 4.) Reference checking
|
|
InputValidationTimes.check_dataset_references(content)
|
|
|
|
# 5.) Value checking
|
|
InputValidationTimes.check_dataset_values(user_data, loadedModel, content)
|
|
return
|
|
|
|
@staticmethod
|
|
def evaluate_put_data(user_data:dict, loadedModel:dict, content:dict):
|
|
# 1.) Check for the presence of required fields
|
|
InputValidationTimes.check_times_required_fields_put_data(content)
|
|
|
|
# 2.) Only users of the same participant_id, which the times dataset refers to, can update the entry
|
|
InputValidationTimes.check_user_belongs_to_same_group_as_dataset_determines(user_data, loadedModel=loadedModel, times_id=None)
|
|
|
|
# 3.) Reference checking
|
|
InputValidationTimes.check_dataset_references(content)
|
|
|
|
# 4.) Value checking
|
|
InputValidationTimes.check_dataset_values(user_data, loadedModel, content)
|
|
return
|
|
|
|
@staticmethod
|
|
def evaluate_delete_data(user_data:dict, times_id:int):
|
|
# #TODO_determine: is times_id always an int or does the request.args call provide a string?
|
|
times_id = int(times_id) if not isinstance(times_id, int) else times_id
|
|
|
|
# 1.) The dataset entry may not be deleted already
|
|
InputValidationTimes.check_if_entry_is_already_deleted(times_id)
|
|
|
|
# 2.) Only users of the same participant_id, which the times dataset refers to, can delete the entry
|
|
InputValidationTimes.check_user_belongs_to_same_group_as_dataset_determines(user_data, loadedModel=None, times_id=times_id)
|
|
return
|
|
|
|
@staticmethod
|
|
def check_if_entry_is_already_deleted(times_id:int):
|
|
"""
|
|
When calling a delete request for times, the dataset may not be deleted already. This method
|
|
makes sure, that the request contains and ID, has a matching entry in the database.
|
|
When a times dataset is deleted, it is directly removed from the database.
|
|
|
|
To identify deleted entries, query from the database and check, whether there is a match for the times id.
|
|
|
|
"""
|
|
# perform an SQL query. Creates a pooled connection internally, queries the database, then closes the connection.
|
|
query = "SELECT shipcall_id FROM times WHERE id = ?id?"
|
|
pdata = execute_sql_query_standalone(query=query, param={"id":times_id}, pooledConnection=None)
|
|
|
|
if len(pdata)==0:
|
|
raise ValidationError(f"The selected time entry is already deleted. ID: {times_id}")
|
|
return
|
|
|
|
@staticmethod
|
|
def check_user_is_not_bsmd_type(user_data:dict):
|
|
"""a new dataset may only be created by a user who is *not* belonging to participant group BSMD"""
|
|
is_bsmd = check_if_user_is_bsmd_type(user_data)
|
|
if is_bsmd:
|
|
raise ValidationError(f"current user belongs to BSMD. Cannot post 'times' datasets. Found user data: {user_data}")
|
|
return
|
|
|
|
@staticmethod
|
|
def check_dataset_values(user_data:dict, loadedModel:dict, content:dict):
|
|
"""
|
|
this method validates POST and PUT data. Most of the dataset arguments are validated directly in the
|
|
BreCal.schemas.model.TimesSchema, using @validates. This is exclusive for 'simple' validation rules.
|
|
|
|
This applies to:
|
|
"remarks" & "berth_info"
|
|
"eta_berth", "etd_berth", "lock_time", "zone_entry", "operations_start", "operations_end"
|
|
"""
|
|
# while InputValidationTimes.check_user_is_not_bsmd_type already validates a user, this method
|
|
# validates the times dataset.
|
|
|
|
# ensure loadedModel["participant_type"] is of type ParticipantType
|
|
if not isinstance(loadedModel["participant_type"], ParticipantType):
|
|
loadedModel["participant_type"] = ParticipantType(loadedModel["participant_type"])
|
|
|
|
if ParticipantType.BSMD in loadedModel["participant_type"]:
|
|
raise ValidationError(f"current user belongs to BSMD. Cannot post times datasets. Found user data: {user_data}")
|
|
return
|
|
|
|
@staticmethod
|
|
def check_dataset_references(content:dict):
|
|
"""
|
|
When IDs are referenced, they must exist in the database. This method individually validates the existance of referred
|
|
berth ID, participant IDs and shipcall ID.
|
|
|
|
Note: whenever an ID is 'None', there is no exception, because a different method is supposed to capture non-existant mandatory fields.
|
|
"""
|
|
# extract the IDs
|
|
berth_id, participant_id, shipcall_id = content.get("berth_id"), content.get("participant_id"), content.get("shipcall_id")
|
|
|
|
valid_berth_id_reference = check_if_berth_id_is_valid(berth_id)
|
|
if not valid_berth_id_reference:
|
|
raise ValidationError(f"The referenced berth_id '{berth_id}' does not exist in the database.")
|
|
|
|
valid_shipcall_id_reference = check_if_shipcall_id_is_valid(shipcall_id)
|
|
if not valid_shipcall_id_reference:
|
|
raise ValidationError(f"The referenced shipcall_id '{shipcall_id}' does not exist in the database.")
|
|
|
|
valid_participant_id_reference = check_if_participant_id_is_valid_standalone(participant_id)
|
|
if not valid_participant_id_reference:
|
|
raise ValidationError(f"The referenced participant_id '{participant_id}' does not exist in the database.")
|
|
|
|
return
|
|
|
|
@staticmethod
|
|
def check_times_required_fields_post_data(loadedModel:dict, content:dict):
|
|
"""
|
|
Depending on ShipcallType and ParticipantType, there is a rather complex set of required fields.
|
|
Independent of those types, any POST request for times should always include the default fields.
|
|
|
|
The dependent and independent fields are validated by checking, whether the respective value in 'content'
|
|
is undefined (returns None). When any of these fields is undefined, a ValidationError is raised.
|
|
"""
|
|
participant_type = loadedModel["participant_type"]
|
|
shipcall_id = loadedModel["shipcall_id"]
|
|
|
|
# build a dictionary of id:item pairs, so one can select the respective participant
|
|
# must look-up the shipcall_type based on the shipcall_id
|
|
shipcalls = get_shipcall_id_dictionary()
|
|
shipcall_type = ShipcallType[shipcalls.get(shipcall_id,{}).get("type",ShipcallType.undefined.name)]
|
|
|
|
if (participant_type is None) or (int(shipcall_type) == int(ShipcallType.undefined)):
|
|
raise ValidationError(f"At least one of the required fields is missing. Missing: 'participant_type' or 'shipcall_type'")
|
|
|
|
|
|
# build a list of required fields based on shipcall and participant type, as well as type-independent fields
|
|
independent_required_fields = InputValidationTimes.get_post_data_type_independent_fields()
|
|
dependent_required_fields = InputValidationTimes.get_post_data_type_dependent_fields(shipcall_type, participant_type)
|
|
|
|
required_fields = independent_required_fields + dependent_required_fields
|
|
|
|
# generate a list of booleans, where each element shows, whether one of the required fields is missing.
|
|
missing_required_fields = [
|
|
content.get(field,None) is None for field in required_fields
|
|
]
|
|
|
|
if any(missing_required_fields):
|
|
# create a tuple of (field_key, bool) to describe to a user, which one of the fields may be missing
|
|
verbosity_tuple = [(field, missing) for field, missing in zip(required_fields, missing_required_fields) if missing]
|
|
raise ValidationError(f"At least one of the required fields is missing. Missing: {verbosity_tuple}")
|
|
return
|
|
|
|
@staticmethod
|
|
def check_times_required_fields_put_data(content:dict):
|
|
"""in a PUT request, only the 'id' is a required field. All other fields are simply ignored, when they are not provided."""
|
|
if content.get("id") is None:
|
|
raise ValidationError(f"A PUT-request requires an 'id' reference, which was not found.")
|
|
return
|
|
|
|
@staticmethod
|
|
def get_post_data_type_independent_fields()->list[str]:
|
|
"""
|
|
Independent of the ShipcallType and ParticipantType, any POST request for times should always include the default fields.
|
|
"""
|
|
independent_required_fields = [
|
|
"shipcall_id", "participant_id", "participant_type"
|
|
]
|
|
return independent_required_fields
|
|
|
|
@staticmethod
|
|
def get_post_data_type_dependent_fields(shipcall_type:typing.Union[int, ShipcallType], participant_type:typing.Union[int, ParticipantType]):
|
|
"""
|
|
Depending on ShipcallType and ParticipantType, there is a rather complex set of required fields.
|
|
|
|
Arriving shipcalls need arrival times (e.g., 'eta'), Departing shipcalls need departure times (e.g., 'etd') and
|
|
Shifting shipcalls need both times (e.g., 'eta' and 'etd').
|
|
|
|
Further, the ParticipantType determines the set of relevant times. In particular, the terminal uses
|
|
'operations_start' and 'operations_end', while other users use 'eta_berth' or 'etd_berth'.
|
|
"""
|
|
# ensure that both, shipcall_type and participant_type, refer to the enumerators, as opposed to integers.
|
|
if not isinstance(shipcall_type, ShipcallType):
|
|
shipcall_type = ShipcallType(shipcall_type)
|
|
if not isinstance(participant_type, ParticipantType):
|
|
participant_type = ParticipantType(participant_type)
|
|
|
|
# build a dictionary, which maps shipcall type and participant type to a list of fields
|
|
dependent_required_fields_dict = build_post_data_type_dependent_required_fields_dict()
|
|
|
|
# select shipcall type & participant type
|
|
dependent_required_fields = dependent_required_fields_dict.get(shipcall_type,{}).get(participant_type,None)
|
|
return dependent_required_fields
|
|
|
|
@staticmethod
|
|
def check_if_user_fits_shipcall_participant_map(user_data:dict, loadedModel:dict, content:dict, spm_shipcall_data=None):
|
|
"""
|
|
a new dataset may only be created, if the user belongs to the participant group (participant_id),
|
|
which is assigned to the shipcall within the ShipcallParticipantMap
|
|
|
|
This method does not validate, what the POST-request contains, but it validates, whether the *user* is
|
|
authorized to send the request.
|
|
|
|
options:
|
|
spm_shipcall_data:
|
|
data from the ShipcallParticipantMap, which refers to the respective shipcall ID. The SPM can be
|
|
an optional argument to allow for much easier unit testing.
|
|
"""
|
|
|
|
# identify shipcall_id
|
|
shipcall_id = loadedModel["shipcall_id"]
|
|
|
|
# identify user's participant_id & type (get all participants; then filter these for the {participant_id})
|
|
participant_id = user_data["participant_id"] #participants = get_participant_id_dictionary() #participant_type = ParticipantType(participants.get(participant_id,{}).get("type"))
|
|
participant_type = ParticipantType(loadedModel["participant_type"]) if not isinstance(loadedModel["participant_type"],ParticipantType) else loadedModel["participant_type"]
|
|
|
|
# get ShipcallParticipantMap for the shipcall_id
|
|
if spm_shipcall_data is None:
|
|
# read the ShipcallParticipantMap entry of the current shipcall_id. This is used within the input validation of a PUT request
|
|
# creates a list of {'participant_id: ..., 'type': ...} elements
|
|
spm_shipcall_data = execute_sql_query_standalone(
|
|
query = "SELECT participant_id, type FROM shipcall_participant_map WHERE shipcall_id=?shipcall_id?",
|
|
param={"shipcall_id":shipcall_id},
|
|
pooledConnection=None
|
|
)
|
|
|
|
# check, if participant_id is assigned to the ShipcallParticipantMap
|
|
matching_spm = [
|
|
spm
|
|
for spm in spm_shipcall_data
|
|
if spm.get("participant_id")==participant_id
|
|
]
|
|
|
|
if not len(matching_spm)>0:
|
|
raise ValidationError(f'The participant group with id {participant_id} is not assigned to the shipcall. Found ShipcallParticipantMap: {spm_shipcall_data}')
|
|
|
|
# check, if the assigned participant_id is assigned with the same role
|
|
matching_spm_element = matching_spm[0]
|
|
matching_spm_element_participant_type = ParticipantType(matching_spm_element.get("type"))
|
|
if not matching_spm_element_participant_type in participant_type:
|
|
raise ValidationError(f'The participant group with id {participant_id} is assigned to the shipcall in a different role. Request Role: {participant_type}, ShipcallParticipantMap Role Assignment: {matching_spm_element_participant_type}')
|
|
return
|
|
|
|
@staticmethod
|
|
def check_if_entry_already_exists_for_participant_type(user_data:dict, loadedModel:dict, content:dict):
|
|
"""determines, whether a dataset for the participant type is already present"""
|
|
# determine participant_type and shipcall_id from the loadedModel
|
|
participant_type = loadedModel["participant_type"]
|
|
if not isinstance(participant_type, ParticipantType): # ensure the correct data type
|
|
participant_type = ParticipantType(participant_type)
|
|
shipcall_id = loadedModel["shipcall_id"]
|
|
|
|
# get all times entries of the shipcall_id from the database
|
|
times, status_code, headers = GetTimes(options={"shipcall_id":shipcall_id})
|
|
times = json.loads(times)
|
|
|
|
# check, if there is already a dataset for the participant type
|
|
participant_type_exists_already = any([ParticipantType(time_.get("participant_type",0)) in participant_type for time_ in times])
|
|
if participant_type_exists_already:
|
|
raise ValidationError(f"A dataset for the participant type is already present. Participant Type: {participant_type}. Times Datasets: {times}")
|
|
return
|
|
|
|
@staticmethod
|
|
def check_user_belongs_to_same_group_as_dataset_determines(user_data:dict, loadedModel:typing.Optional[dict]=None, times_id:typing.Optional[int]=None):
|
|
"""
|
|
This method checks, whether a user belongs to the same participant_id, as the dataset entry refers to.
|
|
It is used in, both, PUT requests and DELETE requests, but uses different arguments to determine the matching
|
|
time dataset entry.
|
|
|
|
PUT:
|
|
loadedModel is unbundled to identify the matching times entry by the shipcall id
|
|
|
|
DELETE:
|
|
times_id is used to directly identify the matching times entry
|
|
"""
|
|
assert not ((loadedModel is None) and (times_id is None)), f"must provide either loadedModel OR times_id. Both are 'None'"
|
|
assert (loadedModel is None) or (times_id is None), f"must provide either loadedModel OR times_id. Both are defined."
|
|
|
|
# identify the user's participant id
|
|
user_participant_id = user_data["participant_id"]
|
|
|
|
if loadedModel is not None:
|
|
shipcall_id = loadedModel["shipcall_id"]
|
|
participant_type = loadedModel["participant_type"]
|
|
|
|
# get all times entries of the shipcall_id from the database as a list of {'participant_id':..., 'participant_type':...} elements
|
|
query = "SELECT participant_id, participant_type FROM times WHERE shipcall_id = ?shipcall_id?"
|
|
times = execute_sql_query_standalone(query=query, param={"shipcall_id":shipcall_id}, pooledConnection=None)
|
|
|
|
# get the matching datasets, where the participant id is identical
|
|
time_datasets_of_participant_type = [time_ for time_ in times if time_.get("participant_type")==participant_type]
|
|
|
|
# when there are no matching participants, raise a ValidationError
|
|
if not len(time_datasets_of_participant_type)>0:
|
|
raise ValidationError(f"Could not find a matching time dataset for the provided participant_type: {participant_type}. Found Time Datasets: {times}")
|
|
|
|
# take the first match. There should always be only one match.
|
|
time_datasets_of_participant_type = time_datasets_of_participant_type[0]
|
|
participant_id_of_times_dataset = time_datasets_of_participant_type.get("participant_id")
|
|
|
|
if times_id is not None:
|
|
# perform an SQL query. Creates a pooled connection internally, queries the database, then closes the connection.
|
|
query = "SELECT participant_id FROM times WHERE id = ?id?"
|
|
pdata = execute_sql_query_standalone(query=query, param={"id":times_id}, pooledConnection=None)
|
|
|
|
# extracts the participant_id from the first matching entry, if applicable
|
|
if not len(pdata)>0:
|
|
# this case is usually covered by the InputValidationTimes.check_if_entry_is_already_deleted method already
|
|
raise ValidationError(f"Unknown times_id. Could not find a matching entry for ID: {times_id}")
|
|
else:
|
|
participant_id_of_times_dataset = pdata[0].get("participant_id")
|
|
|
|
if user_participant_id != participant_id_of_times_dataset:
|
|
raise ValidationError(f"The dataset may only be changed by a user belonging to the same participant group as the times dataset is referring to. User participant_id: {user_participant_id}; Dataset participant_id: {participant_id_of_times_dataset}")
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|