Compare commits

..

No commits in common. "feature/transition_to_boolean" and "develop" have entirely different histories.

30 changed files with 123 additions and 519 deletions

2
.gitignore vendored
View File

@ -289,5 +289,3 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
src/notebooks_metz/ src/notebooks_metz/
src/server/editable_requirements.txt src/server/editable_requirements.txt
schemathesis_report.html

View File

@ -95,14 +95,6 @@ paths:
$ref: '#/components/responses/400' $ref: '#/components/responses/400'
'401': '401':
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
'404':
description: Not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error_field: no such record
'500': '500':
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
'503': '503':
@ -200,8 +192,6 @@ paths:
$ref: '#/components/responses/400' $ref: '#/components/responses/400'
'401': '401':
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
'409':
$ref: '#/components/responses/409'
'500': '500':
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
'503': '503':
@ -258,8 +248,6 @@ paths:
$ref: '#/components/responses/400' $ref: '#/components/responses/400'
'401': '401':
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
'409':
$ref: '#/components/responses/409'
'500': '500':
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
'503': '503':
@ -315,10 +303,6 @@ paths:
$ref: '#/components/responses/400' $ref: '#/components/responses/400'
'401': '401':
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'409':
$ref: '#/components/responses/409'
'500': '500':
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
'503': '503':
@ -422,10 +406,6 @@ paths:
$ref: '#/components/responses/400' $ref: '#/components/responses/400'
'401': '401':
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'409':
$ref: '#/components/responses/409'
'500': '500':
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
'503': '503':
@ -451,8 +431,6 @@ paths:
$ref: '#/components/responses/400' $ref: '#/components/responses/400'
'401': '401':
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'500': '500':
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
'503': '503':
@ -470,10 +448,7 @@ paths:
required: false required: false
description: '**Id of user**. *Example: 2*. User id returned by login call.' description: '**Id of user**. *Example: 2*. User id returned by login call.'
schema: schema:
oneOf: type: integer
- type: integer
- type: string
pattern: '^[0-9]+$'
example: 2 example: 2
responses: responses:
'200': '200':
@ -513,10 +488,7 @@ paths:
in: query in: query
description: '**Id**. *Example: 42*. Id of referenced ship call.' description: '**Id**. *Example: 42*. Id of referenced ship call.'
schema: schema:
oneOf: type: integer
- type: integer
- type: string
pattern: '^[0-9]+$'
example: 42 example: 42
example: 42 example: 42
responses: responses:
@ -650,8 +622,6 @@ paths:
$ref: '#/components/responses/400' $ref: '#/components/responses/400'
'401': '401':
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'500': '500':
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
'503': '503':
@ -676,8 +646,6 @@ paths:
$ref: '#/components/responses/400' $ref: '#/components/responses/400'
'401': '401':
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
'404':
$ref: '#/components/responses/404'
'500': '500':
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
'503': '503':
@ -695,11 +663,7 @@ paths:
required: false required: false
description: '**Id of participant**. *Example: 7*. Id of logged in participant.' description: '**Id of participant**. *Example: 7*. Id of logged in participant.'
schema: schema:
oneOf: $ref: '#/components/schemas/participant_id'
- type: integer
- type: string
pattern: '^[0-9]+$'
example: 7
responses: responses:
'200': '200':
description: notification list description: notification list
@ -836,14 +800,10 @@ components:
properties: properties:
username: username:
type: string type: string
minLength: 1
pattern: "\\S"
example: alfred example: alfred
password: password:
type: string type: string
format: password format: password
minLength: 1
pattern: "\\S"
example: '123456' example: '123456'
required: required:
- username - username
@ -879,7 +839,6 @@ components:
shipcall: shipcall:
type: object type: object
description: Ship call data description: Ship call data
additionalProperties: false
example: example:
id: 6 id: 6
ship_id: 8 ship_id: 8
@ -999,8 +958,6 @@ components:
nullable: true nullable: true
recommended_tugs: recommended_tugs:
type: integer type: integer
minimum: 0
maximum: 10
example: 2 example: 2
nullable: true nullable: true
anchored: anchored:
@ -1434,7 +1391,6 @@ components:
type: integer type: integer
nullable: true nullable: true
example: 1234567 example: 1234567
description: International Maritime Organization number, must be unique across all ships
callsign: callsign:
type: string type: string
maxLength: 8 maxLength: 8
@ -1670,17 +1626,14 @@ components:
street: street:
type: string type: string
maxLength: 128 maxLength: 128
nullable: true
example: Hermann-Hollerith-Str. 7 example: Hermann-Hollerith-Str. 7
postal code: postal code:
type: string type: string
maxLength: 5 maxLength: 5
nullable: true
example: '28359' example: '28359'
city: city:
type: string type: string
maxLength: 64 maxLength: 64
nullable: true
example: Bremen example: Bremen
type: type:
type: integer type: integer
@ -1748,25 +1701,19 @@ components:
example: 5 example: 5
first_name: first_name:
type: string type: string
maxLength: 45
example: John example: John
last_name: last_name:
type: string type: string
maxLength: 45
example: Doe example: Doe
user_name: user_name:
type: string type: string
maxLength: 45
example: johndoe example: johndoe
user_phone: user_phone:
type: string type: string
nullable: true nullable: true
maxLength: 32
example: '1234567890' example: '1234567890'
user_email: user_email:
type: string type: string
format: email
maxLength: 64
nullable: true nullable: true
example: no@where.com example: no@where.com
notify_email: notify_email:
@ -1839,14 +1786,13 @@ components:
maxLength: 45 maxLength: 45
example: Doe example: Doe
user_phone: user_phone:
maxLength: 32 maxLength: 128
type: string type: string
nullable: true nullable: true
example: '1234567890' example: '1234567890'
user_email: user_email:
maxLength: 64 maxLength: 128
type: string type: string
format: email
nullable: true nullable: true
example: no@where.com example: no@where.com
notify_email: notify_email:
@ -2071,17 +2017,8 @@ components:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
error_field: No such record error_field: shipcall_id
error_description: The requested resource to update was not found error_description: Ship call not found
'409':
description: Conflict
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
error_field: imo
error_description: Resource already exists
'500': '500':
description: Unexpected error description: Unexpected error
content: content:

View File

@ -19,9 +19,3 @@ Das Ganze funktioniert nur, wenn auch schemathesis und hypothesis in den passend
Aktuell habe ich schemathesis ("latest") und hypothesis 6.120.0: Aktuell habe ich schemathesis ("latest") und hypothesis 6.120.0:
```pip install "hypothesis==6.120.0"``` ```pip install "hypothesis==6.120.0"```
Das muss wegen dependencies so blöd gepinnt werden. Das muss wegen dependencies so blöd gepinnt werden.
Damit pytest die API findet muss API_BASE_URL als Umgebungsvariable gesetzt werden. In Powershell z.B. so:
```powershell
$env:API_BASE_URL = "http://localhost:5000"
```

View File

@ -35,4 +35,3 @@ typing_extensions==4.12.2
tzdata==2024.1 tzdata==2024.1
webargs==8.6.0 webargs==8.6.0
Werkzeug==3.0.4 Werkzeug==3.0.4
ntlm-auth==1.5.0

View File

