diff --git a/src/server/BreCal/api/shipcalls.py b/src/server/BreCal/api/shipcalls.py index fbf552a..61a7a7c 100644 --- a/src/server/BreCal/api/shipcalls.py +++ b/src/server/BreCal/api/shipcalls.py @@ -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) diff --git a/src/server/BreCal/database/enums.py b/src/server/BreCal/database/enums.py index 90726b3..5d28e65 100644 --- a/src/server/BreCal/database/enums.py +++ b/src/server/BreCal/database/enums.py @@ -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 + diff --git a/src/server/BreCal/database/sql_handler.py b/src/server/BreCal/database/sql_handler.py index 59497e3..929f558 100644 --- a/src/server/BreCal/database/sql_handler.py +++ b/src/server/BreCal/database/sql_handler.py @@ -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 diff --git a/src/server/BreCal/impl/shipcalls.py b/src/server/BreCal/impl/shipcalls.py index 8d15983..252c817 100644 --- a/src/server/BreCal/impl/shipcalls.py +++ b/src/server/BreCal/impl/shipcalls.py @@ -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()") diff --git a/src/server/BreCal/local_db.py b/src/server/BreCal/local_db.py index 4293ff3..b4e02cd 100644 --- a/src/server/BreCal/local_db.py +++ b/src/server/BreCal/local_db.py @@ -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) diff --git a/src/server/BreCal/schemas/model.py b/src/server/BreCal/schemas/model.py index 0df42c8..33638fd 100644 --- a/src/server/BreCal/schemas/model.py +++ b/src/server/BreCal/schemas/model.py @@ -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 "", + } diff --git a/src/server/BreCal/services/auth_guard.py b/src/server/BreCal/services/auth_guard.py index 9c5824d..9bd27c6 100644 --- a/src/server/BreCal/services/auth_guard.py +++ b/src/server/BreCal/services/auth_guard.py @@ -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: diff --git a/src/server/BreCal/services/jwt_handler.py b/src/server/BreCal/services/jwt_handler.py index 66c0f43..b3c80e7 100644 --- a/src/server/BreCal/services/jwt_handler.py +++ b/src/server/BreCal/services/jwt_handler.py @@ -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"]) diff --git a/src/server/BreCal/stubs/shipcall.py b/src/server/BreCal/stubs/shipcall.py index 2e4e154..3f5c062 100644 --- a/src/server/BreCal/stubs/shipcall.py +++ b/src/server/BreCal/stubs/shipcall.py @@ -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, diff --git a/src/server/BreCal/validators/input_validation.py b/src/server/BreCal/validators/input_validation.py index 188e5e4..2248779 100644 --- a/src/server/BreCal/validators/input_validation.py +++ b/src/server/BreCal/validators/input_validation.py @@ -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): diff --git a/src/server/requirements.txt b/src/server/requirements.txt index 8b7f3ad..23bf276 100644 --- a/src/server/requirements.txt +++ b/src/server/requirements.txt @@ -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/. diff --git a/src/server/tests/test_create_app.py b/src/server/tests/test_create_app.py index 652ae3d..46ede88 100644 --- a/src/server/tests/test_create_app.py +++ b/src/server/tests/test_create_app.py @@ -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) diff --git a/src/server/tests/validators/test_validation_rule_functions.py b/src/server/tests/validators/test_validation_rule_functions.py index fdd8152..3a08735 100644 --- a/src/server/tests/validators/test_validation_rule_functions.py +++ b/src/server/tests/validators/test_validation_rule_functions.py @@ -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()