Compare commits

...

17 Commits

Author SHA1 Message Date
16e6444b90 Small adjustment to the formatting 2026-01-27 10:57:57 +01:00
343366db91 Another update to logging init 2026-01-27 10:49:55 +01:00
6eb572cb75 Fix startup logic 2026-01-27 10:40:54 +01:00
3ef5e81b48 No default stderr handler for WSGI 2026-01-27 10:36:18 +01:00
81bbad018b Made SMTP logging optional and added notification creation log 2026-01-27 09:27:20 +01:00
7365949fb5 Create an un-assignment notification for a participant when he is no longer assigned, even if there was no previous
assignment notification. That can happen I believe if the assigment was maybe too long ago and the notification therefore already deleted.
Note that this needs to be tested and may be rolled back if this doesn't solve the issue.
2026-01-22 07:47:09 +01:00
98d713234b Only emit time_conflict_resolved when a conflict has actually existed 2026-01-20 08:52:43 +01:00
409f3140c9 Added separate auth_username to email configuration 2026-01-14 17:21:13 +01:00
ca44f0d154 Fixed taking notification update timer mins value from config.py 2026-01-14 16:40:48 +01:00
d63a26fff9 Fix time offset error when saving 2026-01-14 16:08:26 +01:00
bbd96c47ed Extra schemathesis validation. Still cases failing, but it is much better now 2026-01-14 14:39:56 +01:00
77959b4a50 Added NTLM auth to email client settings 2026-01-13 07:01:15 +01:00
62c13eb17a Experimental change that adds configurable SMTP encryption mode 2026-01-13 05:24:40 +01:00
dfb17d00eb Small schemathesis runtime fix 2026-01-12 09:54:01 +01:00
245cdcb93c Made some additional smalltime-corrections 2026-01-09 14:23:12 +01:00
0ecc6aaefe Fixed boolean returns, first stage of schemathesis tests fixed, still 17 open 2026-01-09 13:11:12 +01:00
f84b3fd7d1 Changed boolean fields in JSON from int to true boolean values 2026-01-08 17:00:13 +01:00
30 changed files with 519 additions and 123 deletions

2
.gitignore vendored
View File