@ -16,7 +16,6 @@ from .api import user
from .api import history from .api import history
from .api import ports from .api import ports
from BreCal.schemas import defs
from BreCal.brecal_utils.file_handling import get_project_root, ensure_path from BreCal.brecal_utils.file_handling import get_project_root, ensure_path
from BreCal.brecal_utils.test_handling import execute_test_with_pytest, execute_coverage_test from BreCal.brecal_utils.test_handling import execute_test_with_pytest, execute_coverage_test
from BreCal.brecal_utils.time_handling import difference_to_then from BreCal.brecal_utils.time_handling import difference_to_then
@ -73,39 +72,21 @@ def create_app(test_config=None, instance_path=None):
app.register_blueprint(ports.bp) app.register_blueprint(ports.bp)
log_level = getattr(logging, app.config.get("LOG_LEVEL", "DEBUG")) log_level = getattr(logging, app.config.get("LOG_LEVEL", "DEBUG"))
log_format = "%(asctime)s [%(levelname)s] %(message)s" log_kwargs = {"format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s"}
root_logger = logging.getLogger()
for handler in list(root_logger.handlers):
root_logger.removeHandler(handler)
if app.config.get("LOG_TO_STDERR"): if app.config.get("LOG_TO_STDERR"):
handler = logging.StreamHandler(sys.stderr) log_kwargs["stream"] = sys.stderr
else: else:
log_file = app.config.get("LOG_FILE", "brecaltest.log") log_kwargs["filename"] = app.config.get("LOG_FILE", "brecaltest.log")
if not os.path.isabs(log_file): logging.basicConfig(level=log_level, **log_kwargs)
log_file = os.path.join(app.instance_path, log_file)
log_dir = os.path.dirname(log_file)
if log_dir and not os.path.exists(log_dir):
os.makedirs(log_dir, exist_ok=True)
handler = logging.FileHandler(log_file)
handler.setFormatter(logging.Formatter(log_format, datefmt="%d.%m.%Y %H:%M:%S"))
root_logger.addHandler(handler)
root_logger.setLevel(log_level)
if app.config.get("SECRET_KEY"): if app.config.get("SECRET_KEY"):
os.environ["SECRET_KEY"] = app.config["SECRET_KEY"] os.environ["SECRET_KEY"] = app.config["SECRET_KEY"]
defs.SMTP_DEBUG_LEVEL = app.config.get("SMTP_DEBUG_LEVEL", defs.SMTP_DEBUG_LEVEL)
local_db.initPool(os.path.dirname(app.instance_path), config=app.config) local_db.initPool(os.path.dirname(app.instance_path), config=app.config)
logging.info('App started') logging.info('App started')
# Setup Routine jobs (e.g., reevaluation of shipcalls) # Setup Routine jobs (e.g., reevaluation of shipcalls)
setup_schedule( setup_schedule(update_shipcalls_interval_in_minutes=app.config.get("SCHEDULE_UPDATE_SHIPCALLS_MINUTES", 60))
update_shipcalls_interval_in_minutes=app.config.get("SCHEDULE_UPDATE_SHIPCALLS_MINUTES", 60),
notification_cooldown_mins=app.config.get("NOTIFICATION_COOLDOWN_MINS", defs.NOTIFICATION_COOLDOWN_MINS),
)
run_schedule_permanently_in_background(latency=app.config.get("SCHEDULE_BACKGROUND_LATENCY_SECONDS", 30)) run_schedule_permanently_in_background(latency=app.config.get("SCHEDULE_BACKGROUND_LATENCY_SECONDS", 30))
logging.info('Routine Jobs are defined.') logging.info('Routine Jobs are defined.')

View File

@ -17,14 +17,7 @@ def GetHistory():
options = {} options = {}
if not 'shipcall_id' in request.args: if not 'shipcall_id' in request.args:
return create_dynamic_exception_response(ex=None, status_code=400, message="missing parameter: shipcall_id") return create_dynamic_exception_response(ex=None, status_code=400, message="missing parameter: shipcall_id")
shipcall_id_values = request.args.getlist("shipcall_id") options["shipcall_id"] = request.args.get("shipcall_id")
if len(shipcall_id_values) != 1:
return create_dynamic_exception_response(ex=None, status_code=400, message="invalid parameter: shipcall_id")
shipcall_id_raw = shipcall_id_values[0]
try:
options["shipcall_id"] = int(shipcall_id_raw)
except (TypeError, ValueError):
return create_dynamic_exception_response(ex=None, status_code=400, message="invalid parameter: shipcall_id")
return impl.history.GetHistory(options) return impl.history.GetHistory(options)
else: else:
return create_dynamic_exception_response(ex=None, status_code=403, message="not authenticated") return create_dynamic_exception_response(ex=None, status_code=403, message="not authenticated")

View File

@ -1,4 +1,4 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request
from flask_jwt_extended import create_access_token from flask_jwt_extended import create_access_token
from webargs.flaskparser import parser from webargs.flaskparser import parser
from ..schemas import model from ..schemas import model
@ -13,14 +13,4 @@ bp = Blueprint('login', __name__)
def Logon(): def Logon():
options = request.get_json(force=True) options = request.get_json(force=True)
if not isinstance(options, dict):
return jsonify({"error_field": "invalid request body"}), 400
username = options.get("username")
password = options.get("password")
if not username or not password:
return jsonify({"error_field": "username and password required"}), 400
if not isinstance(username, str) or not isinstance(password, str):
return jsonify({"error_field": "username and password must be strings"}), 400
if not username.strip() or not password.strip():
return jsonify({"error_field": "username and password cannot be empty"}), 400
return impl.login.GetUser(options) return impl.login.GetUser(options)

View File

@ -16,12 +16,7 @@ def GetParticipant():
if 'Authorization' in request.headers: if 'Authorization' in request.headers:
payload = decode_jwt(request.headers.get("Authorization").split("Bearer ")[-1]) payload = decode_jwt(request.headers.get("Authorization").split("Bearer ")[-1])
options = {} options = {}
user_id_raw = request.args.get("user_id") options["user_id"] = request.args.get("user_id")
if user_id_raw is not None:
try:
options["user_id"] = int(user_id_raw)
except (TypeError, ValueError):
return create_dynamic_exception_response(ex=None, status_code=400, message="invalid parameter: user_id")
if "participant_id" in payload: if "participant_id" in payload:
options["participant_id"] = payload["participant_id"] options["participant_id"] = payload["participant_id"]
else: else:

View File

@ -35,14 +35,7 @@ def GetShipcalls():
""" """
payload = decode_jwt(request.headers.get("Authorization").split("Bearer ")[-1]) payload = decode_jwt(request.headers.get("Authorization").split("Bearer ")[-1])
options = {} options = {}
past_days_raw = request.args.get("past_days", default=None) options["past_days"] = request.args.get("past_days", default=1, type=int)
if past_days_raw is None:
options["past_days"] = 1
else:
try:
options["past_days"] = int(past_days_raw)
except (TypeError, ValueError):
return create_dynamic_exception_response(ex=None, status_code=400, message="invalid parameter: past_days")
options["participant_id"] = payload["participant_id"] options["participant_id"] = payload["participant_id"]
return impl.shipcalls.GetShipcalls(options) return impl.shipcalls.GetShipcalls(options)

View File

@ -13,18 +13,6 @@ from BreCal.validators.input_validation_ship import InputValidationShip
bp = Blueprint('ships', __name__) bp = Blueprint('ships', __name__)
def _message_contains(messages, needle: str) -> bool:
if not isinstance(messages, dict):
return False
for value in messages.values():
if isinstance(value, list):
text = " ".join(map(str, value))
else:
text = str(value)
if needle in text:
return True
return False
@bp.route('/ships', methods=['get']) @bp.route('/ships', methods=['get'])
@auth_guard() # no restriction by role @auth_guard() # no restriction by role
def GetShips(): def GetShips():
@ -64,8 +52,6 @@ def PostShip():
return impl.ships.PostShip(loadedModel) return impl.ships.PostShip(loadedModel)
except ValidationError as ex: except ValidationError as ex:
if _message_contains(ex.messages, "already exists"):
return create_validation_error_response(ex=ex, status_code=409)
return create_validation_error_response(ex=ex, status_code=400) return create_validation_error_response(ex=ex, status_code=400)
except Exception as ex: except Exception as ex:
@ -93,8 +79,6 @@ def PutShip():
return impl.ships.PutShip(loadedModel) return impl.ships.PutShip(loadedModel)
except ValidationError as ex: except ValidationError as ex:
if _message_contains(ex.messages, "may not be changed"):
return create_validation_error_response(ex=ex, status_code=409)
return create_validation_error_response(ex=ex, status_code=400) return create_validation_error_response(ex=ex, status_code=400)
except Exception as ex: except Exception as ex:
@ -127,8 +111,6 @@ def DeleteShip():
return impl.ships.DeleteShip(options) return impl.ships.DeleteShip(options)
except ValidationError as ex: except ValidationError as ex:
if _message_contains(ex.messages, "already deleted") or _message_contains(ex.messages, "Could not find a ship"):
return create_validation_error_response(ex=ex, status_code=404)
return create_validation_error_response(ex=ex, status_code=400) return create_validation_error_response(ex=ex, status_code=400)
except Exception as ex: except Exception as ex:

