diff --git a/AKModel/tests/test_json_export.py b/AKModel/tests/test_json_export.py index e8aac8fed36ba4f6917c3e22e87ae2ebec739775..78046fc9617e2810d24d7d962c02745d851f567a 100644 --- a/AKModel/tests/test_json_export.py +++ b/AKModel/tests/test_json_export.py @@ -1,6 +1,5 @@ import json import math - from collections import defaultdict from collections.abc import Iterable from datetime import datetime, timedelta @@ -10,17 +9,11 @@ from bs4 import BeautifulSoup from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse +from jsonschema.exceptions import best_match from AKModel.availability.models import Availability -from AKModel.models import ( - Event, - AKOwner, - AKCategory, - AK, - Room, - AKSlot, - DefaultSlot, -) +from AKModel.models import AK, AKCategory, AKOwner, AKSlot, DefaultSlot, Event, Room +from AKModel.utils import construct_schema_validator class JSONExportTest(TestCase): @@ -44,6 +37,10 @@ class JSONExportTest(TestCase): is_active=True, ) + cls.json_export_validator = construct_schema_validator( + "solver-input-export.schema.json" + ) + def setUp(self): self.client.force_login(self.admin_user) self.export_dict = {} @@ -119,204 +116,43 @@ class JSONExportTest(TestCase): if contained_type in {str, int}: self._check_uniqueness(lst, name, key=None) - def test_ak_conformity_to_spec(self): - """Test if AK JSON structure and types conform to standard.""" + def test_conformity_to_schema(self): + """Test if JSON structure and types conform to schema.""" for event in Event.objects.all(): with self.subTest(event=event): self.set_up_event(event=event) - self._check_uniqueness(self.export_dict["aks"], "AK") - for ak in self.export_dict["aks"]: - item = f"AK {ak['id']}" - self.assertEqual( - ak.keys(), - { - "id", - "duration", - "properties", - "room_constraints", - "time_constraints", - "info", - }, - f"{item} keys not as expected", - ) - self.assertEqual( - ak["info"].keys(), - { - "name", - "head", - "description", - "reso", - "duration_in_hours", - "django_ak_id", - "types", - }, - f"{item} info keys not as expected", - ) - self.assertEqual( - ak["properties"].keys(), - {"conflicts", "dependencies"}, - f"{item} properties keys not as expected", - ) - - self._check_type(ak["id"], int, "id", item=item) - self._check_type(ak["duration"], int, "duration", item=item) - self._check_type(ak["info"]["name"], str, "info/name", item=item) - self._check_type(ak["info"]["head"], str, "info/head", item=item) - self._check_type( - ak["info"]["description"], str, "info/description", item=item - ) - self._check_type(ak["info"]["reso"], bool, "info/reso", item=item) - self._check_type( - ak["info"]["duration_in_hours"], - float, - "info/duration_in_hours", - item=item, - ) - self._check_type( - ak["info"]["django_ak_id"], - int, - "info/django_ak_id", - item=item, - ) - - self._check_lst( - ak["properties"]["conflicts"], - "conflicts", - item=item, - contained_type=int, - ) - self._check_lst( - ak["properties"]["dependencies"], - "dependencies", - item=item, - contained_type=int, - ) - self._check_lst( - ak["time_constraints"], "time_constraints", item=item - ) - self._check_lst( - ak["room_constraints"], "room_constraints", item=item - ) + error = best_match( + self.json_export_validator.iter_errors(self.export_dict) + ) + msg = "" if not error else f"{error.message} at {error.json_path}" + self.assertFalse(error, msg) - def test_room_conformity_to_spec(self): - """Test if Room JSON structure and types conform to standard.""" + def test_id_uniqueness(self): + """Test if objects are only exported once.""" for event in Event.objects.all(): with self.subTest(event=event): self.set_up_event(event=event) - self._check_uniqueness(self.export_dict["rooms"], "Room") - for room in self.export_dict["rooms"]: - item = f"Room {room['id']}" - self.assertEqual( - room.keys(), - { - "id", - "info", - "capacity", - "fulfilled_room_constraints", - "time_constraints", - }, - f"{item} keys not as expected", - ) - self.assertEqual( - room["info"].keys(), - {"name"}, - f"{item} info keys not as expected", - ) - - self._check_type(room["id"], int, "id", item=item) - self._check_type(room["capacity"], int, "capacity", item=item) - self._check_type(room["info"]["name"], str, "info/name", item=item) - - self.assertTrue( - room["capacity"] > 0 or room["capacity"] == -1, - "invalid room capacity", - ) - - self._check_lst( - room["time_constraints"], "time_constraints", item=item - ) - self._check_lst( - room["fulfilled_room_constraints"], - "fulfilled_room_constraints", - item=item, - ) - - def test_timeslots_conformity_to_spec(self): - """Test if Timeslots JSON structure and types conform to standard.""" - for event in Event.objects.all(): - with self.subTest(event=event): - self.set_up_event(event=event) + self._check_uniqueness(self.export_dict["aks"], "AKs") + self._check_uniqueness(self.export_dict["rooms"], "Rooms") + self._check_uniqueness(self.export_dict["participants"], "Participants") self._check_uniqueness( chain.from_iterable(self.export_dict["timeslots"]["blocks"]), "Timeslots", ) - item = "timeslots" - self.assertEqual( - self.export_dict["timeslots"].keys(), - {"info", "blocks"}, - "timeslot keys not as expected", - ) - self.assertEqual( - self.export_dict["timeslots"]["info"].keys(), - {"duration", "blocknames"}, - "timeslot info keys not as expected", - ) - self._check_type( - self.export_dict["timeslots"]["info"]["duration"], - float, - "info/duration", - item=item, - ) - self._check_lst( - self.export_dict["timeslots"]["info"]["blocknames"], - "info/blocknames", - item=item, - contained_type=list, - ) - for blockname in self.export_dict["timeslots"]["info"]["blocknames"]: - self.assertEqual(len(blockname), 2) - self._check_lst( - blockname, - "info/blocknames/entry", - item=item, - contained_type=str, - ) - self._check_lst( - self.export_dict["timeslots"]["blocks"], - "blocks", - item=item, - contained_type=list, - ) + def test_timeslot_ids_consecutive(self): + """Test if Timeslots ids are chronologically consecutive.""" + for event in Event.objects.all(): + with self.subTest(event=event): + self.set_up_event(event=event) prev_id = None for timeslot in chain.from_iterable( self.export_dict["timeslots"]["blocks"] ): - item = f"timeslot {timeslot['id']}" - self.assertEqual( - timeslot.keys(), - {"id", "info", "fulfilled_time_constraints"}, - f"{item} keys not as expected", - ) - self.assertEqual( - timeslot["info"].keys(), - {"start", "end"}, - f"{item} info keys not as expected", - ) - self._check_type(timeslot["id"], int, "id", item=item) - self._check_type( - timeslot["info"]["start"], str, "info/start", item=item - ) - self._check_lst( - timeslot["fulfilled_time_constraints"], - "fulfilled_time_constraints", - item=item, - ) - if prev_id is not None: self.assertLess( prev_id, @@ -351,8 +187,6 @@ class JSONExportTest(TestCase): getattr(self.event, attr_field), self.export_dict["info"][attr] ) - self._check_uniqueness(self.export_dict["participants"], "Participants") - def test_ak_durations(self): """Test if all AK durations are correct.""" for event in Event.objects.all(): diff --git a/AKModel/utils.py b/AKModel/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..a7b5d2130d07ad046c36dc1790a94c40efe6d173 --- /dev/null +++ b/AKModel/utils.py @@ -0,0 +1,43 @@ +from pathlib import Path + +import referencing.retrieval +from jsonschema import Draft202012Validator +from jsonschema.protocols import Validator +from referencing import Registry + +from AKPlanning import settings + + +def _construct_schema_path(uri: str | Path) -> Path: + """Construct a schema URI. + + This function also checks for unallowed directory traversals + out of the 'schema' subfolder. + """ + schema_base_path = Path(settings.BASE_DIR).resolve() + uri_path = (schema_base_path / uri).resolve() + if not uri_path.is_relative_to(schema_base_path / "schemas"): + raise ValueError("Unallowed dictionary traversal") + return uri_path + + +@referencing.retrieval.to_cached_resource() +def retrieve_schema_from_disk(uri: str) -> str: + """Retrieve schemas from disk by URI.""" + uri_path = _construct_schema_path(uri) + with uri_path.open("r") as ff: + return ff.read() + + +def construct_schema_validator(schema: str | dict) -> Validator: + """Construct a validator for a JSON schema. + + In particular, all schemas from the 'schemas' directory + are loaded into the registry. + """ + registry = Registry(retrieve=retrieve_schema_from_disk) + + if isinstance(schema, str): + schema_uri = str(Path("schemas") / schema) + schema = registry.get_or_retrieve(schema_uri).value.contents + return Draft202012Validator(schema=schema, registry=registry) diff --git a/requirements.txt b/requirements.txt index 9b0938d4a76aff97222757ebd6ac30ac3efef24a..dc320c25b5f22c90f4b77e72f99c23e1e99b7513 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ djangorestframework==3.15.2 fontawesomefree==6.6.0 # Makes static files (css, fonts) available locally mysqlclient==2.2.7 # for production deployment tzdata==2025.1 +jsonschema==4.23.0 # Documentation Sphinx==8.2.3 diff --git a/schemas/ak-export.schema.json b/schemas/ak-export.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..b4c8d63cdee632018ab7b6a58919e45a9ef1ff63 --- /dev/null +++ b/schemas/ak-export.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/ak-export.schema.json", + "properties": { + "info": { + "$ref": "ak.schema.json#/properties/info", + "unevaluatedProperties": false + } + } +} diff --git a/schemas/ak.schema.json b/schemas/ak.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..a8578a347cf78c337f7c99f15942cf268fe1576a --- /dev/null +++ b/schemas/ak.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/ak.schema.json", + "title": "AK", + "type": "object", + "properties": { + "id": { + "$ref": "common/id.schema.json", + "description": "The unique identifier of an AK" + }, + "duration": { + "description": "The number of consecutive slot units", + "type": "integer", + "exclusiveMinimum": 0 + }, + "room_constraints": { + "$ref": "common/constraints.schema.json", + "description": "Room constraints required by this AK" + }, + "time_constraints": { + "$ref": "common/constraints.schema.json", + "description": "Time constraints required by this AK" + }, + "properties": { + "type": "object", + "properties": { + "conflicts": { + "$ref": "common/id_array.schema.json", + "description": "IDs of all AKs that are in conflict with this AK" + }, + "dependencies": { + "$ref": "common/id_array.schema.json", + "description": "IDs of all AKs that should be scheduled before this AK" + } + }, + "required": ["conflicts", "dependencies"], + "additionalProperties": false + }, + "info": { + "type": "object", + "properties": { + "name": {"description": "Name of the AK", "type": "string"}, + "head": {"description": "Name of the head of the AK", "type": "string"}, + "description": {"description": "Short description of the AK", "type": "string"}, + "reso": {"description": "Whether this AK intends to introduce a resolution", "type": "boolean"}, + "duration_in_hours": {"description": "AK duration in hours", "type": "number"}, + "django_ak_id": { + "$ref": "common/id.schema.json", + "description": "Unique identifier of the AK object in the django database" + }, + "types": { + "$ref": "common/constraints.schema.json", + "description": "Types of this AK" + } + }, + "required": ["name", "head", "description", "reso", "duration_in_hours", "django_ak_id", "types"] + } + }, + "required": ["id", "duration", "room_constraints", "time_constraints", "properties", "info"], + "additionalProperties": false +} diff --git a/schemas/common/constraints.schema.json b/schemas/common/constraints.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..21c0dcd661673da3e90d795df2c77cc700905808 --- /dev/null +++ b/schemas/common/constraints.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/common/constraints.schema.json", + "type": "array", + "items": {"type": "string"}, + "uniqueItems": true +} \ No newline at end of file diff --git a/schemas/common/id.schema.json b/schemas/common/id.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..636d6c709c54d425d94ef9dbee8c28d232e3319b --- /dev/null +++ b/schemas/common/id.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/common/id.schema.json", + "type": "integer", + "minimum": 0 +} \ No newline at end of file diff --git a/schemas/common/id_array.schema.json b/schemas/common/id_array.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..322fcb6ba76d8c073190d6ac6825fd834e8e8b7a --- /dev/null +++ b/schemas/common/id_array.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/common/id_array.schema.json", + "type": "array", + "items": {"type": "integer"}, + "uniqueItems": true +} \ No newline at end of file diff --git a/schemas/participant-export.schema.json b/schemas/participant-export.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..d47a33c29bfbe4b3278feda411d7786ebb873254 --- /dev/null +++ b/schemas/participant-export.schema.json @@ -0,0 +1,22 @@ + +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/participant-export.schema.json", + "properties": { + "preferences": { + "items": { + "properties": { + "preference_score": { + "anyOf": [ + {"const": -1}, {"const": 1}, {"const": 2} + ] + } + } + } + }, + "info": { + "$ref": "participant.schema.json#/properties/info", + "unevaluatedProperties": false + } + } +} diff --git a/schemas/participant.schema.json b/schemas/participant.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..70631eb10d3a347e0412fe1ba4adb4c1c7d9035b --- /dev/null +++ b/schemas/participant.schema.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/participant.schema.json", + "title": "Participant", + "type": "object", + "properties": { + "id": { + "$ref": "common/id.schema.json", + "description": "The unique identifier of a participant" + }, + "preferences": { + "description": "AK preferences of the participant", + "type": "array", + "items": { + "type": "object", + "properties": { + "ak_id": { + "$ref": "common/id.schema.json", + "description": "The unique identifier of the AK" + }, + "required": { + "type": "boolean", + "description": "whether this participant is required for the AK" + }, + "preference_score": { + "type": "integer", + "description": "The prefeference score for this AK", + "default": 0, + "minimum": -1, + "maximum": 2 + } + }, + "required": ["ak_id", "required", "preference_score"], + "additionalProperties": false + }, + "uniqueItems": true + }, + "room_constraints": { + "$ref": "common/constraints.schema.json", + "description": "Room constraints required by this participant" + }, + "time_constraints": { + "$ref": "common/constraints.schema.json", + "description": "Time constraints required by this participant" + }, + "info": { + "type": "object", + "properties": {"name": {"description": "Name of the person", "type": "string"}}, + "required": ["name"] + } + }, + "required": ["id", "room_constraints", "time_constraints", "info"], + "additionalProperties": false +} diff --git a/schemas/room-export.schema.json b/schemas/room-export.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..8942a2d383b30f7aac1806239c2bdc9a4b9fb92d --- /dev/null +++ b/schemas/room-export.schema.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/room-export.schema.json", + "properties": { + "info": { + "$ref": "room.schema.json#/properties/info", + "unevaluatedProperties": false + } + } +} diff --git a/schemas/room.schema.json b/schemas/room.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..dbf331f124f6e96fc05c2c533ed34a30105417fb --- /dev/null +++ b/schemas/room.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/room.schema.json", + "title": "Room", + "type": "object", + "properties": { + "id": { + "$ref": "common/id.schema.json", + "description": "The unique identifier of a room" + }, + "capacity": { + "description": "The maximum number of total participants. Unbounded capacity is represented by -1", + "type": "integer", + "anyOf": [ + {"minimum": 1}, {"const": -1} + ] + }, + "fulfilled_room_constraints": { + "$ref": "common/constraints.schema.json", + "description": "Constraints fulfilled by this room" + }, + "time_constraints": { + "$ref": "common/constraints.schema.json", + "description": "Time constraints required by this room" + }, + "info": { + "type": "object", + "properties": { + "name": {"description": "Name of the room", "type": "string"} + }, + "required": ["name"] + } + }, + "required": ["id", "capacity", "fulfilled_room_constraints", "time_constraints", "info"], + "additionalProperties": false +} \ No newline at end of file diff --git a/schemas/solver-input-export.schema.json b/schemas/solver-input-export.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..6fbf48a7ef01da9dbc19cdb719e3a3bed6ae2355 --- /dev/null +++ b/schemas/solver-input-export.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/solver-input-export.schema.json", + "allOf": [{ "$ref": "solver-input.schema.json"}], + "properties": { + "participants": {"items": {"$ref": "participant-export.schema.json"}}, + "rooms": {"items": {"$ref": "room-export.schema.json"}}, + "timeslots": {"items": {"$ref": "timeslot-export.schema.json"}}, + "aks": {"items": {"$ref": "ak-export.schema.json"}}, + "info": { + "$ref": "solver-input.schema.json#/properties/info", + "unevaluatedProperties": false + } + } +} \ No newline at end of file diff --git a/schemas/solver-input.schema.json b/schemas/solver-input.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..faece29b0b2bbf402798646d2d112e74a778e9ca --- /dev/null +++ b/schemas/solver-input.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/solver-input.schema.json", + "type": "object", + "properties": { + "aks": {"type": "array", "items": {"$ref": "ak.schema.json"}, "uniqueItems": true}, + "rooms": {"type": "array", "items": {"$ref": "room.schema.json"}, "uniqueItems": true}, + "participants": {"type": "array", "items": {"$ref": "participant.schema.json"}, "uniqueItems": true}, + "timeslots": {"$ref": "timeslot.schema.json"}, + "info": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "slug": {"type": "string"}, + "place": {"type": "string"}, + "contact_email": {"type": "string"} + } + } + }, + "required": ["aks", "rooms", "participants", "timeslots", "info"], + "additionalProperties": false +} diff --git a/schemas/solver-output.schema.json b/schemas/solver-output.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..c4a8868af02242e9e1fc789c812ac928eb520b31 --- /dev/null +++ b/schemas/solver-output.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/solver-output.schema.json", + "type": "object", + "additionalProperties": false, + "required": ["input", "scheduled_aks"], + "properties": { + "input": {"$ref": "solver-input.schema.json"}, + "scheduled_aks": { + "type": "array", + "items": { + "description": "An object representing the scheduling information for one AK", + "type": "object", + "properties": { + "ak_id": { + "description": "The unique identifier of the scheduled AK", + "type": "integer", + "minimum": 0 + }, + "room_id": { + "description": "The unique identifier of the room the AK takes place in", + "type": "integer", + "minimum": 0 + }, + "timeslot_ids": { + "description": "The unique identifiers of all timeslots the AK takes place in", + "type": "array", + "items": { + "description": "The unique identifier of the referenced timeslot", + "type": "integer", + "minimum": 0 + }, + "uniqueItems": true + }, + "participant_ids": { + "description": "The unique identifiers of all participants assigned to the AK", + "type": "array", + "items": { + "description": "The unique identifier of the referenced participant", + "type": "integer", + "minimum": 0 + }, + "uniqueItems": true + } + }, + "required": ["ak_id", "room_id", "timeslot_ids", "participant_ids"], + "additionalProperties": false + }, + "uniqueItems": true + } + } +} diff --git a/schemas/timeslot-export.schema.json b/schemas/timeslot-export.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..1689ad38add2c48e0b4be5a0764f0f9e65bc815f --- /dev/null +++ b/schemas/timeslot-export.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/timeslot-export.schema.json", + "properties": { + "info": { + "$ref": "timeslot.schema.json#/properties/info", + "unevaluatedProperties": false + }, + "blocks": { + "items": { + "items": { + "info": { + "$ref": "timeslot.schema.json#/properties/blocks/items/items/info", + "unevaluatedProperties": false + } + } + } + } + } +} diff --git a/schemas/timeslot.schema.json b/schemas/timeslot.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..2cd23cd0fc877c2d4ce05b11a87b797f9299083c --- /dev/null +++ b/schemas/timeslot.schema.json @@ -0,0 +1,59 @@ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "schemas/timeslot.schema.json", + "title": "Timeslot", + "type": "object", + "required": ["info", "blocks"], + "additionalProperties": false, + "properties": { + "info": { + "type": "object", + "properties": { + "duration": {"description": "Duration in hours of a slot unit", "type": "number"}, + "blocknames": { + "type": "array", + "items": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + "maxItems": 2 + } + } + }, + "required": ["duration"] + }, + "blocks": { + "type": "array", + "description": "Blocks of consecutive timeslots", + "items": { + "type": "array", + "description": "A single block of consecutive timeslots", + "items": { + "type": "object", + "description": "A single timeslot", + "properties": { + "id": { + "$ref": "common/id.schema.json", + "description": "The unique identifier of the single timeslot. Accross all blocks, the ids must be sorted chronologically." + }, + "info": { + "type": "object", + "properties": { + "start": {"description": "Start datetime of the timeslot", "type": "string"}, + "end": {"description": "End datetime of the timeslot", "type": "string"} + }, + "required": ["start", "end"] + }, + "fulfilled_time_constraints": { + "$ref": "common/constraints.schema.json", + "description": "Time constraints fulfilled by this timeslot" + } + }, + "required": ["id", "info", "fulfilled_time_constraints"], + "additionalProperties": false + } + } + } + } +} + \ No newline at end of file