@ -289,3 +289,5 @@ 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,6 +95,14 @@ 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':
@ -192,6 +200,8 @@ 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':
@ -248,6 +258,8 @@ 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':
@ -303,6 +315,10 @@ 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':
@ -406,6 +422,10 @@ 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':
@ -431,6 +451,8 @@ 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':
@ -448,7 +470,10 @@ 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:
type: integer oneOf:
- type: integer
- type: string
pattern: '^[0-9]+$'
example: 2 example: 2
responses: responses:
'200': '200':
@ -488,7 +513,10 @@ 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:
type: integer oneOf:
- type: integer
- type: string
pattern: '^[0-9]+$'
example: 42 example: 42
example: 42 example: 42
responses: responses:
@ -622,6 +650,8 @@ 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':
@ -646,6 +676,8 @@ 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':
@ -663,7 +695,11 @@ 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:
$ref: '#/components/schemas/participant_id' oneOf:
- type: integer
- type: string
pattern: '^[0-9]+$'
example: 7
responses: responses:
'200': '200':
description: notification list description: notification list
@ -800,10 +836,14 @@ 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
@ -839,6 +879,7 @@ 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
@ -958,6 +999,8 @@ 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:
@ -1391,6 +1434,7 @@ 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
@ -1626,14 +1670,17 @@ 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
@ -1701,19 +1748,25 @@ 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:
@ -1786,13 +1839,14 @@ components:
maxLength: 45 maxLength: 45
example: Doe example: Doe
user_phone: user_phone:
maxLength: 128 maxLength: 32
type: string type: string
nullable: true nullable: true
example: '1234567890' example: '1234567890'
user_email: user_email:
maxLength: 128 maxLength: 64
type: string type: string
format: email
nullable: true nullable: true
example: no@where.com example: no@where.com
notify_email: notify_email:
@ -2017,8 +2071,17 @@ components:
schema: schema:
$ref: '#/components/schemas/Error' $ref: '#/components/schemas/Error'
example: example:
error_field: shipcall_id error_field: No such record
error_description: Ship call not found error_description: The requested resource to update was 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,3 +19,9 @@ 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,3 +35,4 @@ 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,6 +16,7 @@ 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
@ -72,21 +73,39 @@ 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_kwargs = {"format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s"} log_format = "%(asctime)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"):
log_kwargs["stream"] = sys.stderr handler = logging.StreamHandler(sys.stderr)
else: else:
log_kwargs["filename"] = app.config.get("LOG_FILE", "brecaltest.log") log_file = app.config.get("LOG_FILE", "brecaltest.log")
logging.basicConfig(level=log_level, **log_kwargs) if not os.path.isabs(log_file):
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(update_shipcalls_interval_in_minutes=app.config.get("SCHEDULE_UPDATE_SHIPCALLS_MINUTES", 60)) setup_schedule(
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,7 +17,14 @@ 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")
options["shipcall_id"] = request.args.get("shipcall_id") shipcall_id_values = request.args.getlist("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 from flask import Blueprint, request, jsonify
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,4 +13,14 @@ 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,7 +16,12 @@ 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 = {}
options["user_id"] = request.args.get("user_id") user_id_raw = 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,7 +35,14 @@ def GetShipcalls():
""" """
payload = decode_jwt(request.headers.get("Authorization").split("Bearer ")[-1]) payload = decode_jwt(request.headers.get("Authorization").split("Bearer ")[-1])
options = {} options = {}
options["past_days"] = request.args.get("past_days", default=1, type=int) past_days_raw = request.args.get("past_days", default=None)
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,6 +13,18 @@ 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():
@ -52,6 +64,8 @@ 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:
@ -79,6 +93,8 @@ 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:
@ -111,6 +127,8 @@ 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,7 +18,14 @@ def GetTimes():
try: try:
options = {} options = {}
options["shipcall_id"] = request.args.get("shipcall_id") shipcall_id_raw = 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,6 +1,7 @@
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
@ -33,7 +34,7 @@ def GetBerths(options):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500 return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -2,6 +2,7 @@ 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
@ -34,7 +35,7 @@ def GetHistory(options):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps("call failed"), 500 return jsonify("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": data[0].notify_email, "notify_email": model._coerce_bool(data[0].notify_email),
"notify_whatsapp": data[0].notify_whatsapp, "notify_whatsapp": model._coerce_bool(data[0].notify_whatsapp),
"notify_signal": data[0].notify_signal, "notify_signal": model._coerce_bool(data[0].notify_signal),
"notify_popup": data[0].notify_popup, "notify_popup": model._coerce_bool(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 json.dumps(result), 200, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 200
if len(data) > 1: if len(data) > 1:
result = {} result = {}
result["error_field"] = "credential lookup mismatch" result["error_field"] = "credential lookup mismatch"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
result = {} result = {}
result["error_field"] = "invalid credentials" result["error_field"] = "invalid credentials"
return json.dumps(result), 403, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 403
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 json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -1,6 +1,7 @@
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
@ -28,7 +29,7 @@ def GetNotifications(token, participant_id=None):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()

View File

@ -1,6 +1,7 @@
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
@ -59,7 +60,7 @@ def GetParticipant(options):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps("call failed"), 500 return jsonify("call failed"), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -1,6 +1,7 @@
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
@ -23,7 +24,7 @@ def GetPorts(token):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500 return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -2,6 +2,7 @@ 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
@ -44,7 +45,7 @@ def GetShipcalls(options):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -178,7 +179,7 @@ def PostShipcalls(schemaModel):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -205,7 +206,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 json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'} return jsonify("no such record"), 404
was_canceled = theshipcall["canceled"] was_canceled = theshipcall["canceled"]
@ -287,8 +288,10 @@ 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"]})
@ -298,6 +301,10 @@ 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:
@ -325,7 +332,7 @@ def PutShipcalls(schemaModel, original_payload=None):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -1,6 +1,7 @@
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
@ -26,7 +27,7 @@ def GetShips(token):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -90,7 +91,7 @@ def PostShip(schemaModel):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()
@ -133,7 +134,7 @@ def PutShip(schemaModel):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()
@ -151,21 +152,22 @@ 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"]})
affected_rows = commands.execute("UPDATE ship SET deleted = 1 WHERE id = ?id?", 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" : ship_id})
if affected_rows == 1: if affected_rows == 1:
return json.dumps({"id" : options["id"]}), 200, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps({"id" : ship_id}), 200, {'Content-Type': 'application/json; charset=utf-8'}
result = {} result = {}
result["error_field"] = "no such record" result["error_field"] = "no such record"
return json.dumps(result), 404, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 404
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 json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
pooledConnection.close() pooledConnection.close()

View File

@ -2,6 +2,7 @@ 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
@ -35,7 +36,7 @@ def GetTimes(options):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -108,7 +109,7 @@ def PostTimes(schemaModel):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -130,7 +131,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 json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'} return jsonify("no such record"), 404
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
@ -177,7 +178,7 @@ def PutTimes(schemaModel, original_payload=None):
print(ex) print(ex)
result = {} result = {}
result["error_field"] = "call failed" result["error_field"] = "call failed"
return json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:
@ -193,8 +194,9 @@ def DeleteTimes(options):
try: try:
pooledConnection = local_db.getPoolConnection() pooledConnection = local_db.getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
shipcall_id = commands.execute_scalar("SELECT shipcall_id FROM times WHERE id = ?id?", param={"id" : options["id"]}) times_id = int(options["id"]) if not isinstance(options["id"], int) else options["id"]
affected_rows = commands.execute("DELETE 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" : times_id})
# TODO: set ETA properly? # TODO: set ETA properly?
@ -204,18 +206,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" : options["id"]}), 200, {'Content-Type': 'application/json; charset=utf-8'} return json.dumps({"id" : times_id}), 200, {'Content-Type': 'application/json; charset=utf-8'}
result = {} result = {}
result["error_field"] = "no such record" result["error_field"] = "no such record"
return json.dumps(result), 404, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 404
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 json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -2,6 +2,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
@ -27,7 +28,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 json.dumps("no such record"), 404, {'Content-Type': 'application/json; charset=utf-8'} return jsonify({"error_field": "no such record"}), 404
# 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
@ -70,16 +71,16 @@ def PutUser(schemaModel):
else: else:
result = {} result = {}
result["error_field"] = "old password invalid" result["error_field"] = "old password invalid"
return json.dumps(result), 400, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 400
return json.dumps({"id" : schemaModel["id"]}), 200 return jsonify({"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 json.dumps(result), 500, {'Content-Type': 'application/json; charset=utf-8'} return jsonify(result), 500
finally: finally:
if pooledConnection is not None: if pooledConnection is not None:

View File

@ -18,3 +18,6 @@ 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 from marshmallow import Schema, fields, INCLUDE, ValidationError, validate, validates, post_load, pre_load, EXCLUDE
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,13 +17,31 @@ 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 obj.isoformat() return _format_datetime(obj)
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
@ -36,6 +54,19 @@ 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
@ -45,6 +76,16 @@ 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
@ -148,8 +189,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": self.timestamp.isoformat() if self.timestamp else "", "timestamp": _format_datetime(self.timestamp),
"eta": self.eta.isoformat() if self.eta else "", "eta": _format_datetime(self.eta),
"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
} }
@ -190,8 +231,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": self.created.isoformat() if self.created else "", "created": _format_datetime(self.created),
"modified": self.modified.isoformat() if self.modified else "" "modified": _format_datetime(self.modified)
} }
@classmethod @classmethod
@ -212,6 +253,21 @@ 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
@ -243,7 +299,7 @@ class ParticipantAssignmentSchema(Schema):
class ShipcallSchema(Schema): class ShipcallSchema(Schema):
def __init__(self): def __init__(self):
super().__init__(unknown=None) super().__init__(unknown=EXCLUDE)
pass pass
id = fields.Integer(required=True) id = fields.Integer(required=True)
@ -365,34 +421,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": self.eta.isoformat() if self.eta else "", "eta": _format_datetime(self.eta),
"voyage": self.voyage, "voyage": self.voyage,
"etd": self.etd.isoformat() if self.etd else "", "etd": _format_datetime(self.etd),
"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": self.tug_required, "tug_required": _coerce_bool(self.tug_required),
"pilot_required": self.pilot_required, "pilot_required": _coerce_bool(self.pilot_required),
"flags": self.flags, "flags": self.flags,
"pier_side": self.pier_side, "pier_side": _coerce_bool(self.pier_side),
"bunkering": self.bunkering, "bunkering": _coerce_bool(self.bunkering),
"replenishing_terminal": self.replenishing_terminal, "replenishing_terminal": _coerce_bool(self.replenishing_terminal),
"replenishing_lock": self.replenishing_lock, "replenishing_lock": _coerce_bool(self.replenishing_lock),
"draft": self.draft, "draft": self.draft,
"tidal_window_from": self.tidal_window_from.isoformat() if self.tidal_window_from else "", "tidal_window_from": _format_datetime(self.tidal_window_from),
"tidal_window_to": self.tidal_window_to.isoformat() if self.tidal_window_to else "", "tidal_window_to": _format_datetime(self.tidal_window_to),
"rain_sensitive_cargo": self.rain_sensitive_cargo, "rain_sensitive_cargo": _coerce_bool(self.rain_sensitive_cargo),
"recommended_tugs": self.recommended_tugs, "recommended_tugs": self.recommended_tugs,
"anchored": self.anchored, "anchored": _coerce_bool(self.anchored),
"moored_lock": self.moored_lock, "moored_lock": _coerce_bool(self.moored_lock),
"canceled": self.canceled, "canceled": _coerce_bool(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": self.evaluation_time.isoformat() if self.evaluation_time else "", "evaluation_time": _format_datetime(self.evaluation_time),
"evaluation_notifications_sent": self.evaluation_notifications_sent, "evaluation_notifications_sent": _coerce_bool(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": self.created.isoformat() if self.created else "", "created": _format_datetime(self.created),
"modified": self.modified.isoformat() if self.modified else "", "modified": _format_datetime(self.modified),
"participants": [participant.__dict__ for participant in self.participants] "participants": [participant.__dict__ for participant in self.participants]
} }
@ -400,7 +456,39 @@ 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(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) return self(
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
@ -515,9 +603,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=64)]) first_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)]) last_name = fields.String(allow_none=True, required=False, validate=[validate.Length(max=45)])
user_phone = fields.String(allow_none=True, required=False) user_phone = fields.String(allow_none=True, required=False, validate=[validate.Length(max=32)])
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)])
@ -527,6 +615,14 @@ 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:
@ -567,6 +663,34 @@ 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:
@ -610,6 +734,23 @@ 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):
@ -630,6 +771,14 @@ 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))
@ -696,6 +845,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": self.created.isoformat() if self.created else "", "created": _format_datetime(self.created),
"modified": self.modified.isoformat() if self.modified else "", "modified": _format_datetime(self.modified),
} }