View File

@ -18,14 +18,7 @@ def GetTimes():
try: try:
options = {} options = {}
shipcall_id_raw = request.args.get("shipcall_id") options["shipcall_id"] = request.args.get("shipcall_id")
if shipcall_id_raw is not None:
try:
options["shipcall_id"] = int(shipcall_id_raw)
except (TypeError, ValueError):
return create_dynamic_exception_response(ex=None, status_code=400, message="invalid parameter: shipcall_id")
else:
options["shipcall_id"] = None
return impl.times.GetTimes(options) return impl.times.GetTimes(options)
except Exception as ex: except Exception as ex:

View File

@ -1,7 +1,6 @@
import json import json
import logging import logging
import pydapper import pydapper
from flask import jsonify
from ..schemas import model from ..schemas import model
from .. import local_db from .. import local_db
@ -34,7 +33,7 @@ def GetBerths(options):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -2,7 +2,6 @@ import json
import logging import logging
import pydapper import pydapper
import pdb import pdb
from flask import jsonify
from ..schemas import model from ..schemas import model
from ..schemas.model import History from ..schemas.model import History
@ -35,7 +34,7 @@ def GetHistory(options):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify("call failed"), 500 return json.dumps("call failed"), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()

View File

@ -1,7 +1,7 @@
import json
import logging import logging
import pydapper import pydapper
import bcrypt import bcrypt
from flask import jsonify
from ..schemas import model from ..schemas import model
from .. import local_db from .. import local_db
@ -35,24 +35,24 @@ def GetUser(options):
"user_name": data[0].user_name, "user_name": data[0].user_name,
"user_phone": data[0].user_phone, "user_phone": data[0].user_phone,
"user_email": data[0].user_email, "user_email": data[0].user_email,
"notify_email": model._coerce_bool(data[0].notify_email), "notify_email": data[0].notify_email,
"notify_whatsapp": model._coerce_bool(data[0].notify_whatsapp), "notify_whatsapp": data[0].notify_whatsapp,
"notify_signal": model._coerce_bool(data[0].notify_signal), "notify_signal": data[0].notify_signal,
"notify_popup": model._coerce_bool(data[0].notify_popup), "notify_popup": data[0].notify_popup,
"notify_on": model.notification_types_to_names(model.bitflag_to_list(data[0].notify_event)) "notify_on": model.notification_types_to_names(model.bitflag_to_list(data[0].notify_event))
} }
token = jwt_handler.generate_jwt(payload=result, lifetime=120) # generate token valid 60 mins token = jwt_handler.generate_jwt(payload=result, lifetime=120) # generate token valid 60 mins
result["token"] = token # add token to user data result["token"] = token # add token to user data
return jsonify(result), 200 return json.dumps(result), 200, {'Content-Type': 'application/json; charset=utf-8'}
if len(data) > 1: if len(data) > 1:
result = {} result = {}
result["error_field"] = "credential lookup mismatch" result["error_field"] = "credential lookup mismatch"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
result = {} result = {}
result["error_field"] = "invalid credentials" result["error_field"] = "invalid credentials"
return jsonify(result), 403 return json.dumps(result), 403, {'Content-Type': 'application/json; charset=utf-8'}
except Exception as ex: except Exception as ex:
logging.error(ex) logging.error(ex)
@ -60,7 +60,7 @@ def GetUser(options):
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
result["error_description"] = str(ex) result["error_description"] = str(ex)
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -1,7 +1,6 @@
import json import json
import logging import logging
import pydapper import pydapper
from flask import jsonify
from ..schemas import model from ..schemas import model
from .. import local_db from .. import local_db
@ -29,7 +28,7 @@ def GetNotifications(token, participant_id=None):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()

View File

@ -1,7 +1,6 @@
import json import json
import logging import logging
import pydapper import pydapper
from flask import jsonify
from ..schemas import model from ..schemas import model
from .. import local_db from .. import local_db
@ -60,7 +59,7 @@ def GetParticipant(options):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify("call failed"), 500 return json.dumps("call failed"), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -1,7 +1,6 @@
import json import json
import logging import logging
import pydapper import pydapper
from flask import jsonify
from ..schemas import model from ..schemas import model
from .. import local_db from .. import local_db
@ -24,7 +23,7 @@ def GetPorts(token):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -2,7 +2,6 @@ import json
import logging import logging
import traceback import traceback
import pydapper import pydapper
from flask import jsonify
from ..schemas import model from ..schemas import model
from .. import local_db from .. import local_db
@ -45,7 +44,7 @@ def GetShipcalls(options):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -179,7 +178,7 @@ def PostShipcalls(schemaModel):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -206,7 +205,7 @@ def PutShipcalls(schemaModel, original_payload=None):
theshipcall = commands.query_single_or_default("SELECT * FROM shipcall where id = ?id?", sentinel, param={"id" : schemaModel["id"]}) theshipcall = commands.query_single_or_default("SELECT * FROM shipcall where id = ?id?", sentinel, param={"id" : schemaModel["id"]})
if theshipcall is sentinel: if theshipcall is sentinel:
return jsonify("no such record"), 404 return json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'}
was_canceled = theshipcall["canceled"] was_canceled = theshipcall["canceled"]
@ -288,10 +287,8 @@ def PutShipcalls(schemaModel, original_payload=None):
dquery = "DELETE FROM shipcall_participant_map WHERE id = ?existing_id?" dquery = "DELETE FROM shipcall_participant_map WHERE id = ?existing_id?"
commands.execute(dquery, param={"existing_id" : elem["id"]}) commands.execute(dquery, param={"existing_id" : elem["id"]})
# TODO: Create un-assignment notification but only if level > 0 else delete existing notification # TODO: Create un-assignment notification but only if level > 0 else delete existing notification
found_assignment_notification = False
for existing_notification in existing_notifications: for existing_notification in existing_notifications:
if existing_notification["participant_id"] == elem["participant_id"]: if existing_notification["participant_id"] == elem["participant_id"]:
found_assignment_notification = True
if existing_notification["level"] == 0: if existing_notification["level"] == 0:
nquery = "DELETE FROM notification WHERE id = ?nid?" nquery = "DELETE FROM notification WHERE id = ?nid?"
commands.execute(nquery, param={"nid" : existing_notification["id"]}) commands.execute(nquery, param={"nid" : existing_notification["id"]})
@ -301,10 +298,6 @@ def PutShipcalls(schemaModel, original_payload=None):
nquery = "INSERT INTO notification (shipcall_id, participant_id, level, type, message) VALUES (?shipcall_id?, ?participant_id?, 0, 5, ?message?)" nquery = "INSERT INTO notification (shipcall_id, participant_id, level, type, message) VALUES (?shipcall_id?, ?participant_id?, 0, 5, ?message?)"
commands.execute(nquery, param={"shipcall_id" : schemaModel["id"], "participant_id" : elem["participant_id"], "message" : message}) commands.execute(nquery, param={"shipcall_id" : schemaModel["id"], "participant_id" : elem["participant_id"], "message" : message})
break break
if not found_assignment_notification:
message = "The participant has been unassigned from the shipcall."
nquery = "INSERT INTO notification (shipcall_id, participant_id, level, type, message) VALUES (?shipcall_id?, ?participant_id?, 0, 5, ?message?)"
commands.execute(nquery, param={"shipcall_id" : schemaModel["id"], "participant_id" : elem["participant_id"], "message" : message})
canceled_value = schemaModel.get("canceled") canceled_value = schemaModel.get("canceled")
if canceled_value is not None: if canceled_value is not None:
@ -332,7 +325,7 @@ def PutShipcalls(schemaModel, original_payload=None):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -1,7 +1,6 @@
import json import json
import logging import logging
import pydapper import pydapper
from flask import jsonify
from ..schemas import model from ..schemas import model
from .. import local_db from .. import local_db
@ -27,7 +26,7 @@ def GetShips(token):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -91,7 +90,7 @@ def PostShip(schemaModel):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()
@ -134,7 +133,7 @@ def PutShip(schemaModel):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()
@ -152,22 +151,21 @@ def DeleteShip(options):
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
# query = SQLQuery.get_ship_delete_by_id() # query = SQLQuery.get_ship_delete_by_id()
# affected_rows = commands.execute(query, param={"id" : options["id"]}) # affected_rows = commands.execute(query, param={"id" : options["id"]})
ship_id = int(options["id"]) if not isinstance(options["id"], int) else options["id"] affected_rows = commands.execute("UPDATE ship SET deleted = 1 WHERE id = ?id?", param={"id" : options["id"]})
affected_rows = commands.execute("UPDATE ship SET deleted = 1 WHERE id = ?id?", param={"id" : ship_id})
if affected_rows == 1: if affected_rows == 1:
return json.dumps({"id" : ship_id}), 200, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps({"id" : options["id"]}), 200, {'Content-Type': 'application/json; charset=utf-8'}
result = {} result = {}
result["error_field"] = "no such record" result["error_field"] = "no such record"
return jsonify(result), 404 return json.dumps(result), 404, {'Content-Type': 'application/json; charset=utf-8'}
except Exception as ex: except Exception as ex:
logging.error(ex) logging.error(ex)
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()

