setting up a local mysql database and running the API locally, which requires slight adaptations. Implementing input validation for POST requests of shipcalls and adapting enumerators, as well as data models.

This commit is contained in:
Max Metz 2024-04-24 08:26:37 +02:00
parent fcb889d2bc
commit b32b466f74
13 changed files with 244 additions and 33 deletions

View File

@ -3,7 +3,8 @@ from webargs.flaskparser import parser
from marshmallow import Schema, fields, ValidationError
from ..schemas import model
from .. import impl
from ..services.auth_guard import auth_guard
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
import logging
import json
@ -40,6 +41,26 @@ def PostShipcalls():
try:
content = request.get_json(force=True)
loadedModel = model.ShipcallSchema().load(data=content, many=False, partial=True)
# 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 ...
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)
"""
except ValidationError as ex:
logging.error(ex)

View File

@ -2,7 +2,7 @@ from enum import IntEnum, Enum, IntFlag
class ParticipantType(IntFlag):
"""determines the type of a participant"""
NONE = 0
undefined = 0
BSMD = 1
TERMINAL = 2
PILOT = 4
@ -13,10 +13,15 @@ class ParticipantType(IntFlag):
class ShipcallType(IntEnum):
"""determines the type of a shipcall, as this changes the applicable validation rules"""
undefined = 0
INCOMING = 1
OUTGOING = 2
SHIFTING = 3
@classmethod
def _missing_(cls, value):
return cls.undefined
class ParticipantwiseTimeDelta():
"""stores the time delta for every participant, which triggers the validation rules in the rule set '0001'"""
AGENCY = 1200.0 # 20 h * 60 min/h = 1200 min
@ -42,10 +47,23 @@ class PierSide(IntEnum):
"""These enumerators determine the pier side of a shipcall."""
PORTSIDE = 0 # Port/Backbord
STARBOARD_SIDE = 1 # Starboard / Steuerbord
class NotificationType(IntFlag):
"""determines the method by which a notification is distributed to users. Flagging allows selecting multiple notification types."""
UNDEFINED = 0
EMAIL = 1
POPUP = 2
MESSENGER = 4
class ParticipantFlag(IntFlag):
"""
| 1 | If this flag is set on a shipcall record with participant type Agency (8),
all participants of type BSMD (1) may edit the record.
"""
undefined = 0
BSMD = 1
@classmethod
def _missing_(cls, value):
return cls.undefined

View File

@ -2,7 +2,7 @@ import numpy as np
import pandas as pd
import datetime
import typing
from BreCal.schemas.model import Shipcall, Ship, Participant, Berth, User, Times
from BreCal.schemas.model import Shipcall, Ship, Participant, Berth, User, Times, ShipcallParticipantMap
from BreCal.database.enums import ParticipantType
def pandas_series_to_data_model():
@ -50,7 +50,8 @@ class SQLHandler():
'ship'->BreCal.schemas.model.Ship object
"""
self.str_to_model_dict = {
"shipcall":Shipcall, "ship":Ship, "participant":Participant, "berth":Berth, "user":User, "times":Times
"shipcall":Shipcall, "ship":Ship, "participant":Participant, "berth":Berth, "user":User, "times":Times,
"shipcall_participant_map":ShipcallParticipantMap
}
return
@ -70,12 +71,16 @@ class SQLHandler():
data = [{k:v for k,v in zip(column_names, dat)} for dat in data]
# 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)
return df
def build_df_from_data_and_name(self, data, table_name):
data_model = self.str_to_model_dict.get(table_name)
if data_model is not None:
df = pd.DataFrame([data_model(**dat) for dat in data])
df = pd.DataFrame([data_model(**dat) for dat in data], columns=list(data_model.__annotations__.keys()))
else:
df = pd.DataFrame([dat for dat in data])
return df
return df
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"""
@ -94,11 +99,7 @@ class SQLHandler():
# 4.) build a dataframe from the respective data models (which ensures the correct data type)
data_model = self.str_to_model_dict.get(table_name)
if data_model is not None:
df = pd.DataFrame([data_model(**dat) for dat in data])
else:
df = pd.DataFrame([dat for dat in data])
df = self.build_df_from_data_and_name(data, table_name)
if 'id' in df.columns:
df = df.set_index('id', inplace=False) # avoid inplace updates, so the raw sql remains unchanged
return df

View File

@ -60,8 +60,13 @@ def PostShipcalls(schemaModel):
"""
:param schemaModel: The deserialized dict of the request
e.g.,
{
'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}
}
"""
# TODO: Validate the upload data
# This creates a *new* entry
@ -133,7 +138,6 @@ def PostShipcalls(schemaModel):
# if not full_id_existances:
# pooledConnection.close()
# return json.dumps({"message" : "call failed. missing mandatory keywords."}), 500, {'Content-Type': 'application/json; charset=utf-8'}
commands.execute(query, schemaModel)
new_id = commands.execute_scalar("select last_insert_id()")

View File

@ -7,11 +7,11 @@ import sys
config_path = None
def initPool(instancePath):
def initPool(instancePath, connection_filename="connection_data_devel.json"):
try:
global config_path
if(config_path == None):
config_path = os.path.join(instancePath,'../../../secure/connection_data_devel.json');
config_path = os.path.join(instancePath,f'../../../../secure/{connection_filename}') #connection_data_devel.json');
print (config_path)

View File

@ -10,6 +10,9 @@ from typing import List
import json
import datetime
from BreCal.validators.time_logic import validate_time_exceeds_threshold
from BreCal.database.enums import ParticipantType, ParticipantFlag
# from BreCal. ... import check_if_user_is_bsmd_type
def obj_dict(obj):
if isinstance(obj, datetime.datetime):
@ -54,6 +57,7 @@ class NotificationType(IntEnum):
undefined = 0
email = 1
push = 2
@classmethod
def _missing_(cls, value):
return cls.undefined
@ -143,12 +147,34 @@ class Participant(Schema):
street: str
postal_code: str
city: str
type: int
type: int # fields.Enum(ParticipantType ...)
flags: int
created: datetime
modified: datetime
deleted: bool
@validates("type")
def validate_type(self, value):
# e.g., when an IntFlag has the values 1,2,4; the maximum valid value is 7
max_int = sum([int(val) for val in list(ParticipantType._value2member_map_.values())])
min_int = 0
valid_type = 0 <= value < max_int
if not valid_type:
raise ValidationError(f"the provided integer is not supported for default behaviour of the ParticipantType IntFlag. Your choice: {value}. Supported values are: 0 <= value {max_int}")
@validates("flags")
def validate_type(self, value):
# e.g., when an IntFlag has the values 1,2,4; the maximum valid value is 7
max_int = sum([int(val) for val in list(ParticipantFlag._value2member_map_.values())])
min_int = 0
valid_type = 0 <= value < max_int
if not valid_type:
raise ValidationError(f"the provided integer is not supported for default behaviour of the ParticipantFlag IntFlag. Your choice: {value}. Supported values are: 0 <= value {max_int}")
class ParticipantList(Participant):
pass
@ -163,7 +189,8 @@ class ShipcallSchema(Schema):
id = fields.Integer()
ship_id = fields.Integer()
type = fields.Integer()
#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
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)
@ -196,15 +223,23 @@ class ShipcallSchema(Schema):
@post_load
def make_shipcall(self, data, **kwargs):
if 'type' in data:
data['type_value'] = data['type'].value
data['type_value'] = int(data['type'])
else:
data['type_value'] = ShipcallType.undefined
data['type_value'] = int(ShipcallType.undefined)
if 'evaluation' in data:
if data['evaluation']:
data['evaluation_value'] = data['evaluation'].value
data['evaluation_value'] = int(data['evaluation'])
else:
data['evaluation_value'] = EvaluationType.undefined
data['evaluation_value'] = int(EvaluationType.undefined)
return data
@validates("type")
def validate_type(self, value):
valid_shipcall_type = int(value) in [item.value for item in ShipcallType]
if not valid_shipcall_type:
raise ValidationError(f"the provided type is not a valid shipcall type.")
@dataclass
class Participant_Assignment:
@ -321,7 +356,7 @@ class TimesSchema(Schema):
berth_info = fields.String(metadata={'required':False}, allow_none=True, validate=[validate.Length(max=256)])
pier_side = fields.Bool(metadata={'required':False}, allow_none = True)
shipcall_id = fields.Integer(metadata={'required':True})
participant_type = fields.Integer(metadata={'required':False}, allow_none=True)
participant_type = fields.Enum(ParticipantType, metadata={'required':False}, allow_none=True, default=ParticipantType.undefined) #fields.Integer(metadata={'required':False}, allow_none=True)
ata = fields.DateTime(metadata={'required':False}, allow_none=True)
atd = fields.DateTime(metadata={'required':False}, allow_none=True)
eta_interval_end = fields.DateTime(metadata={'required':False}, allow_none=True)
@ -463,3 +498,22 @@ class Shipcalls(Shipcall):
class TimesList(Times):
pass
@dataclass
class ShipcallParticipantMap:
id: int
shipcall_id: int
participant_id: int
type : ShipcallType
created: datetime
modified: datetime
def to_json(self):
return {
"id": self.id,
"shipcall_id": self.shipcall_id,
"participant_id": self.participant_id,
"type": self.type.name,
"created": self.created.isoformat() if self.created else "",
"modified": self.modified.isoformat() if self.modified else "",
}

View File

@ -9,6 +9,7 @@ def check_jwt():
if not token:
raise Exception('Missing access token')
jwt = token.split('Bearer ')[1]
try:
return decode_jwt(jwt)
except Exception as e:

View File

@ -7,11 +7,26 @@ def create_api_key():
return secrets.token_urlsafe(16)
def generate_jwt(payload, lifetime=None):
"""
creates an encoded token, which is based on the 'SECRET_KEY' environment variable. The environment variable
is set when the .wsgi application is started or can theoretically be set on system-level.
args:
payload:
json-dictionary with key:value pairs.
lifetime:
When a 'lifetime' (integer) is provided, the payload will be extended by an expiration key 'exp', which is
valid for the next {lifetime} minutes.
returns: token, a JWT-encoded string
"""
if lifetime:
payload['exp'] = (datetime.datetime.now() + datetime.timedelta(minutes=lifetime)).timestamp()
return jwt.encode(payload, os.environ.get('SECRET_KEY'), algorithm="HS256")
def decode_jwt(token):
"""this function reverts the {generate_jwt} function. An encoded JWT token is decoded into a JSON dictionary."""
return jwt.decode(token, os.environ.get('SECRET_KEY'), algorithms=["HS256"])

View File

@ -7,21 +7,21 @@ def get_shipcall_simple():
# only used for the stub
base_time = datetime.datetime.now()
shipcall_id = generate_uuid1_int()
ship_id = generate_uuid1_int()
shipcall_id = 124 # generate_uuid1_int()
ship_id = 5 # generate_uuid1_int()
eta = base_time+datetime.timedelta(hours=3, minutes=12)
role_type = 1
voyage = "987654321"
etd = base_time+datetime.timedelta(hours=6, minutes=12) # should never be before eta
arrival_berth_id = generate_uuid1_int()
departure_berth_id = generate_uuid1_int()
arrival_berth_id = 140 #generate_uuid1_int()
departure_berth_id = 140 #generate_uuid1_int()
tug_required = False
pilot_required = False
flags = 0 # #TODO_shipcall_flags. What is meant here? What should be tested?
flags = 0 # #TODO_shipcall_flags. What is meant here? What should be tested?
pier_side = False # whether a ship will be fixated on the pier side. en: pier side, de: Anlegestelle. From 'BremenCalling_Datenmodell.xlsx': gedreht/ungedreht
bunkering = False # #TODO_bunkering_unclear
replenishing_terminal = False # en: replenishing terminal, de: Nachfüll-Liegeplatz
@ -38,11 +38,13 @@ def get_shipcall_simple():
anchored = False
moored_lock = False # de: 'Festmacherschleuse', en: 'moored lock'
canceled = False
time_ref_point = 0
evaluation = None
evaluation_message = ""
evaluation_time = None
evaluation_notifications_sent = None
evaluation_time = datetime.datetime.now()
evaluation_notifications_sent = False
created = datetime.datetime.now()
modified = created+datetime.timedelta(seconds=10)
@ -76,6 +78,7 @@ def get_shipcall_simple():
evaluation_message,
evaluation_time,
evaluation_notifications_sent,
time_ref_point,
created,
modified,
participants,

View File

@ -1,8 +1,91 @@
####################################### InputValidation #######################################
import json
from abc import ABC, abstractmethod
from BreCal.schemas.model import Ship, Shipcall, Berth, User, Participant
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 get_participant_id_dictionary():
# get all participants
response,status_code,header = GetParticipant(options={})
# build a dictionary of id:item pairs, so one can select the respective participant
participants = json.loads(response)
participants = {items.get("id"):items for items in participants}
return participants
def get_ship_id_dictionary():
# get all ships
response,status_code,header = GetShips(token=None)
# build a dictionary of id:item pairs, so one can select the respective participant
ships = json.loads(response)
ships = {items.get("id"):items for items in ships}
return ships
def get_berth_id_dictionary():
# get all berths
response,status_code,header = GetBerths(token=None)
# build a dictionary of id:item pairs, so one can select the respective participant
berths = json.loads(response)
berths = {items.get("id"):items for items in berths}
return berths
def check_if_user_is_bsmd_type(user_data:dict)->bool:
"""
given a dictionary of user data, determine the respective participant id and read, whether
that participant is a .BSMD-type
Note: ParticipantType is an IntFlag.
Hence, ParticipantType(1) is ParticipantType.BSMD,
and ParticipantType(7) is [ParticipantType.BSMD, ParticipantType.TERMINAL, ParticipantType.PILOT]
both would return 'True'
returns: boolean. Whether the participant id is a .BSMD type element
"""
# user_data = decode token
participant_id = user_data.get("participant_id")
# build a dictionary of id:item pairs, so one can select the respective participant
participants = get_participant_id_dictionary()
participant = participants.get(participant_id,{})
# boolean check: is the participant of type .BSMD?
is_bsmd = ParticipantType.BSMD in ParticipantType(participant.get("type",0))
return is_bsmd
def check_if_user_data_has_valid_ship_id(ship_id):
# build a dictionary of id:item pairs, so one can select the respective participant
ships = get_ship_id_dictionary()
# boolean check
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):
# build a dictionary of id:item pairs, so one can select the respective participant
berths = get_berth_id_dictionary()
# boolean check
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):
# build a dictionary of id:item pairs, so one can select the respective participant
participants = get_participant_id_dictionary()
# boolean check
participant_id_is_valid = participant_id in list(participants.keys())
return participant_id_is_valid
class InputValidation():
def __init__(self):

View File

@ -8,6 +8,7 @@ webargs==6.1.1
Werkzeug==1.0.1
pydapper[mysql-connector-python]
marshmallow-dataclass
marshmallow-enum
bcrypt
pyjwt
flask-jwt-extended
@ -20,4 +21,4 @@ pytest
pytest-cov
coverage
../server/.
-e ../server/.

View File

@ -8,7 +8,7 @@ def test_create_app():
import sys
from BreCal import get_project_root
project_root = get_project_root("brecal")
project_root = os.path.join(os.path.expanduser("~"), "brecal")
lib_location = os.path.join(project_root, "src", "server")
sys.path.append(lib_location)

View File

@ -12,7 +12,17 @@ from BreCal.stubs.df_times import get_df_times, random_time_perturbation, get_df
@pytest.fixture(scope="session")
def build_sql_proxy_connection():
import mysql.connector
conn_from_pool = mysql.connector.connect(**{'host':'localhost', 'port':3306, 'user':'root', 'password':'HalloWach_2323XXL!!', 'pool_name':'brecal_pool', 'pool_size':20, 'database':'bremen_calling', 'autocommit': True})
import os
import json
connection_data_path = os.path.join(os.path.expanduser("~"),"secure","connection_data_local.json")
assert os.path.exists(connection_data_path)
with open(connection_data_path, "r") as jr:
connection_data = json.load(jr)
connection_data = {k:v for k,v in connection_data.items() if k in ["host", "port", "user", "password", "pool_size", "pool_name", "database"]}
conn_from_pool = mysql.connector.connect(**connection_data)
#conn_from_pool = mysql.connector.connect(**{'host':'localhost', 'port':3306, 'user':'root', 'password':'HalloWach_2323XXL!!', 'pool_name':'brecal_pool', 'pool_size':20, 'database':'bremen_calling_local', 'autocommit': True})
sql_handler = SQLHandler(sql_connection=conn_from_pool, read_all=True)
vr = ValidationRules(sql_handler)
return locals()