View File

@ -1,5 +1,4 @@
import json from flask import request, jsonify
from flask import request
from .jwt_handler import decode_jwt from .jwt_handler import decode_jwt
def check_jwt(): def check_jwt():
@ -25,9 +24,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 json.dumps({"error_field" : f'{e}', "status": 401}), 401 return jsonify({"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 json.dumps({"error_field": 'Authorization required.', "status" : 403}), 403 return jsonify({"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,6 +3,7 @@ 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
@ -110,12 +111,60 @@ def SendEmails(email_dict):
pooledConnection = getPoolConnection() pooledConnection = getPoolConnection()
commands = pydapper.using(pooledConnection) commands = pydapper.using(pooledConnection)
conn = smtplib.SMTP(defs.email_credentials["server"], defs.email_credentials["port"]) def _parse_bool(value) -> bool:
conn.set_debuglevel(1) # set this to 0 to disable debug output to log 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"])
try:
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()
conn.starttls() if encryption_norm in ("STARTTLS", "STARTSSL"):
conn.ehlo() conn.starttls()
conn.login(defs.email_credentials["sender"], defs.email_credentials["password_send"]) conn.ehlo()
elif encryption_norm in ("NONE", "NO", "DISABLE"):
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__))
@ -261,6 +310,13 @@ 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)
@ -326,7 +382,7 @@ def eval_next_24_hrs():
return return
def setup_schedule(update_shipcalls_interval_in_minutes:int=60): def setup_schedule(update_shipcalls_interval_in_minutes:int=60, notification_cooldown_mins:int=defs.NOTIFICATION_COOLDOWN_MINS):
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
@ -335,7 +391,7 @@ def setup_schedule(update_shipcalls_interval_in_minutes:int=60):
# 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(defs.NOTIFICATION_COOLDOWN_MINS) add_function_to_evaluate_notifications(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,6 +177,8 @@ 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"])
@ -229,8 +231,10 @@ 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["participant_type"] participant_type = loadedModel.get("participant_type")
shipcall_id = loadedModel["shipcall_id"] shipcall_id = loadedModel.get("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
@ -319,7 +323,9 @@ class InputValidationTimes(InputValidationBase):
""" """
### TIMES DATASET (ShipcallParticipantMap) ### ### TIMES DATASET (ShipcallParticipantMap) ###
# identify shipcall_id # identify shipcall_id
shipcall_id = loadedModel["shipcall_id"] shipcall_id = loadedModel.get("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
@ -434,6 +440,8 @@ 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,6 +2,7 @@ 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
@ -29,7 +30,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":v[0] if isinstance(v,list) else str(v)}) for k,v in errors.items()} {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()}
return return
def unbundle_validation_error_message(message): def unbundle_validation_error_message(message):
@ -40,8 +41,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([unb["error_field"] for unb in unbundled]) error_field = "ValidationError in the following field(s): " + " & ".join([str(unb["error_field"]) for unb in unbundled])
error_description = " " . join([unb["error_description"] for unb in unbundled]) error_description = " " . join([str(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"
@ -53,35 +54,28 @@ 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 (serialized_response, status_code) return (jsonify(json_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 serialized_response, status_code return jsonify(json_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 (serialized_response, status_code) return (jsonify(json_response), status_code)

View File

@ -135,15 +135,22 @@ 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})
if state_new == 1 and state_old != 0: # this resolves the time conflict resolves_time_conflict = (state_old == StatusFlags.RED.value) and (state_new in [StatusFlags.GREEN.value, StatusFlags.YELLOW.value])
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 = f"DELETE from notification where shipcall_id = ?shipcall_id? and type = {notification_type} and level = 0" query = "SELECT COUNT(*) as cnt FROM notification WHERE shipcall_id = ?shipcall_id? AND type = ?type?"
deleted_count = commands.execute(query, param={"shipcall_id" : int(shipcall_id)}) result = commands.query(query, model=dict, param={"shipcall_id" : int(shipcall_id), "type" : notification_type})
logging.info(f"Deleted {deleted_count} existing notifications (yet unsent)") has_conflict_notification = (len(result) > 0) and (result[0].get("cnt", 0) > 0)
if deleted_count == 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)")
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://neptun.fritz.box" url = "http://127.0.0.1:5000"
# raise RuntimeError("Set API_BASE_URL") # raise RuntimeError("Set API_BASE_URL")
return url.rstrip("/") return url.rstrip("/")

View File

@ -1,6 +1,16 @@
import os
import pytest
import schemathesis import schemathesis
from schemathesis import checks
schema = schemathesis.openapi.from_path("../../../misc/BreCalApi.yaml") schema = schemathesis.openapi.from_path(
"../../../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(
@ -9,10 +19,18 @@ 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, json=login_payload) response = case.call(base_url=base_url)
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) case.validate_response(response, checks=custom_checks)