View File

@ -2,7 +2,6 @@ import json
import logging import logging
import traceback import traceback
import pydapper import pydapper
from flask import jsonify
from enum import Enum, Flag from enum import Enum, Flag
from ..schemas import model from ..schemas import model
@ -36,7 +35,7 @@ def GetTimes(options):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -109,7 +108,7 @@ def PostTimes(schemaModel):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -131,7 +130,7 @@ def PutTimes(schemaModel, original_payload=None):
sentinel = object() sentinel = object()
existing_times = commands.query_single_or_default("SELECT * FROM times WHERE id = ?id?", sentinel, param={"id": schemaModel["id"]}) existing_times = commands.query_single_or_default("SELECT * FROM times WHERE id = ?id?", sentinel, param={"id": schemaModel["id"]})
if existing_times is sentinel: if existing_times is sentinel:
return jsonify("no such record"), 404 return json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'}
provided_keys = set(original_payload.keys()) if isinstance(original_payload, dict) else None provided_keys = set(original_payload.keys()) if isinstance(original_payload, dict) else None
@ -178,7 +177,7 @@ def PutTimes(schemaModel, original_payload=None):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -194,9 +193,8 @@ def DeleteTimes(options):
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
times_id = int(options["id"]) if not isinstance(options["id"], int) else options["id"] shipcall_id = commands.execute_scalar("SELECT shipcall_id FROM times WHERE id = ?id?", param={"id" : options["id"]})
shipcall_id = commands.execute_scalar("SELECT shipcall_id FROM times WHERE id = ?id?", param={"id" : times_id}) affected_rows = commands.execute("DELETE FROM times WHERE id = ?id?", param={"id" : options["id"]})
affected_rows = commands.execute("DELETE FROM times WHERE id = ?id?", param={"id" : times_id})
# TODO: set ETA properly? # TODO: set ETA properly?
@ -206,18 +204,18 @@ def DeleteTimes(options):
commands.execute(query, {"pid" : user_data["participant_id"], "shipcall_id" : shipcall_id, "uid" : user_data["id"]}) commands.execute(query, {"pid" : user_data["participant_id"], "shipcall_id" : shipcall_id, "uid" : user_data["id"]})
if affected_rows == 1: if affected_rows == 1:
return json.dumps({"id" : times_id}), 200, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps({"id" : options["id"]}), 200, {'Content-Type': 'application/json; charset=utf-8'}
result = {} result = {}
result["error_field"] = "no such record" result["error_field"] = "no such record"
return jsonify(result), 404 return json.dumps(result), 404, {'Content-Type': 'application/json; charset=utf-8'}
except Exception as ex: except Exception as ex:
logging.error(ex) logging.error(ex)
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -2,7 +2,6 @@ import json
import logging import logging
import pydapper import pydapper
import bcrypt import bcrypt
from flask import jsonify
from ..schemas import model from ..schemas import model
from .. import local_db from .. import local_db
@ -28,7 +27,7 @@ def PutUser(schemaModel):
theuser = commands.query_single_or_default("SELECT * FROM user where id = ?id?", sentinel, param={"id" : schemaModel["id"]}, model=model.User) theuser = commands.query_single_or_default("SELECT * FROM user where id = ?id?", sentinel, param={"id" : schemaModel["id"]}, model=model.User)
if theuser is sentinel: if theuser is sentinel:
# #TODO: result = {"message":"no such record"} -> json.dumps # #TODO: result = {"message":"no such record"} -> json.dumps
return jsonify({"error_field": "no such record"}), 404 return json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'}
# see if we need to update public fields # see if we need to update public fields
# #TODO_determine: this filter blocks Put-Requests, which update the 'notify_email', 'notify_whatsapp', 'notify_signal', 'notify_popup' fields # #TODO_determine: this filter blocks Put-Requests, which update the 'notify_email', 'notify_whatsapp', 'notify_signal', 'notify_popup' fields
@ -71,16 +70,16 @@ def PutUser(schemaModel):
else: else:
result = {} result = {}
result["error_field"] = "old password invalid" result["error_field"] = "old password invalid"
return jsonify(result), 400 return json.dumps(result), 400, {'Content-Type': 'application/json; charset=utf-8'}
return jsonify({"id" : schemaModel["id"]}), 200 return json.dumps({"id" : schemaModel["id"]}), 200
except Exception as ex: except Exception as ex:
logging.error(ex) logging.error(ex)
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return jsonify(result), 500 return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'}
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -18,6 +18,3 @@ shipcall_types = {
3: "Shifting" 3: "Shifting"
} }
# Email transport logging (0 = quiet, 1 = verbose)
SMTP_DEBUG_LEVEL = 0

View File

