Extra schemathesis validation. Still cases failing, but it is much better now

This commit is contained in:
Daniel Schick 2026-01-14 14:39:56 +01:00
parent 77959b4a50
commit bbd96c47ed
10 changed files with 168 additions and 56 deletions

View File

@ -200,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':
@ -256,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':
@ -313,6 +317,8 @@ paths:
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
'404': '404':
$ref: '#/components/responses/404' $ref: '#/components/responses/404'
'409':
$ref: '#/components/responses/409'
'500': '500':
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
'503': '503':
@ -418,6 +424,8 @@ paths:
$ref: '#/components/responses/401' $ref: '#/components/responses/401'
'404': '404':
$ref: '#/components/responses/404' $ref: '#/components/responses/404'
'409':
$ref: '#/components/responses/409'
'500': '500':
$ref: '#/components/responses/500' $ref: '#/components/responses/500'
'503': '503':
@ -462,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':
@ -502,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:
@ -681,7 +695,10 @@ 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:
type: integer oneOf:
- type: integer
- type: string
pattern: '^[0-9]+$'
example: 7 example: 7
responses: responses:
'200': '200':
@ -862,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
@ -981,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:
@ -1650,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
@ -1816,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:
@ -2049,6 +2073,15 @@ components:
example: example:
error_field: No such record error_field: No such record
error_description: The requested resource to update was 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

@ -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

@ -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

@ -152,10 +152,11 @@ 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"

View File

@ -194,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?
@ -205,7 +206,7 @@ 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"

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,9 +17,18 @@ 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() + "Z"
return value.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__
@ -53,8 +62,8 @@ class Berth(Schema):
"owner_id": self.owner_id, "owner_id": self.owner_id,
"authority_id": self.authority_id, "authority_id": self.authority_id,
"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),
"deleted": _coerce_bool(self.deleted), "deleted": _coerce_bool(self.deleted),
} }
@ -72,8 +81,8 @@ class Port(Schema):
"id": self.id, "id": self.id,
"name": self.name, "name": self.name,
"locode": self.locode, "locode": self.locode,
"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),
"deleted": _coerce_bool(self.deleted), "deleted": _coerce_bool(self.deleted),
} }
@ -180,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
} }
@ -222,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
@ -253,8 +262,8 @@ class Participant(Schema):
"city": self.city, "city": self.city,
"type": self.type, "type": self.type,
"flags": self.flags, "flags": self.flags,
"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),
"deleted": _coerce_bool(self.deleted), "deleted": _coerce_bool(self.deleted),
"ports": self.ports, "ports": self.ports,
} }
@ -290,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)
@ -412,9 +421,9 @@ 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": _coerce_bool(self.tug_required), "tug_required": _coerce_bool(self.tug_required),
@ -425,8 +434,8 @@ class Shipcall:
"replenishing_terminal": _coerce_bool(self.replenishing_terminal), "replenishing_terminal": _coerce_bool(self.replenishing_terminal),
"replenishing_lock": _coerce_bool(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": _coerce_bool(self.rain_sensitive_cargo), "rain_sensitive_cargo": _coerce_bool(self.rain_sensitive_cargo),
"recommended_tugs": self.recommended_tugs, "recommended_tugs": self.recommended_tugs,
"anchored": _coerce_bool(self.anchored), "anchored": _coerce_bool(self.anchored),
@ -434,12 +443,12 @@ class Shipcall:
"canceled": _coerce_bool(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": _coerce_bool(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]
} }
@ -596,8 +605,8 @@ class UserSchema(Schema):
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=45)])
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=45)])
user_phone = fields.String(allow_none=True, required=False, validate=[validate.Length(max=128)]) 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=128)]) 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)])
notify_email = fields.Bool(allow_none=True, required=False) notify_email = fields.Bool(allow_none=True, required=False)
@ -606,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:
@ -649,16 +666,16 @@ class Times:
def to_json(self): def to_json(self):
return { return {
"id": self.id, "id": self.id,
"eta_berth": self.eta_berth.isoformat() if self.eta_berth else "", "eta_berth": _format_datetime(self.eta_berth),
"eta_berth_fixed": _coerce_bool(self.eta_berth_fixed), "eta_berth_fixed": _coerce_bool(self.eta_berth_fixed),
"etd_berth": self.etd_berth.isoformat() if self.etd_berth else "", "etd_berth": _format_datetime(self.etd_berth),
"etd_berth_fixed": _coerce_bool(self.etd_berth_fixed), "etd_berth_fixed": _coerce_bool(self.etd_berth_fixed),
"lock_time": self.lock_time.isoformat() if self.lock_time else "", "lock_time": _format_datetime(self.lock_time),
"lock_time_fixed": _coerce_bool(self.lock_time_fixed), "lock_time_fixed": _coerce_bool(self.lock_time_fixed),
"zone_entry": self.zone_entry.isoformat() if self.zone_entry else "", "zone_entry": _format_datetime(self.zone_entry),
"zone_entry_fixed": _coerce_bool(self.zone_entry_fixed), "zone_entry_fixed": _coerce_bool(self.zone_entry_fixed),
"operations_start": self.operations_start.isoformat() if self.operations_start else "", "operations_start": _format_datetime(self.operations_start),
"operations_end": self.operations_end.isoformat() if self.operations_end else "", "operations_end": _format_datetime(self.operations_end),
"remarks": self.remarks, "remarks": self.remarks,
"participant_id": self.participant_id, "participant_id": self.participant_id,
"berth_id": self.berth_id, "berth_id": self.berth_id,
@ -666,12 +683,12 @@ class Times:
"pier_side": _coerce_bool(self.pier_side), "pier_side": _coerce_bool(self.pier_side),
"participant_type": self.participant_type, "participant_type": self.participant_type,
"shipcall_id": self.shipcall_id, "shipcall_id": self.shipcall_id,
"ata": self.ata.isoformat() if self.ata else "", "ata": _format_datetime(self.ata),
"atd": self.atd.isoformat() if self.atd else "", "atd": _format_datetime(self.atd),
"eta_interval_end": self.eta_interval_end.isoformat() if self.eta_interval_end else "", "eta_interval_end": _format_datetime(self.eta_interval_end),
"etd_interval_end": self.etd_interval_end.isoformat() if self.etd_interval_end else "", "etd_interval_end": _format_datetime(self.etd_interval_end),
"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),
} }
@dataclass @dataclass
@ -720,7 +737,7 @@ class Ship:
def to_json(self): def to_json(self):
return { return {
"id": self.id, "id": self.id,
"name": self.name, "name": self.name if self.name is not None else "",
"imo": self.imo, "imo": self.imo,
"callsign": self.callsign, "callsign": self.callsign,
"participant_id": self.participant_id, "participant_id": self.participant_id,
@ -729,8 +746,8 @@ class Ship:
"is_tug": _coerce_bool(self.is_tug), "is_tug": _coerce_bool(self.is_tug),
"bollard_pull": self.bollard_pull, "bollard_pull": self.bollard_pull,
"eni": self.eni, "eni": self.eni,
"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),
"deleted": _coerce_bool(self.deleted), "deleted": _coerce_bool(self.deleted),
} }
@ -754,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))
@ -820,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

@ -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