bugfix for shipcall PUT validation

This commit is contained in:
Daniel Schick 2024-10-16 16:16:54 +02:00
parent c0902c65ee
commit 401e0d4ae8
2 changed files with 92 additions and 93 deletions

View File

@ -11,7 +11,7 @@ from BreCal.schemas import model
def pandas_series_to_data_model(): def pandas_series_to_data_model():
return return
def set_participant_type(x, participant_df)->int: def set_participant_type(x, participant_df)->int:
""" """
@ -23,12 +23,12 @@ def set_participant_type(x, participant_df)->int:
participant_id = x["participant_id"] participant_id = x["participant_id"]
participant_type = participant_df.loc[participant_id, "type"] participant_type = participant_df.loc[participant_id, "type"]
return participant_type return participant_type
def get_synchronous_shipcall_times_standalone(query_time:pd.Timestamp, all_df_times:pd.DataFrame, delta_threshold=900)->int: def get_synchronous_shipcall_times_standalone(query_time:pd.Timestamp, all_df_times:pd.DataFrame, delta_threshold=900)->int:
""" """
This function counts all entries in {all_df_times}, which have the same timestamp as {query_time}. This function counts all entries in {all_df_times}, which have the same timestamp as {query_time}.
It does so by: It does so by:
1.) selecting all eta_berth & etd_berth entries 1.) selecting all eta_berth & etd_berth entries
2.) measuring the timedelta towards {query_time} 2.) measuring the timedelta towards {query_time}
3.) converting the timedelta to total absolute seconds (positive or negative time differences do not matter) 3.) converting the timedelta to total absolute seconds (positive or negative time differences do not matter)
4.) applying a {delta_threshold} to identify, whether two times are too closely together 4.) applying a {delta_threshold} to identify, whether two times are too closely together
@ -60,8 +60,8 @@ def get_synchronous_shipcall_times_standalone(query_time:pd.Timestamp, all_df_ti
def execute_sql_query_standalone(query, param={}, pooledConnection=None, model=None, command_type="query"): def execute_sql_query_standalone(query, param={}, pooledConnection=None, model=None, command_type="query"):
""" """
execute an arbitrary query with a set of parameters, return the output and convert it to a list. execute an arbitrary query with a set of parameters, return the output and convert it to a list.
when the pooled connection is rebuilt, it will be closed at the end of the function. when the pooled connection is rebuilt, it will be closed at the end of the function.
""" """
rebuild_pooled_connection = pooledConnection is None rebuild_pooled_connection = pooledConnection is None
@ -99,13 +99,13 @@ def execute_sql_query_standalone(query, param={}, pooledConnection=None, model=N
# when providing a model, such as model.Shipcall, the dataset is immediately translated into a data model. # when providing a model, such as model.Shipcall, the dataset is immediately translated into a data model.
schemas = commands.query_single_or_default(query, sentinel, param=param) if model is None else commands.query_single_or_default(query, sentinel, param=param, model=model) schemas = commands.query_single_or_default(query, sentinel, param=param) if model is None else commands.query_single_or_default(query, sentinel, param=param, model=model)
schemas = None if schemas is sentinel else schemas schemas = None if schemas is sentinel else schemas
elif command_type=="execute_scalar": elif command_type=="execute_scalar":
schemas = commands.execute_scalar(query) schemas = commands.execute_scalar(query)
else: else:
raise ValueError(command_type) raise ValueError(command_type)
finally: # if needed, ensure that the pooled connection is closed. finally: # if needed, ensure that the pooled connection is closed.
if rebuild_pooled_connection: if rebuild_pooled_connection:
pooledConnection.close() pooledConnection.close()
@ -114,23 +114,23 @@ def execute_sql_query_standalone(query, param={}, pooledConnection=None, model=N
def get_assigned_participant_of_type(shipcall_id:int, participant_type:typing.Union[int,model.ParticipantType])->typing.Optional[model.Participant]: def get_assigned_participant_of_type(shipcall_id:int, participant_type:typing.Union[int,model.ParticipantType])->typing.Optional[model.Participant]:
"""obtains the ShipcallParticipantMap of a given shipcall and finds the participant id of a desired type. Finally, returns the respective Participant""" """obtains the ShipcallParticipantMap of a given shipcall and finds the participant id of a desired type. Finally, returns the respective Participant"""
spm_shipcall_data = execute_sql_query_standalone( spm_shipcall_data = execute_sql_query_standalone(
query=SQLQuery.get_shipcall_participant_map_by_shipcall_id_and_type(), query=SQLQuery.get_shipcall_participant_map_by_shipcall_id_and_type(),
param={"id":shipcall_id, "type":participant_type}, param={"id":shipcall_id, "type":int(participant_type)},
command_type="query") # returns a list of matches command_type="query") # returns a list of matches
if len(spm_shipcall_data)==0: if len(spm_shipcall_data)==0:
return None return None
query = 'SELECT * FROM participant WHERE id=?participant_id?' query = 'SELECT * FROM participant WHERE id=?participant_id?'
assigned_participant = execute_sql_query_standalone( assigned_participant = execute_sql_query_standalone(
query=query, query=query,
param={"participant_id":spm_shipcall_data[0]["participant_id"]}, param={"participant_id":spm_shipcall_data[0]["participant_id"]},
model=model.Participant, model=model.Participant,
command_type="single_or_none" command_type="single_or_none"
) # returns a list of matches ) # returns a list of matches
return assigned_participant return assigned_participant
class SQLHandler(): class SQLHandler():
""" """
An object that reads SQL queries from the sql_connection and stores it in pandas DataFrames. The object can read all available tables An object that reads SQL queries from the sql_connection and stores it in pandas DataFrames. The object can read all available tables
@ -161,7 +161,7 @@ class SQLHandler():
schema = cursor.fetchall() schema = cursor.fetchall()
all_schemas = [schem[0] for schem in schema] all_schemas = [schem[0] for schem in schema]
return all_schemas return all_schemas
def build_str_to_model_dict(self): def build_str_to_model_dict(self):
""" """
creates a simple dictionary, which maps a string to a data object creates a simple dictionary, which maps a string to a data object
@ -181,7 +181,7 @@ class SQLHandler():
cursor.execute(f"DESCRIBE {table_name}") cursor.execute(f"DESCRIBE {table_name}")
cols = cursor.fetchall() cols = cursor.fetchall()
column_names = [col_name[0] for col_name in cols] column_names = [col_name[0] for col_name in cols]
# 2.) get the data tuples # 2.) get the data tuples
cursor.execute(f"SELECT * FROM {table_name}") cursor.execute(f"SELECT * FROM {table_name}")
data = cursor.fetchall() data = cursor.fetchall()
@ -192,14 +192,14 @@ class SQLHandler():
# 4.) build a dataframe from the respective data models (which ensures the correct data type) # 4.) build a dataframe from the respective data models (which ensures the correct data type)
df = self.build_df_from_data_and_name(data, table_name) df = self.build_df_from_data_and_name(data, table_name)
return df return df
def build_df_from_data_and_name(self, data, table_name): def build_df_from_data_and_name(self, data, table_name):
data_model = self.str_to_model_dict.get(table_name) data_model = self.str_to_model_dict.get(table_name)
if data_model is not None: if data_model is not None:
df = pd.DataFrame([data_model(**dat) for dat in data], columns=list(data_model.__annotations__.keys())) df = pd.DataFrame([data_model(**dat) for dat in data], columns=list(data_model.__annotations__.keys()))
else: else:
df = pd.DataFrame([dat for dat in data]) df = pd.DataFrame([dat for dat in data])
return df return df
def mysql_to_df(self, query, table_name): def mysql_to_df(self, query, table_name):
"""provide an arbitrary sql query that should be read from a mysql server {sql_connection}. returns a pandas DataFrame with the obtained data""" """provide an arbitrary sql query that should be read from a mysql server {sql_connection}. returns a pandas DataFrame with the obtained data"""
@ -222,7 +222,7 @@ class SQLHandler():
if 'id' in df.columns: if 'id' in df.columns:
df = df.set_index('id', inplace=False) # avoid inplace updates, so the raw sql remains unchanged df = df.set_index('id', inplace=False) # avoid inplace updates, so the raw sql remains unchanged
return df return df
def read_all(self, all_schemas): def read_all(self, all_schemas):
# create a dictionary, which maps every mysql schema to pandas DataFrames # create a dictionary, which maps every mysql schema to pandas DataFrames
self.df_dict = self.build_full_mysql_df_dict(all_schemas) self.df_dict = self.build_full_mysql_df_dict(all_schemas)
@ -242,7 +242,7 @@ class SQLHandler():
query = f"SELECT * FROM {schem}" query = f"SELECT * FROM {schem}"
mysql_df_dict[schem] = self.mysql_to_df(query, table_name=schem) mysql_df_dict[schem] = self.mysql_to_df(query, table_name=schem)
return mysql_df_dict return mysql_df_dict
def initialize_shipcall_participant_list(self): def initialize_shipcall_participant_list(self):
""" """
iteratively applies the .get_participants method to each shipcall. iteratively applies the .get_participants method to each shipcall.
@ -256,10 +256,10 @@ class SQLHandler():
# if the shipcall_id exists, the list contains ids # if the shipcall_id exists, the list contains ids
# otherwise, return a blank list # otherwise, return a blank list
df['participants'] = df.apply( df['participants'] = df.apply(
lambda x: self.get_participants(x.name), lambda x: self.get_participants(x.name),
axis=1) axis=1)
return return
def add_participant_type_to_map(self): def add_participant_type_to_map(self):
""" """
applies a lambda function, where the 'type'-column in the shipcall_participant_map is updated by reading the applies a lambda function, where the 'type'-column in the shipcall_participant_map is updated by reading the
@ -272,14 +272,14 @@ class SQLHandler():
#spm.loc[:,"type"] = spm.loc[:].apply(lambda x: set_participant_type(x, participant_df=participant_df),axis=1) #spm.loc[:,"type"] = spm.loc[:].apply(lambda x: set_participant_type(x, participant_df=participant_df),axis=1)
#self.df_dict["shipcall_participant_map"] = spm #self.df_dict["shipcall_participant_map"] = spm
return return
def get_assigned_participants(self, shipcall)->pd.DataFrame: def get_assigned_participants(self, shipcall)->pd.DataFrame:
"""return each participant of a respective shipcall, filtered by the shipcall id""" """return each participant of a respective shipcall, filtered by the shipcall id"""
# get the shipcall_participant_map # get the shipcall_participant_map
spm = self.df_dict["shipcall_participant_map"] spm = self.df_dict["shipcall_participant_map"]
assigned_participants = spm.loc[spm["shipcall_id"]==shipcall.id] assigned_participants = spm.loc[spm["shipcall_id"]==shipcall.id]
return assigned_participants return assigned_participants
def get_assigned_participants_by_type(self, assigned_participants:pd.DataFrame, participant_type:ParticipantType): def get_assigned_participants_by_type(self, assigned_participants:pd.DataFrame, participant_type:ParticipantType):
"""filters a dataframe of assigned_participants by the provided type enumerator""" """filters a dataframe of assigned_participants by the provided type enumerator"""
if isinstance(participant_type,int): if isinstance(participant_type,int):
@ -288,7 +288,7 @@ class SQLHandler():
assigned_participants_of_type = assigned_participants.loc[[participant_type in ParticipantType(int(pt_)) for pt_ in list(assigned_participants["type"].values)]] assigned_participants_of_type = assigned_participants.loc[[participant_type in ParticipantType(int(pt_)) for pt_ in list(assigned_participants["type"].values)]]
#assigned_participants_of_type = assigned_participants.loc[assigned_participants["type"]==participant_type.value] #assigned_participants_of_type = assigned_participants.loc[assigned_participants["type"]==participant_type.value]
return assigned_participants_of_type return assigned_participants_of_type
def check_if_any_participant_of_type_is_unassigned(self, shipcall, *args:list[ParticipantType])->bool: def check_if_any_participant_of_type_is_unassigned(self, shipcall, *args:list[ParticipantType])->bool:
""" """
given a list of input arguments, where item is a participant type, the function determines, whether at least one participant given a list of input arguments, where item is a participant type, the function determines, whether at least one participant
@ -305,13 +305,13 @@ class SQLHandler():
unassignment = len(assignments_of_type)==0 # a participant type does not exist, when there is no match unassignment = len(assignments_of_type)==0 # a participant type does not exist, when there is no match
unassigned.append(unassignment) unassigned.append(unassignment)
return any(unassigned) # returns a single boolean, whether ANY of the types is not assigned return any(unassigned) # returns a single boolean, whether ANY of the types is not assigned
def standardize_model_str(self, model_str:str)->str: def standardize_model_str(self, model_str:str)->str:
"""check if the 'model_str' is valid and apply lowercasing to the string""" """check if the 'model_str' is valid and apply lowercasing to the string"""
model_str = model_str.lower() model_str = model_str.lower()
assert model_str in list(self.df_dict.keys()), f"cannot find the requested 'model_str' in mysql: {model_str}" assert model_str in list(self.df_dict.keys()), f"cannot find the requested 'model_str' in mysql: {model_str}"
return model_str return model_str
def get_data(self, id:int, model_str:str): def get_data(self, id:int, model_str:str):
""" """
obtains {id} from the respective mysql database and builds a data model from that. obtains {id} from the respective mysql database and builds a data model from that.
@ -323,11 +323,11 @@ class SQLHandler():
returns a Shipcall object returns a Shipcall object
""" """
model_str = self.standardize_model_str(model_str) model_str = self.standardize_model_str(model_str)
df = self.df_dict.get(model_str) df = self.df_dict.get(model_str)
data = self.df_loc_to_data_model(df, id, model_str) data = self.df_loc_to_data_model(df, id, model_str)
return data return data
def get_all(self, model_str:str)->list: def get_all(self, model_str:str)->list:
""" """
given a model string (e.g., 'shipcall'), return a list of all given a model string (e.g., 'shipcall'), return a list of all
@ -341,13 +341,13 @@ class SQLHandler():
for _aid in all_ids for _aid in all_ids
] ]
return all_data return all_data
def df_loc_to_data_model(self, df, id, model_str, loc_type:str="loc"): def df_loc_to_data_model(self, df, id, model_str, loc_type:str="loc"):
if not len(df)>0: if not len(df)>0:
import warnings import warnings
warnings.warn(f"empty dataframe in SQLHandler.df_loc_to_data_model for model type: {model_str}\n") warnings.warn(f"empty dataframe in SQLHandler.df_loc_to_data_model for model type: {model_str}\n")
return df return df
# get a pandas series from the dataframe # get a pandas series from the dataframe
series = df.loc[id] if loc_type=="loc" else df.iloc[id] series = df.loc[id] if loc_type=="loc" else df.iloc[id]
@ -360,7 +360,7 @@ class SQLHandler():
data = {**{'id':int(id)}, **series.to_dict()} # 'id' must be added manually, as .to_dict does not contain the index, which was set with .set_index data = {**{'id':int(id)}, **series.to_dict()} # 'id' must be added manually, as .to_dict does not contain the index, which was set with .set_index
data = data_model(**data) data = data_model(**data)
return data return data
def filter_df_by_participant_type(self, df, participant_type:typing.Union[int, ParticipantType])->pd.DataFrame: def filter_df_by_participant_type(self, df, participant_type:typing.Union[int, ParticipantType])->pd.DataFrame:
""" """
As ParticipantTypes are Flag objects, a dataframe's integer might resemble multiple participant types simultaneously. As ParticipantTypes are Flag objects, a dataframe's integer might resemble multiple participant types simultaneously.
@ -376,7 +376,7 @@ class SQLHandler():
participant_type = ParticipantType(participant_type) participant_type = ParticipantType(participant_type)
filtered_df = df.loc[[participant_type in ParticipantType(df_pt) for df_pt in list(df["participant_type"].values)]] filtered_df = df.loc[[participant_type in ParticipantType(df_pt) for df_pt in list(df["participant_type"].values)]]
return filtered_df return filtered_df
def get_times_for_participant_type(self, df_times, participant_type:int): def get_times_for_participant_type(self, df_times, participant_type:int):
filtered_series = self.filter_df_by_participant_type(df_times, participant_type) filtered_series = self.filter_df_by_participant_type(df_times, participant_type)
#filtered_series = df_times.loc[df_times["participant_type"]==participant_type] #filtered_series = df_times.loc[df_times["participant_type"]==participant_type]
@ -385,14 +385,14 @@ class SQLHandler():
return None return None
if not len(filtered_series)<=1: if not len(filtered_series)<=1:
# correcting the error: ERROR:root:found multiple results # correcting the error: ERROR:root:found multiple results
# however, a warning will still be issued # however, a warning will still be issued
import warnings import warnings
warnings.warn(f"found multiple results in function SQLHandler.get_times_for_participant_type\nConsidering only the first match!\nAffected Times Indexes: {filtered_series.index}") warnings.warn(f"found multiple results in function SQLHandler.get_times_for_participant_type\nConsidering only the first match!\nAffected Times Indexes: {filtered_series.index}")
times = self.df_loc_to_data_model(filtered_series, id=0, model_str='times', loc_type="iloc") # use iloc! to retrieve the first result times = self.df_loc_to_data_model(filtered_series, id=0, model_str='times', loc_type="iloc") # use iloc! to retrieve the first result
return times return times
def dataframe_to_data_model_list(self, df, model_str)->list: def dataframe_to_data_model_list(self, df, model_str)->list:
model_str = self.standardize_model_str(model_str) model_str = self.standardize_model_str(model_str)
@ -413,22 +413,22 @@ class SQLHandler():
df = self.df_dict.get("shipcall_participant_map") df = self.df_dict.get("shipcall_participant_map")
if 'shipcall_id' in list(df.columns): if 'shipcall_id' in list(df.columns):
df = df.set_index('shipcall_id', inplace=False) df = df.set_index('shipcall_id', inplace=False)
# the 'if' call is needed to ensure, that no Exception is raised, when the shipcall_id is not present in the df # the 'if' call is needed to ensure, that no Exception is raised, when the shipcall_id is not present in the df
participant_id_list = df.loc[shipcall_id, "participant_id"].tolist() if shipcall_id in list(df.index) else [] participant_id_list = df.loc[shipcall_id, "participant_id"].tolist() if shipcall_id in list(df.index) else []
if not isinstance(participant_id_list,list): if not isinstance(participant_id_list,list):
participant_id_list = [participant_id_list] participant_id_list = [participant_id_list]
return participant_id_list return participant_id_list
def get_times_of_shipcall(self, shipcall)->pd.DataFrame: def get_times_of_shipcall(self, shipcall)->pd.DataFrame:
df_times = self.df_dict.get('times') # -> pd.DataFrame df_times = self.df_dict.get('times') # -> pd.DataFrame
df_times = df_times.loc[df_times["shipcall_id"]==shipcall.id] df_times = df_times.loc[df_times["shipcall_id"]==shipcall.id]
return df_times return df_times
def get_times_for_agency(self, non_null_column=None)->pd.DataFrame: def get_times_for_agency(self, non_null_column=None)->pd.DataFrame:
""" """
options: options:
non_null_column: non_null_column:
None or str. If provided, the 'non_null_column'-column of the dataframe will be filtered, None or str. If provided, the 'non_null_column'-column of the dataframe will be filtered,
so only entries with provided values are returned (filters all NaN and NaT entries) so only entries with provided values are returned (filters all NaN and NaT entries)
""" """
@ -437,8 +437,8 @@ class SQLHandler():
# filter out all NaN and NaT entries # filter out all NaN and NaT entries
if non_null_column is not None: if non_null_column is not None:
# in the Pandas documentation, it says for .isnull(): # in the Pandas documentation, it says for .isnull():
# "This function takes a scalar or array-like object and indicates whether values are missing # "This function takes a scalar or array-like object and indicates whether values are missing
# (NaN in numeric arrays, None or NaN in object arrays, NaT in datetimelike)." # (NaN in numeric arrays, None or NaN in object arrays, NaT in datetimelike)."
df_times = df_times.loc[~df_times[non_null_column].isnull()] # NOT null filter df_times = df_times.loc[~df_times[non_null_column].isnull()] # NOT null filter
@ -446,10 +446,10 @@ class SQLHandler():
times_agency = self.filter_df_by_participant_type(df_times, ParticipantType.AGENCY.value) times_agency = self.filter_df_by_participant_type(df_times, ParticipantType.AGENCY.value)
#times_agency = df_times.loc[df_times["participant_type"]==ParticipantType.AGENCY.value] #times_agency = df_times.loc[df_times["participant_type"]==ParticipantType.AGENCY.value]
return times_agency return times_agency
def filter_df_by_key_value(self, df, key, value)->pd.DataFrame: def filter_df_by_key_value(self, df, key, value)->pd.DataFrame:
return df.loc[df[key]==value] return df.loc[df[key]==value]
def get_unique_ship_counts(self, all_df_times:pd.DataFrame, times_agency:pd.DataFrame, query:str, rounding:str="min", maximum_threshold=3): def get_unique_ship_counts(self, all_df_times:pd.DataFrame, times_agency:pd.DataFrame, query:str, rounding:str="min", maximum_threshold=3):
"""given a dataframe of all agency times, get all unique ship counts, their values (datetime) and the string tags. returns a tuple (values,unique,counts)""" """given a dataframe of all agency times, get all unique ship counts, their values (datetime) and the string tags. returns a tuple (values,unique,counts)"""
# #deprecated! # #deprecated!

View File

@ -29,7 +29,7 @@ class InputValidationShipcall():
Example: Example:
InputValidationShipcall.evaluate(user_data, loadedModel, content) InputValidationShipcall.evaluate(user_data, loadedModel, content)
When the data violates one of the rules, a marshmallow.ValidationError is raised, which details the issues. When the data violates one of the rules, a marshmallow.ValidationError is raised, which details the issues.
""" """
def __init__(self) -> None: def __init__(self) -> None:
@ -39,7 +39,7 @@ class InputValidationShipcall():
def evaluate_post_data(user_data:dict, loadedModel:dict, content:dict): def evaluate_post_data(user_data:dict, loadedModel:dict, content:dict):
""" """
this function combines multiple validation functions to verify data, which is sent to the API as a shipcall's POST-request this function combines multiple validation functions to verify data, which is sent to the API as a shipcall's POST-request
checks: checks:
1. permission: only participants that belong to the BSMD or AGENCY groups are allowed to POST shipcalls 1. permission: only participants that belong to the BSMD or AGENCY groups are allowed to POST shipcalls
2. reference checks: all refered objects within the Shipcall must exist 2. reference checks: all refered objects within the Shipcall must exist
@ -61,16 +61,16 @@ class InputValidationShipcall():
# check for reasonable values in the shipcall fields # check for reasonable values in the shipcall fields
InputValidationShipcall.check_shipcall_values(loadedModel, content, forbidden_keys=["evaluation", "evaluation_message"]) # "canceled" InputValidationShipcall.check_shipcall_values(loadedModel, content, forbidden_keys=["evaluation", "evaluation_message"]) # "canceled"
return return
@staticmethod @staticmethod
def evaluate_put_data(user_data:dict, loadedModel:dict, content:dict): def evaluate_put_data(user_data:dict, loadedModel:dict, content:dict):
""" """
this function combines multiple validation functions to verify data, which is sent to the API as a shipcall's PUT-request this function combines multiple validation functions to verify data, which is sent to the API as a shipcall's PUT-request
checks: checks:
1. user's authority: 1. user's authority:
a) whether the user's participant is assigned to the shipcall (via shipcall-participant-map) a) whether the user's participant is assigned to the shipcall (via shipcall-participant-map)
b) whether the user is either an AGENCY (assigned) or the BSMD, in case the AGENCY allows the BSMD to edit their shipcalls b) whether the user is either an AGENCY (assigned) or the BSMD, in case the AGENCY allows the BSMD to edit their shipcalls
2. existance of required fields 2. existance of required fields
3. all value-rules of the POST evaluation 3. all value-rules of the POST evaluation
4. a canceled shipcall may not be changed 4. a canceled shipcall may not be changed
@ -90,24 +90,24 @@ class InputValidationShipcall():
# check the referenced IDs # check the referenced IDs
InputValidationShipcall.check_referenced_ids(loadedModel) InputValidationShipcall.check_referenced_ids(loadedModel)
# check for reasonable values in the shipcall fields and checks for forbidden keys. # check for reasonable values in the shipcall fields and checks for forbidden keys.
InputValidationShipcall.check_shipcall_values(loadedModel, content, forbidden_keys=["evaluation", "evaluation_message"], is_put_data=True) InputValidationShipcall.check_shipcall_values(loadedModel, content, forbidden_keys=["evaluation", "evaluation_message"], is_put_data=True)
# a canceled shipcall cannot be selected # a canceled shipcall cannot be selected
# Note: 'canceled' is allowed in PUT-requests, if it is not already set (which is checked by InputValidationShipcall.check_shipcall_is_cancel) # Note: 'canceled' is allowed in PUT-requests, if it is not already set (which is checked by InputValidationShipcall.check_shipcall_is_cancel)
InputValidationShipcall.check_shipcall_is_canceled(loadedModel, content) InputValidationShipcall.check_shipcall_is_canceled(loadedModel, content)
return return
@staticmethod @staticmethod
def check_shipcall_values(loadedModel:dict, content:dict, forbidden_keys:list=["evaluation", "evaluation_message"], is_put_data:bool=False): def check_shipcall_values(loadedModel:dict, content:dict, forbidden_keys:list=["evaluation", "evaluation_message"], is_put_data:bool=False):
""" """
individually checks each value provided in the loadedModel/content. individually checks each value provided in the loadedModel/content.
This function validates, whether the values are reasonable. This function validates, whether the values are reasonable.
Also, some data may not be set in a POST-request. Also, some data may not be set in a POST-request.
options: options:
is_put_data: bool. Some validation rules do not apply to POST data, but apply to PUT data. This flag separates the two. is_put_data: bool. Some validation rules do not apply to POST data, but apply to PUT data. This flag separates the two.
""" """
# Note: BreCal.schemas.model.ShipcallSchema has an internal validation, which the marshmallow library provides. This is used # Note: BreCal.schemas.model.ShipcallSchema has an internal validation, which the marshmallow library provides. This is used
# to verify values individually, when the schema is loaded with data. # to verify values individually, when the schema is loaded with data.
@ -117,13 +117,13 @@ class InputValidationShipcall():
# voyage shall not contain special characters # voyage shall not contain special characters
voyage_str_is_invalid = check_if_string_has_special_characters(text=content.get("voyage","")) voyage_str_is_invalid = check_if_string_has_special_characters(text=content.get("voyage",""))
if voyage_str_is_invalid: if voyage_str_is_invalid:
raise ValidationError({"voyage":f"there are invalid characters in the 'voyage'-string. Please use only digits and ASCII letters. Allowed: {ascii_letters+digits}. Found: {content.get('voyage')}"}) raise ValidationError({"voyage":f"there are invalid characters in the 'voyage'-string. Please use only digits and ASCII letters. Allowed: {ascii_letters+digits}. Found: {content.get('voyage')}"})
# the 'flags' integer must be valid # the 'flags' integer must be valid
flags_value = content.get("flags", 0) flags_value = content.get("flags", 0)
if check_if_int_is_valid_flag(flags_value, enum_object=ParticipantFlag): if check_if_int_is_valid_flag(flags_value, enum_object=ParticipantFlag):
raise ValidationError({"flags":f"incorrect value provided for 'flags'. Must be a valid combination of the flags."}) raise ValidationError({"flags":f"incorrect value provided for 'flags'. Must be a valid combination of the flags."})
if is_put_data: if is_put_data:
# the type of a shipcall may not be changed. It can only be set with the initial POST-request. # the type of a shipcall may not be changed. It can only be set with the initial POST-request.
InputValidationShipcall.check_shipcall_type_is_unchanged(loadedModel) InputValidationShipcall.check_shipcall_type_is_unchanged(loadedModel)
@ -134,7 +134,7 @@ class InputValidationShipcall():
# some arguments must not be provided # some arguments must not be provided
InputValidationShipcall.check_forbidden_arguments(content, forbidden_keys=forbidden_keys) InputValidationShipcall.check_forbidden_arguments(content, forbidden_keys=forbidden_keys)
return return
@staticmethod @staticmethod
def check_agency_in_shipcall_participant_map(user_data:dict, loadedModel:dict, content:dict, spm_shipcall_data:typing.Optional[list]=None): def check_agency_in_shipcall_participant_map(user_data:dict, loadedModel:dict, content:dict, spm_shipcall_data:typing.Optional[list]=None):
""" """
@ -145,11 +145,11 @@ class InputValidationShipcall():
Upon violation, this method issues 'Forbidden'-Exceptions with HTTP status code 403. There are four reasons for violations: Upon violation, this method issues 'Forbidden'-Exceptions with HTTP status code 403. There are four reasons for violations:
a) an agency tries to self-assign for a shipcall a) an agency tries to self-assign for a shipcall
b) there is no assigned agency for the current shipcall b) there is no assigned agency for the current shipcall
c) an agency is assigned, but the current agency-user belongs to a different participant_id c) an agency is assigned, but the current agency-user belongs to a different participant_id
d) the user must be of ParticipantType BSMD or AGENCY d) the user must be of ParticipantType BSMD or AGENCY
args: args:
spm_shipcall_data: spm_shipcall_data:
a list of entries obtained from the ShipcallParticipantMap. These are deserialized dictionaries. a list of entries obtained from the ShipcallParticipantMap. These are deserialized dictionaries.
e.g., [{'participant_id': 136, 'type': 8}, ] e.g., [{'participant_id': 136, 'type': 8}, ]
""" """
@ -158,11 +158,11 @@ class InputValidationShipcall():
# read the ShipcallParticipantMap entry of the current shipcall_id. This is used within the input validation of a PUT request # read the ShipcallParticipantMap entry of the current shipcall_id. This is used within the input validation of a PUT request
spm_shipcall_data = execute_sql_query_standalone( spm_shipcall_data = execute_sql_query_standalone(
# #TODO_refactor: place this within the SQLQuery object # #TODO_refactor: place this within the SQLQuery object
query = "SELECT participant_id, type FROM shipcall_participant_map WHERE shipcall_id=?shipcall_id?", query = "SELECT participant_id, type FROM shipcall_participant_map WHERE shipcall_id=?shipcall_id?",
param={"shipcall_id":loadedModel["id"]}, param={"shipcall_id":loadedModel["id"]},
pooledConnection=None pooledConnection=None
) )
# which role should be set by the PUT request? If the agency is about to be set, an error will be created # which role should be set by the PUT request? If the agency is about to be set, an error will be created
# read the user data from the JWT token (set when login is performed) # read the user data from the JWT token (set when login is performed)
user_type = get_participant_type_from_user_data(user_data) # decode JWT -> get 'type' value (guarantees to convert user type into an IntFlag) user_type = get_participant_type_from_user_data(user_data) # decode JWT -> get 'type' value (guarantees to convert user type into an IntFlag)
@ -180,7 +180,7 @@ class InputValidationShipcall():
# user not AGENCY or BSMD # user not AGENCY or BSMD
raise werkzeug.exceptions.Forbidden(f"PUT Requests for shipcalls can only be issued by AGENCY or BSMD users.") # Forbidden: 403 raise werkzeug.exceptions.Forbidden(f"PUT Requests for shipcalls can only be issued by AGENCY or BSMD users.") # Forbidden: 403
# Placeholder: when a user is an AGENCY, # Placeholder: when a user is an AGENCY,
if (ParticipantType.AGENCY in user_type) & (any_type_is_agency): if (ParticipantType.AGENCY in user_type) & (any_type_is_agency):
# self-assignment: agency sets agency participant # self-assignment: agency sets agency participant
@ -189,7 +189,7 @@ class InputValidationShipcall():
if len(agency_entries)>0: if len(agency_entries)>0:
# agency participant exists: participant id must be the same as shipcall participant map entry # agency participant exists: participant id must be the same as shipcall participant map entry
matching_spm_entry = [spm_entry for spm_entry in spm_shipcall_data if (spm_entry.get("participant_id")==user_data["id"]) & (int(spm_entry.get("type"))==int(ParticipantType.AGENCY))] matching_spm_entry = [spm_entry for spm_entry in spm_shipcall_data if (spm_entry.get("participant_id")==user_data["id"]) & (int(spm_entry.get("type"))==int(ParticipantType.AGENCY))]
if len(matching_spm_entry)==0: if len(matching_spm_entry)==0:
# An AGENCY was found, but a different participant_id is assigned to that AGENCY # An AGENCY was found, but a different participant_id is assigned to that AGENCY
raise werkzeug.exceptions.Forbidden(f"A different participant_id is assigned as the AGENCY of this shipcall. Provided ID: {user_data.get('id')}, Assigned ShipcallParticipantMap: {agency_entries}") # Forbidden: 403 raise werkzeug.exceptions.Forbidden(f"A different participant_id is assigned as the AGENCY of this shipcall. Provided ID: {user_data.get('id')}, Assigned ShipcallParticipantMap: {agency_entries}") # Forbidden: 403
@ -201,7 +201,7 @@ class InputValidationShipcall():
# agency participant does not exist: there is no assigned agency role for the shipcall {shipcall_id} # agency participant does not exist: there is no assigned agency role for the shipcall {shipcall_id}
raise werkzeug.exceptions.Forbidden(f"There is no assigned agency for this shipcall. Shipcall ID: {loadedModel['id']}") # Forbidden: 403 raise werkzeug.exceptions.Forbidden(f"There is no assigned agency for this shipcall. Shipcall ID: {loadedModel['id']}") # Forbidden: 403
return return
@staticmethod @staticmethod
def check_user_is_bsmd_or_agent_type(user_data): def check_user_is_bsmd_or_agent_type(user_data):
""" """
@ -226,7 +226,7 @@ class InputValidationShipcall():
check, whether the referenced entries exist (e.g., when a Ship ID is referenced, but does not exist, the validation fails) check, whether the referenced entries exist (e.g., when a Ship ID is referenced, but does not exist, the validation fails)
""" """
# #TODO: arrival and departure berth id should be coupled with the shipcall type. One shall not provide # #TODO: arrival and departure berth id should be coupled with the shipcall type. One shall not provide
# arrival berth id when the shipcall type is departure or vise versa. # arrival berth id when the shipcall type is departure or vise versa.
# a similar logic has already been implemented to the eta/etd or for the operation windows # a similar logic has already been implemented to the eta/etd or for the operation windows
# get all IDs from the loadedModel # get all IDs from the loadedModel
@ -238,23 +238,23 @@ class InputValidationShipcall():
valid_ship_id = check_if_ship_id_is_valid(ship_id=ship_id) valid_ship_id = check_if_ship_id_is_valid(ship_id=ship_id)
if not valid_ship_id: if not valid_ship_id:
raise ValidationError({"ship_id":f"provided an invalid ship id, which is not found in the database: {ship_id}"}) raise ValidationError({"ship_id":f"provided an invalid ship id, which is not found in the database: {ship_id}"})
valid_arrival_berth_id = check_if_berth_id_is_valid(berth_id=arrival_berth_id) valid_arrival_berth_id = check_if_berth_id_is_valid(berth_id=arrival_berth_id)
if not valid_arrival_berth_id: if not valid_arrival_berth_id:
raise ValidationError({"arrival_berth_id":f"provided an invalid arrival berth id, which is not found in the database: {arrival_berth_id}"}) raise ValidationError({"arrival_berth_id":f"provided an invalid arrival berth id, which is not found in the database: {arrival_berth_id}"})
valid_departure_berth_id = check_if_berth_id_is_valid(berth_id=departure_berth_id) valid_departure_berth_id = check_if_berth_id_is_valid(berth_id=departure_berth_id)
if not valid_departure_berth_id: if not valid_departure_berth_id:
raise ValidationError({"departure_berth_id":f"provided an invalid departure berth id, which is not found in the database: {departure_berth_id}"}) raise ValidationError({"departure_berth_id":f"provided an invalid departure berth id, which is not found in the database: {departure_berth_id}"})
valid_participant_ids = check_if_participant_ids_are_valid(participants=participants) valid_participant_ids = check_if_participant_ids_are_valid(participants=participants)
if not valid_participant_ids: if not valid_participant_ids:
raise ValidationError({"participants":f"one of the provided participant ids is invalid. Could not find one of these in the database: {participants}"}) raise ValidationError({"participants":f"one of the provided participant ids is invalid. Could not find one of these in the database: {participants}"})
valid_participant_types = check_if_participant_ids_and_types_are_valid(participants=participants) valid_participant_types = check_if_participant_ids_and_types_are_valid(participants=participants)
if not valid_participant_types: # #TODO: according to Daniel, there may eventually be multi-assignment of participants for the same role if not valid_participant_types: # #TODO: according to Daniel, there may eventually be multi-assignment of participants for the same role
raise ValidationError({"participants":f"every participant id and type should be listed only once. Found multiple entries for one of the participants."}) raise ValidationError({"participants":f"every participant id and type should be listed only once. Found multiple entries for one of the participants."})
@staticmethod @staticmethod
def check_shipcall_type_is_unchanged(loadedModel:dict): def check_shipcall_type_is_unchanged(loadedModel:dict):
# the type of a shipcall may only be set on POST requests. Afterwards, shipcall types may not be changed. # the type of a shipcall may only be set on POST requests. Afterwards, shipcall types may not be changed.
@ -264,20 +264,20 @@ class InputValidationShipcall():
if int(loadedModel["type"]) != int(shipcall.type): if int(loadedModel["type"]) != int(shipcall.type):
raise ValidationError({"type":f"The shipcall type may only be set in the initial POST-request. Afterwards, changing the shipcall type is not allowed."}) # @pytest.raises raise ValidationError({"type":f"The shipcall type may only be set in the initial POST-request. Afterwards, changing the shipcall type is not allowed."}) # @pytest.raises
return return
@staticmethod @staticmethod
def check_forbidden_arguments(content:dict, forbidden_keys=["evaluation", "evaluation_message"]): def check_forbidden_arguments(content:dict, forbidden_keys=["evaluation", "evaluation_message"]):
""" """
a post-request must not contain the arguments 'canceled', 'evaluation', 'evaluation_message'. a post-request must not contain the arguments 'canceled', 'evaluation', 'evaluation_message'.
a put-request must not contain the arguments 'evaluation', 'evaluation_message' a put-request must not contain the arguments 'evaluation', 'evaluation_message'
""" """
# the following keys should not be set in a POST-request. # the following keys should not be set in a POST-request.
for forbidden_key in forbidden_keys: for forbidden_key in forbidden_keys:
value = content.get(forbidden_key, None) value = content.get(forbidden_key, None)
if value is not None: if value is not None:
raise ValidationError({"forbidden_key":f"'{forbidden_key}' may not be set on POST. Found: {value}"}) raise ValidationError({"forbidden_key":f"'{forbidden_key}' may not be set on POST. Found: {value}"})
return return
@staticmethod @staticmethod
def check_required_fields_exist_based_on_type(loadedModel:dict, content:dict): def check_required_fields_exist_based_on_type(loadedModel:dict, content:dict):
@ -296,30 +296,30 @@ class InputValidationShipcall():
if int(type_)==int(ShipcallType.undefined): if int(type_)==int(ShipcallType.undefined):
raise ValidationError({"type":f"providing 'type' is mandatory. Missing key!"}) raise ValidationError({"type":f"providing 'type' is mandatory. Missing key!"})
# arrival: arrival_berth_id & eta must exist # arrival: arrival_berth_id & eta must exist
elif int(type_)==int(ShipcallType.arrival): elif int(type_)==int(ShipcallType.arrival):
if eta is None: if eta is None:
raise ValidationError({"eta":f"providing 'eta' is mandatory. Missing key!"}) raise ValidationError({"eta":f"providing 'eta' is mandatory. Missing key!"})
if arrival_berth_id is None: if arrival_berth_id is None:
raise ValidationError({"arrival_berth_id":f"providing 'arrival_berth_id' is mandatory. Missing key!"}) raise ValidationError({"arrival_berth_id":f"providing 'arrival_berth_id' is mandatory. Missing key!"})
# departure: departive_berth_id and etd must exist # departure: departive_berth_id and etd must exist
elif int(type_)==int(ShipcallType.departure): elif int(type_)==int(ShipcallType.departure):
if etd is None: if etd is None:
raise ValidationError({"etd":f"providing 'etd' is mandatory. Missing key!"}) raise ValidationError({"etd":f"providing 'etd' is mandatory. Missing key!"})
if departure_berth_id is None: if departure_berth_id is None:
raise ValidationError({"departure_berth_id":f"providing 'departure_berth_id' is mandatory. Missing key!"}) raise ValidationError({"departure_berth_id":f"providing 'departure_berth_id' is mandatory. Missing key!"})
# shifting: arrival_berth_id, departure_berth_id, eta and etd must exist # shifting: arrival_berth_id, departure_berth_id, eta and etd must exist
elif int(type_)==int(ShipcallType.shifting): elif int(type_)==int(ShipcallType.shifting):
if (eta is None) or (etd is None): if (eta is None) or (etd is None):
raise ValidationError({"eta_or_etd":f"providing 'eta' and 'etd' is mandatory. Missing one of those keys!"}) raise ValidationError({"eta_or_etd":f"providing 'eta' and 'etd' is mandatory. Missing one of those keys!"})
if (arrival_berth_id is None) or (departure_berth_id is None): if (arrival_berth_id is None) or (departure_berth_id is None):
raise ValidationError({"arrival_berth_id_or_departure_berth_id":f"providing 'arrival_berth_id' & 'departure_berth_id' is mandatory. Missing key!"}) raise ValidationError({"arrival_berth_id_or_departure_berth_id":f"providing 'arrival_berth_id' & 'departure_berth_id' is mandatory. Missing key!"})
else: else:
raise ValidationError({"type":f"incorrect 'type' provided!"}) raise ValidationError({"type":f"incorrect 'type' provided!"})
return return
@ -328,7 +328,7 @@ class InputValidationShipcall():
def check_times_are_in_future(loadedModel:dict, content:dict): def check_times_are_in_future(loadedModel:dict, content:dict):
""" """
Dates should be in the future. Depending on the ShipcallType, specific values should be checked Dates should be in the future. Depending on the ShipcallType, specific values should be checked
Perfornms datetime checks in the loadedModel (datetime.datetime objects). Perfornms datetime checks in the loadedModel (datetime.datetime objects).
""" """
# obtain the current datetime to check, whether the provided values are in the future # obtain the current datetime to check, whether the provided values are in the future
time_now = datetime.datetime.now() time_now = datetime.datetime.now()
@ -350,11 +350,11 @@ class InputValidationShipcall():
# Estimated arrival or departure times # Estimated arrival or departure times
InputValidationShipcall.check_times_in_future_based_on_type(type_, time_now, eta, etd) InputValidationShipcall.check_times_in_future_based_on_type(type_, time_now, eta, etd)
# Tidal Window # Tidal Window
InputValidationShipcall.check_tidal_window_in_future(type_, time_now, tidal_window_from, tidal_window_to) InputValidationShipcall.check_tidal_window_in_future(type_, time_now, tidal_window_from, tidal_window_to)
return return
@staticmethod @staticmethod
def check_times_in_future_based_on_type(type_, time_now, eta, etd): def check_times_in_future_based_on_type(type_, time_now, eta, etd):
""" """
@ -366,7 +366,7 @@ class InputValidationShipcall():
""" """
if (eta is None) and (etd is None): if (eta is None) and (etd is None):
return return
if type_ is None: if type_ is None:
raise ValidationError({"type":f"when providing 'eta' or 'etd', one must provide the type of the shipcall, so the datetimes can be verified."}) raise ValidationError({"type":f"when providing 'eta' or 'etd', one must provide the type of the shipcall, so the datetimes can be verified."})
@ -412,7 +412,7 @@ class InputValidationShipcall():
if (eta is not None and etd is None) or (eta is None and etd is not None): if (eta is not None and etd is None) or (eta is None and etd is not None):
raise ValidationError({"eta_or_etd":f"'eta' and 'etd' must both be provided when the shipcall type is 'shifting'."}) raise ValidationError({"eta_or_etd":f"'eta' and 'etd' must both be provided when the shipcall type is 'shifting'."})
return return
@staticmethod @staticmethod
def check_tidal_window_in_future(type_, time_now, tidal_window_from, tidal_window_to): def check_tidal_window_in_future(type_, time_now, tidal_window_from, tidal_window_to):
if tidal_window_to is not None: if tidal_window_to is not None:
@ -422,12 +422,12 @@ class InputValidationShipcall():
if tidal_window_from is not None: if tidal_window_from is not None:
if not tidal_window_from >= time_now: if not tidal_window_from >= time_now:
raise ValidationError({"tidal_window_from":f"'tidal_window_from' must be in the future. Incorrect datetime provided."}) raise ValidationError({"tidal_window_from":f"'tidal_window_from' must be in the future. Incorrect datetime provided."})
if (tidal_window_to is not None) and (tidal_window_from is not None): if (tidal_window_to is not None) and (tidal_window_from is not None):
if tidal_window_to < tidal_window_from: if tidal_window_to < tidal_window_from:
raise ValidationError({"tidal_window_to_or_tidal_window_from":f"'tidal_window_to' must take place after 'tidal_window_from'. Incorrect datetime provided. Found 'tidal_window_to': {tidal_window_to}, 'tidal_window_from': {tidal_window_to}."}) raise ValidationError({"tidal_window_to_or_tidal_window_from":f"'tidal_window_to' must take place after 'tidal_window_from'. Incorrect datetime provided. Found 'tidal_window_to': {tidal_window_to}, 'tidal_window_from': {tidal_window_to}."})
return return
@staticmethod @staticmethod
def check_participant_list_not_empty_when_user_is_agency(loadedModel): def check_participant_list_not_empty_when_user_is_agency(loadedModel):
""" """
@ -436,10 +436,10 @@ class InputValidationShipcall():
participants = loadedModel.get("participants", []) participants = loadedModel.get("participants", [])
is_agency_participant = [ParticipantType.AGENCY in ParticipantType(participant.get("type")) for participant in participants] is_agency_participant = [ParticipantType.AGENCY in ParticipantType(participant.get("type")) for participant in participants]
if not any(is_agency_participant): if not any(is_agency_participant):
raise ValidationError({"participants":f"One of the assigned participants *must* be of type 'ParticipantType.AGENCY'. Found list of participants: {participants}"}) raise ValidationError({"participants":f"One of the assigned participants *must* be of type 'ParticipantType.AGENCY'. Found list of participants: {participants}"})
return return
@staticmethod @staticmethod
def check_shipcall_is_canceled(loadedModel, content): def check_shipcall_is_canceled(loadedModel, content):
# read the shipcall_id from the PUT data # read the shipcall_id from the PUT data
@ -455,13 +455,13 @@ class InputValidationShipcall():
if shipcall.get("canceled", False): if shipcall.get("canceled", False):
raise ValidationError({"canceled":f"The shipcall with id 'shipcall_id' is canceled. A canceled shipcall may not be changed."}) raise ValidationError({"canceled":f"The shipcall with id 'shipcall_id' is canceled. A canceled shipcall may not be changed."})
return return
@staticmethod @staticmethod
def check_required_fields_of_put_request(content:dict): def check_required_fields_of_put_request(content:dict):
shipcall_id = content.get("id", None) shipcall_id = content.get("id", None)
if shipcall_id is None: if shipcall_id is None:
raise ValidationError({"id":f"A PUT request requires an 'id' to refer to."}) raise ValidationError({"id":f"A PUT request requires an 'id' to refer to."})
@staticmethod @staticmethod
def check_shipcall_id_exists(loadedModel): def check_shipcall_id_exists(loadedModel):
"""simply checks, whether the defined shipcall ID exists in the database. Otherwise, a PUT-request must fail.""" """simply checks, whether the defined shipcall ID exists in the database. Otherwise, a PUT-request must fail."""
@ -483,8 +483,8 @@ class InputValidationShipcall():
a) belong to the ASSIGNED agency participant group a) belong to the ASSIGNED agency participant group
b) belong to a BSMD participant, if the assigned agency has enabled the bit flag b) belong to a BSMD participant, if the assigned agency has enabled the bit flag
When there is not yet an assigned agency for the respective shipcall, only BSMD users are authorized. When there is not yet an assigned agency for the respective shipcall, only BSMD users are authorized.
This mechanism prevents self-assignment of an agency to arbitrary shipcalls. This mechanism prevents self-assignment of an agency to arbitrary shipcalls.
""" """
### preparation ### ### preparation ###
# use the decoded JWT token and extract the participant type & participant id # use the decoded JWT token and extract the participant type & participant id
@ -521,7 +521,7 @@ class InputValidationShipcall():
### USER authority ### ### USER authority ###
# determine, whether the user is a) the assigned agency or b) a BSMD participant # determine, whether the user is a) the assigned agency or b) a BSMD participant
user_is_assigned_agency = (user_participant_id == assigned_agency.participant_id) user_is_assigned_agency = (user_participant_id == assigned_agency.id)
# when the BSMD flag is set: the user must be either BSMD or the assigned agency # when the BSMD flag is set: the user must be either BSMD or the assigned agency
# when the BSMD flag is not set: the user must be the assigned agency # when the BSMD flag is not set: the user must be the assigned agency
@ -529,7 +529,7 @@ class InputValidationShipcall():
if not user_is_authorized: if not user_is_authorized:
raise werkzeug.exceptions.Forbidden(f"PUT Requests for shipcalls can only be issued by an assigned AGENCY or BSMD users (if the special-flag is enabled). Assigned Agency: {assigned_agency} with Flags: {assigned_agency.flags}") # Forbidden: 403 raise werkzeug.exceptions.Forbidden(f"PUT Requests for shipcalls can only be issued by an assigned AGENCY or BSMD users (if the special-flag is enabled). Assigned Agency: {assigned_agency} with Flags: {assigned_agency.flags}") # Forbidden: 403
else: else:
# when there is no assigned agency, only BSMD users can update the shipcall # when there is no assigned agency, only BSMD users can update the shipcall
if not user_is_bsmd: if not user_is_bsmd:
@ -537,4 +537,3 @@ class InputValidationShipcall():
return return