@ -1,5 +1,5 @@
from dataclasses import field, dataclass from dataclasses import field, dataclass
from marshmallow import Schema, fields, INCLUDE, ValidationError, validate, validates, post_load, pre_load, EXCLUDE from marshmallow import Schema, fields, INCLUDE, ValidationError, validate, validates, post_load
from marshmallow.fields import Field from marshmallow.fields import Field
from marshmallow_enum import EnumField from marshmallow_enum import EnumField
from enum import IntEnum from enum import IntEnum
@ -17,31 +17,13 @@ from BreCal.database.enums import ParticipantType, ParticipantFlag
def _format_datetime(value):
if value is None:
return None
if isinstance(value, datetime.datetime):
if value.tzinfo is None or value.tzinfo.utcoffset(value) is None:
return value.isoformat()
return value.astimezone().replace(tzinfo=None).isoformat()
return value
def obj_dict(obj): def obj_dict(obj):
if isinstance(obj, datetime.datetime): if isinstance(obj, datetime.datetime):
return _format_datetime(obj) return obj.isoformat()
if hasattr(obj, 'to_json'): if hasattr(obj, 'to_json'):
return obj.to_json() return obj.to_json()
return obj.__dict__ return obj.__dict__
def _coerce_bool(value):
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, int) and value in (0, 1):
return bool(value)
return value
@dataclass @dataclass
class Berth(Schema): class Berth(Schema):
id: int id: int
@ -54,19 +36,6 @@ class Berth(Schema):
modified: datetime modified: datetime
deleted: bool deleted: bool
def to_json(self):
return {
"id": self.id,
"name": self.name,
"lock": _coerce_bool(self.lock),
"owner_id": self.owner_id,
"authority_id": self.authority_id,
"port_id": self.port_id,
"created": _format_datetime(self.created),
"modified": _format_datetime(self.modified),
"deleted": _coerce_bool(self.deleted),
}
@dataclass @dataclass
class Port(Schema): class Port(Schema):
id: int id: int
@ -76,16 +45,6 @@ class Port(Schema):
modified: datetime modified: datetime
deleted: bool deleted: bool
def to_json(self):
return {
"id": self.id,
"name": self.name,
"locode": self.locode,
"created": _format_datetime(self.created),
"modified": _format_datetime(self.modified),
"deleted": _coerce_bool(self.deleted),
}
class OperationType(IntEnum): class OperationType(IntEnum):
undefined = 0 undefined = 0
insert = 1 insert = 1
@ -189,8 +148,8 @@ class History:
"id": self.id, "id": self.id,
"participant_id": self.participant_id, "participant_id": self.participant_id,
"shipcall_id": self.shipcall_id, "shipcall_id": self.shipcall_id,
"timestamp": _format_datetime(self.timestamp), "timestamp": self.timestamp.isoformat() if self.timestamp else "",
"eta": _format_datetime(self.eta), "eta": self.eta.isoformat() if self.eta else "",
"type": self.type.name if isinstance(self.type, IntEnum) else ObjectType(self.type).name, "type": self.type.name if isinstance(self.type, IntEnum) else ObjectType(self.type).name,
"operation": self.operation.name if isinstance(self.operation, IntEnum) else OperationType(self.operation).name "operation": self.operation.name if isinstance(self.operation, IntEnum) else OperationType(self.operation).name
} }
@ -231,8 +190,8 @@ class Notification:
"level": self.level, "level": self.level,
"type": self.type.name if isinstance(self.type, IntEnum) else NotificationType(self.type).name, "type": self.type.name if isinstance(self.type, IntEnum) else NotificationType(self.type).name,
"message": self.message, "message": self.message,
"created": _format_datetime(self.created), "created": self.created.isoformat() if self.created else "",
"modified": _format_datetime(self.modified) "modified": self.modified.isoformat() if self.modified else ""
} }
@classmethod @classmethod
@ -253,21 +212,6 @@ class Participant(Schema):
deleted: bool deleted: bool
ports: List[int] = field(default_factory=list) ports: List[int] = field(default_factory=list)
def to_json(self):
return {
"id": self.id,
"name": self.name,
"street": self.street,
"postal_code": self.postal_code,
"city": self.city,
"type": self.type,
"flags": self.flags,
"created": _format_datetime(self.created),
"modified": _format_datetime(self.modified),
"deleted": _coerce_bool(self.deleted),
"ports": self.ports,
}
@validates("type") @validates("type")
def validate_type(self, value, **kwargs): def validate_type(self, value, **kwargs):
# e.g., when an IntFlag has the values 1,2,4; the maximum valid value is 7 # e.g., when an IntFlag has the values 1,2,4; the maximum valid value is 7
@ -299,7 +243,7 @@ class ParticipantAssignmentSchema(Schema):
class ShipcallSchema(Schema): class ShipcallSchema(Schema):
def __init__(self): def __init__(self):
super().__init__(unknown=EXCLUDE) super().__init__(unknown=None)
pass pass
id = fields.Integer(required=True) id = fields.Integer(required=True)
@ -421,34 +365,34 @@ class Shipcall:
"id": self.id, "id": self.id,
"ship_id": self.ship_id, "ship_id": self.ship_id,
"type": self.type.name if isinstance(self.type, IntEnum) else ShipcallType(self.type).name, "type": self.type.name if isinstance(self.type, IntEnum) else ShipcallType(self.type).name,
"eta": _format_datetime(self.eta), "eta": self.eta.isoformat() if self.eta else "",
"voyage": self.voyage, "voyage": self.voyage,
"etd": _format_datetime(self.etd), "etd": self.etd.isoformat() if self.etd else "",
"arrival_berth_id": self.arrival_berth_id, "arrival_berth_id": self.arrival_berth_id,
"departure_berth_id": self.departure_berth_id, "departure_berth_id": self.departure_berth_id,
"tug_required": _coerce_bool(self.tug_required), "tug_required": self.tug_required,
"pilot_required": _coerce_bool(self.pilot_required), "pilot_required": self.pilot_required,
"flags": self.flags, "flags": self.flags,
"pier_side": _coerce_bool(self.pier_side), "pier_side": self.pier_side,
"bunkering": _coerce_bool(self.bunkering), "bunkering": self.bunkering,
"replenishing_terminal": _coerce_bool(self.replenishing_terminal), "replenishing_terminal": self.replenishing_terminal,
"replenishing_lock": _coerce_bool(self.replenishing_lock), "replenishing_lock": self.replenishing_lock,
"draft": self.draft, "draft": self.draft,
"tidal_window_from": _format_datetime(self.tidal_window_from), "tidal_window_from": self.tidal_window_from.isoformat() if self.tidal_window_from else "",
"tidal_window_to": _format_datetime(self.tidal_window_to), "tidal_window_to": self.tidal_window_to.isoformat() if self.tidal_window_to else "",
"rain_sensitive_cargo": _coerce_bool(self.rain_sensitive_cargo), "rain_sensitive_cargo": self.rain_sensitive_cargo,
"recommended_tugs": self.recommended_tugs, "recommended_tugs": self.recommended_tugs,
"anchored": _coerce_bool(self.anchored), "anchored": self.anchored,
"moored_lock": _coerce_bool(self.moored_lock), "moored_lock": self.moored_lock,
"canceled": _coerce_bool(self.canceled), "canceled": self.canceled,
"evaluation": self.evaluation.name if isinstance(self.evaluation, IntEnum) else EvaluationType(self.evaluation).name, "evaluation": self.evaluation.name if isinstance(self.evaluation, IntEnum) else EvaluationType(self.evaluation).name,
"evaluation_message": self.evaluation_message, "evaluation_message": self.evaluation_message,
"evaluation_time": _format_datetime(self.evaluation_time), "evaluation_time": self.evaluation_time.isoformat() if self.evaluation_time else "",
"evaluation_notifications_sent": _coerce_bool(self.evaluation_notifications_sent), "evaluation_notifications_sent": self.evaluation_notifications_sent,
"time_ref_point": self.time_ref_point, "time_ref_point": self.time_ref_point,
"port_id": self.port_id, "port_id": self.port_id,
"created": _format_datetime(self.created), "created": self.created.isoformat() if self.created else "",
"modified": _format_datetime(self.modified), "modified": self.modified.isoformat() if self.modified else "",
"participants": [participant.__dict__ for participant in self.participants] "participants": [participant.__dict__ for participant in self.participants]
} }
@ -456,39 +400,7 @@ class Shipcall:
@classmethod @classmethod
def from_query_row(self, id, ship_id, type, eta, voyage, etd, arrival_berth_id, departure_berth_id, tug_required, pilot_required, flags, pier_side, bunkering, replenishing_terminal, replenishing_lock, draft, tidal_window_from, tidal_window_to, rain_sensitive_cargo, recommended_tugs, anchored, moored_lock, canceled, evaluation, evaluation_message, evaluation_time, evaluation_notifications_sent, time_ref_point, port_id, created, modified): def from_query_row(self, id, ship_id, type, eta, voyage, etd, arrival_berth_id, departure_berth_id, tug_required, pilot_required, flags, pier_side, bunkering, replenishing_terminal, replenishing_lock, draft, tidal_window_from, tidal_window_to, rain_sensitive_cargo, recommended_tugs, anchored, moored_lock, canceled, evaluation, evaluation_message, evaluation_time, evaluation_notifications_sent, time_ref_point, port_id, created, modified):
return self( return self(id, ship_id, ShipcallType(type), eta, voyage, etd, arrival_berth_id, departure_berth_id, tug_required, pilot_required, flags, pier_side, bunkering, replenishing_terminal, replenishing_lock, draft, tidal_window_from, tidal_window_to, rain_sensitive_cargo, recommended_tugs, anchored, moored_lock, canceled, EvaluationType(evaluation), evaluation_message, evaluation_time, evaluation_notifications_sent, time_ref_point, port_id, created, modified)
id,
ship_id,
ShipcallType(type),
eta,
voyage,
etd,
arrival_berth_id,
departure_berth_id,
_coerce_bool(tug_required),
_coerce_bool(pilot_required),
flags,
_coerce_bool(pier_side),
_coerce_bool(bunkering),
_coerce_bool(replenishing_terminal),
_coerce_bool(replenishing_lock),
draft,
tidal_window_from,
tidal_window_to,
_coerce_bool(rain_sensitive_cargo),
recommended_tugs,
_coerce_bool(anchored),
_coerce_bool(moored_lock),
_coerce_bool(canceled),
EvaluationType(evaluation),
evaluation_message,
evaluation_time,
_coerce_bool(evaluation_notifications_sent),
time_ref_point,
port_id,
created,
modified
)
class ShipcallId(Schema): class ShipcallId(Schema):
pass pass
@ -603,9 +515,9 @@ class UserSchema(Schema):
super().__init__(unknown=None) super().__init__(unknown=None)
pass pass
id = fields.Integer(required=True) id = fields.Integer(required=True)
first_name = fields.String(allow_none=True, required=False, validate=[validate.Length(max=45)]) first_name = fields.String(allow_none=True, required=False, validate=[validate.Length(max=64)])
last_name = fields.String(allow_none=True, required=False, validate=[validate.Length(max=45)]) last_name = fields.String(allow_none=True, required=False, validate=[validate.Length(max=64)])
user_phone = fields.String(allow_none=True, required=False, validate=[validate.Length(max=32)]) user_phone = fields.String(allow_none=True, required=False)
user_email = fields.String(allow_none=True, required=False, validate=[validate.Length(max=64)]) user_email = fields.String(allow_none=True, required=False, validate=[validate.Length(max=64)])
old_password = fields.String(allow_none=True, required=False, validate=[validate.Length(max=128)]) old_password = fields.String(allow_none=True, required=False, validate=[validate.Length(max=128)])
new_password = fields.String(allow_none=True, required=False, validate=[validate.Length(min=6, max=128)]) new_password = fields.String(allow_none=True, required=False, validate=[validate.Length(min=6, max=128)])
@ -615,14 +527,6 @@ class UserSchema(Schema):
notify_popup = fields.Bool(allow_none=True, required=False) notify_popup = fields.Bool(allow_none=True, required=False)
notify_on = fields.List(fields.Enum(NotificationType), required=False, allow_none=True) notify_on = fields.List(fields.Enum(NotificationType), required=False, allow_none=True)
@pre_load
def validate_bool_types(self, data, **kwargs):
bool_fields = ["notify_email", "notify_whatsapp", "notify_signal", "notify_popup"]
for field_name in bool_fields:
if field_name in data and data[field_name] is not None and not isinstance(data[field_name], bool):
raise ValidationError({field_name: "must be a boolean"})
return data
@validates("user_phone") @validates("user_phone")
def validate_user_phone(self, value, **kwargs): def validate_user_phone(self, value, **kwargs):
if value is not None: if value is not None:
@ -663,34 +567,6 @@ class Times:
created: datetime created: datetime
modified: datetime modified: datetime
def to_json(self):
return {
"id": self.id,
"eta_berth": _format_datetime(self.eta_berth),
"eta_berth_fixed": _coerce_bool(self.eta_berth_fixed),
"etd_berth": _format_datetime(self.etd_berth),
"etd_berth_fixed": _coerce_bool(self.etd_berth_fixed),
"lock_time": _format_datetime(self.lock_time),
"lock_time_fixed": _coerce_bool(self.lock_time_fixed),
"zone_entry": _format_datetime(self.zone_entry),
"zone_entry_fixed": _coerce_bool(self.zone_entry_fixed),
"operations_start": _format_datetime(self.operations_start),
"operations_end": _format_datetime(self.operations_end),
"remarks": self.remarks,
"participant_id": self.participant_id,
"berth_id": self.berth_id,
"berth_info": self.berth_info,
"pier_side": _coerce_bool(self.pier_side),
"participant_type": self.participant_type,
"shipcall_id": self.shipcall_id,
"ata": _format_datetime(self.ata),
"atd": _format_datetime(self.atd),
"eta_interval_end": _format_datetime(self.eta_interval_end),
"etd_interval_end": _format_datetime(self.etd_interval_end),
"created": _format_datetime(self.created),
"modified": _format_datetime(self.modified),
}
@dataclass @dataclass
class User: class User:
@ -734,23 +610,6 @@ class Ship:
modified: datetime modified: datetime
deleted: bool deleted: bool
def to_json(self):
return {
"id": self.id,
"name": self.name if self.name is not None else "",
"imo": self.imo,
"callsign": self.callsign,
"participant_id": self.participant_id,
"length": self.length,
"width": self.width,
"is_tug": _coerce_bool(self.is_tug),
"bollard_pull": self.bollard_pull,
"eni": self.eni,
"created": _format_datetime(self.created),
"modified": _format_datetime(self.modified),
"deleted": _coerce_bool(self.deleted),
}
class ShipSchema(Schema): class ShipSchema(Schema):
def __init__(self): def __init__(self):
@ -771,14 +630,6 @@ class ShipSchema(Schema):
modified = fields.DateTime(allow_none=True, required=False) modified = fields.DateTime(allow_none=True, required=False)
deleted = fields.Bool(allow_none=True, required=False, load_default=False, dump_default=False) deleted = fields.Bool(allow_none=True, required=False, load_default=False, dump_default=False)
@pre_load
def validate_bool_types(self, data, **kwargs):
bool_fields = ["is_tug", "deleted"]
for field_name in bool_fields:
if field_name in data and data[field_name] is not None and not isinstance(data[field_name], bool):
raise ValidationError({field_name: "must be a boolean"})
return data
@validates("name") @validates("name")
def validate_name(self, value, **kwargs): def validate_name(self, value, **kwargs):
character_length = len(str(value)) character_length = len(str(value))
@ -845,6 +696,6 @@ class ShipcallParticipantMap:
"shipcall_id": self.shipcall_id, "shipcall_id": self.shipcall_id,
"participant_id": self.participant_id, "participant_id": self.participant_id,
"type": self.type.name if isinstance(self.type, IntEnum) else ShipcallType(self.type).name, "type": self.type.name if isinstance(self.type, IntEnum) else ShipcallType(self.type).name,
"created": _format_datetime(self.created), "created": self.created.isoformat() if self.created else "",
"modified": _format_datetime(self.modified), "modified": self.modified.isoformat() if self.modified else "",
} }

View File

@ -1,4 +1,5 @@
from flask import request, jsonify import json
from flask import request
from .jwt_handler import decode_jwt from .jwt_handler import decode_jwt
def check_jwt(): def check_jwt():
@ -24,9 +25,9 @@ def auth_guard(role=None):
try: try:
user_data = check_jwt() user_data = check_jwt()
except Exception as e: except Exception as e:
return jsonify({"error_field" : f'{e}', "status": 401}), 401 return json.dumps({"error_field" : f'{e}', "status": 401}), 401
if role and role not in user_data['roles']: if role and role not in user_data['roles']:
return jsonify({"error_field": 'Authorization required.', "status" : 403}), 403 return json.dumps({"error_field": 'Authorization required.', "status" : 403}), 403
# get on to original route # get on to original route
return route_function(*args, **kwargs) return route_function(*args, **kwargs)
decorated_function.__name__ = route_function.__name__ decorated_function.__name__ = route_function.__name__

View File

@ -3,7 +3,6 @@ import pydapper
import smtplib import smtplib
import json import json
import os import os
import base64
from email.message import EmailMessage from email.message import EmailMessage
from BreCal.schemas import model, defs from BreCal.schemas import model, defs
@ -111,60 +110,12 @@ def SendEmails(email_dict):
pooledConnection = getPoolConnection() pooledConnection = getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
def _parse_bool(value) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).strip().lower() in ("1", "true", "yes", "y", "on")
def _smtp_auth_ntlm(connection, username: str, password: str, domain: str | None = None, workstation: str | None = None):
try:
from ntlm_auth.ntlm import NtlmContext
except Exception as exc:
raise RuntimeError("NTLM auth requested but ntlm-auth is not installed") from exc
context = NtlmContext(username, password, domain=domain, workstation=workstation)
negotiate = context.step()
code, challenge = connection.docmd("AUTH", "NTLM " + base64.b64encode(negotiate).decode("ascii"))
if code != 334:
raise smtplib.SMTPException(f"NTLM negotiate failed: {code} {challenge}")
challenge_bytes = base64.b64decode(challenge.strip())
authenticate = context.step(challenge_bytes)
code, msg = connection.docmd(base64.b64encode(authenticate).decode("ascii"))
if code != 235:
raise smtplib.SMTPAuthenticationError(code, msg)
encryption = defs.email_credentials.get("encryption") or defs.email_credentials.get("encryption_method") or "STARTTLS"
encryption_norm = str(encryption).strip().upper().replace(" ", "").replace("_", "").replace("-", "")
use_ntlm_auth = _parse_bool(defs.email_credentials.get("USE_NTLM_AUTH"))
if encryption_norm in ("SSLTLS", "SSL", "TLS"):
conn = smtplib.SMTP_SSL(defs.email_credentials["server"], defs.email_credentials["port"])
else:
conn = smtplib.SMTP(defs.email_credentials["server"], defs.email_credentials["port"]) conn = smtplib.SMTP(defs.email_credentials["server"], defs.email_credentials["port"])
try: conn.set_debuglevel(1) # set this to 0 to disable debug output to log
smtp_debug_level = int(defs.SMTP_DEBUG_LEVEL)
except (TypeError, ValueError):
smtp_debug_level = 0
conn.set_debuglevel(1 if smtp_debug_level else 0)
conn.ehlo() conn.ehlo()
if encryption_norm in ("STARTTLS", "STARTSSL"):
conn.starttls() conn.starttls()
conn.ehlo() conn.ehlo()
elif encryption_norm in ("NONE", "NO", "DISABLE"): conn.login(defs.email_credentials["sender"], defs.email_credentials["password_send"])
pass
elif encryption_norm not in ("SSLTLS", "SSL", "TLS"):
logging.warning("Unknown email encryption '%s'; defaulting to STARTTLS.", encryption)
conn.starttls()
conn.ehlo()
auth_username = defs.email_credentials.get("auth_username") or defs.email_credentials.get("sender")
if use_ntlm_auth:
ntlm_user = defs.email_credentials.get("ntlm_user") or auth_username
ntlm_domain = defs.email_credentials.get("ntlm_domain")
ntlm_workstation = defs.email_credentials.get("ntlm_workstation")
_smtp_auth_ntlm(conn, ntlm_user, defs.email_credentials["password_send"], domain=ntlm_domain, workstation=ntlm_workstation)
else:
conn.login(auth_username, defs.email_credentials["password_send"])
current_path = os.path.dirname(os.path.abspath(__file__)) current_path = os.path.dirname(os.path.abspath(__file__))
@ -310,13 +261,6 @@ def SendNotifications():
pass pass
# mark as sent # mark as sent
logging.info(
"Notification finalized (level=2): id=%s shipcall_id=%s participant_id=%s type=%s",
notification.id,
notification.shipcall_id,
notification.participant_id,
notification.type,
)
commands.execute("UPDATE notification SET level = 2 WHERE id = ?id?", param={"id":notification.id}) commands.execute("UPDATE notification SET level = 2 WHERE id = ?id?", param={"id":notification.id})
# send emails (if any) # send emails (if any)
@ -382,7 +326,7 @@ def eval_next_24_hrs():
return return
def setup_schedule(update_shipcalls_interval_in_minutes:int=60, notification_cooldown_mins:int=defs.NOTIFICATION_COOLDOWN_MINS): def setup_schedule(update_shipcalls_interval_in_minutes:int=60):
logging.getLogger('schedule').setLevel(logging.INFO); # set the logging level of the schedule module to INFO logging.getLogger('schedule').setLevel(logging.INFO); # set the logging level of the schedule module to INFO
@ -391,7 +335,7 @@ def setup_schedule(update_shipcalls_interval_in_minutes:int=60, notification_coo
# update the evaluation state in every recent shipcall # update the evaluation state in every recent shipcall
add_function_to_schedule__update_shipcalls(update_shipcalls_interval_in_minutes) add_function_to_schedule__update_shipcalls(update_shipcalls_interval_in_minutes)
add_function_to_evaluate_notifications(notification_cooldown_mins) add_function_to_evaluate_notifications(defs.NOTIFICATION_COOLDOWN_MINS)
add_function_to_clear_notifications(defs.NOTIFICATION_MAX_AGE_DAYS) add_function_to_clear_notifications(defs.NOTIFICATION_MAX_AGE_DAYS)

View File

@ -177,8 +177,6 @@ class InputValidationTimes(InputValidationBase):
# validates the times dataset. # validates the times dataset.
# ensure loadedModel["participant_type"] is of type ParticipantType # ensure loadedModel["participant_type"] is of type ParticipantType
if loadedModel.get("participant_type") is None:
raise ValidationError({"participant_type": "participant_type is required"})
if not isinstance(loadedModel["participant_type"], ParticipantType): if not isinstance(loadedModel["participant_type"], ParticipantType):
loadedModel["participant_type"] = ParticipantType(loadedModel["participant_type"]) loadedModel["participant_type"] = ParticipantType(loadedModel["participant_type"])
@ -231,10 +229,8 @@ class InputValidationTimes(InputValidationBase):
The dependent and independent fields are validated by checking, whether the respective value in 'content' The dependent and independent fields are validated by checking, whether the respective value in 'content'
is undefined (returns None). When any of these fields is undefined, a ValidationError is raised. is undefined (returns None). When any of these fields is undefined, a ValidationError is raised.
""" """
participant_type = loadedModel.get("participant_type") participant_type = loadedModel["participant_type"]
shipcall_id = loadedModel.get("shipcall_id") shipcall_id = loadedModel["shipcall_id"]
if shipcall_id is None:
raise ValidationError({"shipcall_id": "shipcall_id is required"})
# build a dictionary of id:item pairs, so one can select the respective participant # build a dictionary of id:item pairs, so one can select the respective participant
# must look-up the shipcall_type based on the shipcall_id # must look-up the shipcall_type based on the shipcall_id
@ -323,9 +319,7 @@ class InputValidationTimes(InputValidationBase):
""" """
### TIMES DATASET (ShipcallParticipantMap) ### ### TIMES DATASET (ShipcallParticipantMap) ###
# identify shipcall_id # identify shipcall_id
shipcall_id = loadedModel.get("shipcall_id") shipcall_id = loadedModel["shipcall_id"]
if loadedModel.get("participant_type") is None:
raise ValidationError({"participant_type": "participant_type is required"})
DATASET_participant_type = ParticipantType(loadedModel["participant_type"]) if not isinstance(loadedModel["participant_type"],ParticipantType) else loadedModel["participant_type"] DATASET_participant_type = ParticipantType(loadedModel["participant_type"]) if not isinstance(loadedModel["participant_type"],ParticipantType) else loadedModel["participant_type"]
# get ShipcallParticipantMap for the shipcall_id # get ShipcallParticipantMap for the shipcall_id
@ -440,8 +434,6 @@ class InputValidationTimes(InputValidationBase):
# get the matching entry from the shipcall participant map, where the role matches. Raise an error, when there is no match. # get the matching entry from the shipcall participant map, where the role matches. Raise an error, when there is no match.
assigned_agency = get_assigned_participant_of_type(shipcall_id, participant_type=ParticipantType.AGENCY) assigned_agency = get_assigned_participant_of_type(shipcall_id, participant_type=ParticipantType.AGENCY)
if assigned_agency is None:
raise ValidationError({"participant_type": "the assigned agency for this shipcall could not be resolved."})
# a) the user has the participant ID of the assigned entry for a given role # a) the user has the participant ID of the assigned entry for a given role
user_is_assigned_role = user_participant_id == times_assigned_participant.id user_is_assigned_role = user_participant_id == times_assigned_participant.id

View File

@ -2,7 +2,6 @@ import logging
import typing import typing
import json import json
import sys import sys
from flask import jsonify
from marshmallow import ValidationError from marshmallow import ValidationError
from werkzeug.exceptions import Forbidden from werkzeug.exceptions import Forbidden
@ -30,7 +29,7 @@ def unbundle_(errors, unbundled=[]):
[{'error_field':'keya', 'error_description':['keyb']}, {'error_field':'keyc12', 'error_description':[12]}] [{'error_field':'keya', 'error_description':['keyb']}, {'error_field':'keyc12', 'error_description':[12]}]
As can be seen, only the subkeys and their respective value are received. Each value is *always* a list. As can be seen, only the subkeys and their respective value are received. Each value is *always* a list.
""" """
{k:unbundle_(v,unbundled=unbundled) if isinstance(v,dict) else unbundled.append({"error_field":k, "error_description": str(v[0] if isinstance(v,list) else v)}) for k,v in errors.items()} {k:unbundle_(v,unbundled=unbundled) if isinstance(v,dict) else unbundled.append({"error_field":k, "error_description":v[0] if isinstance(v,list) else str(v)}) for k,v in errors.items()}
return return
def unbundle_validation_error_message(message): def unbundle_validation_error_message(message):
@ -41,8 +40,8 @@ def unbundle_validation_error_message(message):
unbundled = [] unbundled = []
unbundle_(message, unbundled=unbundled) unbundle_(message, unbundled=unbundled)
if len(unbundled)>0: if len(unbundled)>0:
error_field = "ValidationError in the following field(s): " + " & ".join([str(unb["error_field"]) for unb in unbundled]) error_field = "ValidationError in the following field(s): " + " & ".join([unb["error_field"] for unb in unbundled])
error_description = " " . join([str(unb["error_description"]) for unb in unbundled]) error_description = " " . join([unb["error_description"] for unb in unbundled])
else: else:
error_field = "ValidationError" error_field = "ValidationError"
error_description = "unknown validation error" error_description = "unknown validation error"
@ -54,28 +53,35 @@ def create_validation_error_response(ex:ValidationError, status_code:int=400, cr
(error_field, error_description) = unbundle_validation_error_message(message) (error_field, error_description) = unbundle_validation_error_message(message)
json_response = create_default_json_response_format(error_field=error_field, error_description=error_description) json_response = create_default_json_response_format(error_field=error_field, error_description=error_description)
# json.dumps with default=str automatically converts non-serializable values to strings. Hence, datetime objects (which are not)
# natively serializable are properly serialized.
serialized_response = json.dumps(json_response, default=str)
if create_log: if create_log:
logging.warning(ex) if ex is not None else logging.warning(message) logging.warning(ex) if ex is not None else logging.warning(message)
# print(ex) if ex is not None else print(message) # print(ex) if ex is not None else print(message)
return (jsonify(json_response), status_code) return (serialized_response, status_code)
def create_werkzeug_error_response(ex:Forbidden, status_code:int=403, create_log:bool=True)->typing.Tuple[str,int]: def create_werkzeug_error_response(ex:Forbidden, status_code:int=403, create_log:bool=True)->typing.Tuple[str,int]:
# json.dumps with default=str automatically converts non-serializable values to strings. Hence, datetime objects (which are not) # json.dumps with default=str automatically converts non-serializable values to strings. Hence, datetime objects (which are not)
# natively serializable are properly serialized. # natively serializable are properly serialized.
message = ex.description message = ex.description
json_response = create_default_json_response_format(error_field=str(repr(ex)), error_description=message) json_response = create_default_json_response_format(error_field=str(repr(ex)), error_description=message)
serialized_response = json.dumps(json_response, default=str)
if create_log: if create_log:
logging.warning(ex) if ex is not None else logging.warning(message) logging.warning(ex) if ex is not None else logging.warning(message)
# print(ex) if ex is not None else print(message) # print(ex) if ex is not None else print(message)
return jsonify(json_response), status_code return serialized_response, status_code
def create_dynamic_exception_response(ex, status_code:int=400, message:typing.Optional[str]=None, create_log:bool=True): def create_dynamic_exception_response(ex, status_code:int=400, message:typing.Optional[str]=None, create_log:bool=True):
message = repr(ex) if message is None else message message = repr(ex) if message is None else message
json_response = create_default_json_response_format(error_field="Exception", error_description=message) json_response = create_default_json_response_format(error_field="Exception", error_description=message)
json_response["error_field"] = "call failed" json_response["error_field"] = "call failed"
serialized_response = json.dumps(json_response, default=str)
if create_log: if create_log:
logging.warning(ex) if ex is not None else logging.warning(message) logging.warning(ex) if ex is not None else logging.warning(message)
# print(ex) if ex is not None else print(message) # print(ex) if ex is not None else print(message)
return (jsonify(json_response), status_code) return (serialized_response, status_code)

View File

@ -135,22 +135,15 @@ class ValidationRules(ValidationRuleFunctions):
for participant in participants: for participant in participants:
commands.execute(query, param={"shipcall_id" : int(shipcall_id), "participant_id" : participant["participant_id"], "message" : violation}) commands.execute(query, param={"shipcall_id" : int(shipcall_id), "participant_id" : participant["participant_id"], "message" : violation})
resolves_time_conflict = (state_old == StatusFlags.RED.value) and (state_new in [StatusFlags.GREEN.value, StatusFlags.YELLOW.value]) if state_new == 1 and state_old != 0: # this resolves the time conflict
if resolves_time_conflict:
notification_type = 3 # time_conflict
logging.info(f"Resolving notifications for shipcall {shipcall_id}, type={notification_type}") logging.info(f"Resolving notifications for shipcall {shipcall_id}, type={notification_type}")
query = "SELECT COUNT(*) as cnt FROM notification WHERE shipcall_id = ?shipcall_id? AND type = ?type?" query = f"DELETE from notification where shipcall_id = ?shipcall_id? and type = {notification_type} and level = 0"
result = commands.query(query, model=dict, param={"shipcall_id" : int(shipcall_id), "type" : notification_type}) deleted_count = commands.execute(query, param={"shipcall_id" : int(shipcall_id)})
has_conflict_notification = (len(result) > 0) and (result[0].get("cnt", 0) > 0)
if has_conflict_notification:
query = "DELETE from notification where shipcall_id = ?shipcall_id? and type = ?type? and level = 0"
deleted_count = commands.execute(query, param={"shipcall_id" : int(shipcall_id), "type" : notification_type})
logging.info(f"Deleted {deleted_count} existing notifications (yet unsent)") logging.info(f"Deleted {deleted_count} existing notifications (yet unsent)")
if deleted_count == 0:
query = "INSERT INTO notification (shipcall_id, participant_id, type, level) VALUES (?shipcall_id?, ?participant_id?, 4, 0)" query = "INSERT INTO notification (shipcall_id, participant_id, type, level) VALUES (?shipcall_id?, ?participant_id?, 4, 0)"
for participant in participants: for participant in participants:
commands.execute(query, param={"shipcall_id" : int(shipcall_id), "participant_id" : participant["participant_id"]}) commands.execute(query, param={"shipcall_id" : int(shipcall_id), "participant_id" : participant["participant_id"]})
else:
logging.info(f"Skipping resolve notification for shipcall {shipcall_id} because no prior time_conflict exists")
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()

View File

@ -7,7 +7,7 @@ def base_url() -> str:
# Example: https://dev.api.mycompany.com # Example: https://dev.api.mycompany.com
url = os.environ.get("API_BASE_URL") url = os.environ.get("API_BASE_URL")
if not url: if not url:
url = "http://127.0.0.1:5000" url = "http://neptun.fritz.box"
# raise RuntimeError("Set API_BASE_URL") # raise RuntimeError("Set API_BASE_URL")
return url.rstrip("/") return url.rstrip("/")

View File

@ -1,16 +1,6 @@
import os
import pytest
import schemathesis import schemathesis
from schemathesis import checks
schema = schemathesis.openapi.from_path( schema = schemathesis.openapi.from_path("../../../misc/BreCalApi.yaml")
"../../../misc/BreCalApi.yaml",
)
schema.base_url = (os.environ.get("API_BASE_URL") or "http://127.0.0.1:5000").rstrip("/")
@pytest.fixture(scope="session", autouse=True)
def _set_schema_base_url(base_url: str) -> None:
schema.base_url = base_url
@schema.parametrize() @schema.parametrize()
def test_api_conformance( def test_api_conformance(
@ -19,18 +9,10 @@ def test_api_conformance(
auth_headers: dict[str, str], auth_headers: dict[str, str],
login_payload: dict[str, str], login_payload: dict[str, str],
) -> None: ) -> None:
case.operation.schema.base_url = base_url
transport = getattr(case.operation.schema, "transport", None)
if transport is not None and hasattr(transport, "base_url"):
transport.base_url = base_url
# Calls your real service: # Calls your real service:
if case.path == "/login" and case.method.upper() == "POST": if case.path == "/login" and case.method.upper() == "POST":
response = case.call(base_url=base_url) response = case.call(base_url=base_url, json=login_payload)
else: else:
response = case.call(base_url=base_url, headers=auth_headers) response = case.call(base_url=base_url, headers=auth_headers)
checks.load_all_checks()
custom_checks = [c for c in checks.CHECKS.get_all() if c.__name__ != "ignored_auth"]
# Validates status code, headers, and body against the OpenAPI schema: # Validates status code, headers, and body against the OpenAPI schema:
case.validate_response(response, checks=custom_checks) case.validate_response(response)