diff --git a/AKModel/tests/test_json_export.py b/AKModel/tests/test_json_export.py index e8aac8fed36ba4f6917c3e22e87ae2ebec739775..5ec117497b2afd6bdb54cbc1507485303a2df451 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.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 error is None else error.message + self.assertTrue(error is None, 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..3a0b3f0d57cb646954cd34a9a76c5903ee74cc10 --- /dev/null +++ b/AKModel/utils.py @@ -0,0 +1,27 @@ +import json +from pathlib import Path + +from jsonschema import Draft202012Validator +from jsonschema.protocols import Validator +from referencing import Registry, Resource + +from AKPlanning import settings + + +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. + """ + schema_base_path = Path(settings.BASE_DIR) / "schemas" + resources = [] + for schema_path in schema_base_path.glob("**/*.schema.json"): + with schema_path.open("r") as ff: + res = Resource.from_contents(json.load(ff)) + resources.append((res.id(), res)) + registry = Registry().with_resources(resources) + if isinstance(schema, str): + with (schema_base_path / schema).open("r") as ff: + schema = json.load(ff) + return Draft202012Validator(schema=schema, registry=registry) diff --git a/requirements.txt b/requirements.txt index 451feef4db2ee295a38687bc0f96ff1dee637418..3183a66eee179f45bba2314a2efff72b68a7e865 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ django-libsass==0.9 django-betterforms==2.0.0 mysqlclient==2.2.0 # for production deployment tzdata==2024.1 +jsonschema==4.23.0 # Documentation sphinxcontrib-django==2.5 diff --git a/schemas/ak.schema.json b/schemas/ak.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..a7428a30ff6ce7e47c8650ec6de66e80112a2b4c --- /dev/null +++ b/schemas/ak.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "/schema/ak.schema.json", + "title": "AK", + "type": "object", + "properties": { + "id": { + "$ref": "/schema/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": "/schema/common/constraints.schema.json", + "description": "Room constraints required by this AK" + }, + "time_constraints": { + "$ref": "/schema/common/constraints.schema.json", + "description": "Time constraints required by this AK" + }, + "properties": { + "type": "object", + "properties": { + "conflicts": { + "$ref": "/schema/common/id_array.schema.json", + "description": "IDs of all AKs that are in conflict with this AK" + }, + "dependencies": { + "$ref": "/schema/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": "/schema/common/id.schema.json", + "description": "Unique identifier of the AK object in the django database" + }, + "types": { + "$ref": "/schema/common/constraints.schema.json", + "description": "Types of this AK" + } + }, + "required": ["name", "head", "description", "reso", "duration_in_hours", "django_ak_id", "types"], + "additionalProperties": false + } + }, + "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..7d3fce56e26ba2403538583c20a0d211b56c2774 --- /dev/null +++ b/schemas/common/constraints.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "/schema/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..4dd06e349a9ec29caf5cf051d9044a9e47a2924d --- /dev/null +++ b/schemas/common/id.schema.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "/schema/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..622e692ac617ed4fc5ce8261b9da135a0ab1e79d --- /dev/null +++ b/schemas/common/id_array.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "/schema/common/id_array.schema.json", + "type": "array", + "items": {"type": "integer"}, + "uniqueItems": true +} \ No newline at end of file diff --git a/schemas/participant.schema.json b/schemas/participant.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..a7eff8e8f87534d4a50b7789c82a07f6163ebb98 --- /dev/null +++ b/schemas/participant.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "/schema/participant.schema.json", + "title": "Participant", + "type": "object", + "properties": { + "id": { + "$ref": "/schema/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": "/schema/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, + "anyOf": [ + {"const": -1}, {"const": 1}, {"const": 2} + ] + } + }, + "required": ["ak_id", "required", "preference_score"], + "additionalProperties": false + }, + "uniqueItems": true + }, + "room_constraints": { + "$ref": "/schema/common/constraints.schema.json", + "description": "Room constraints required by this participant" + }, + "time_constraints": { + "$ref": "/schema/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"], + "additionalProperties": false + } + }, + "required": ["id", "room_constraints", "time_constraints", "info"], + "additionalProperties": false +} diff --git a/schemas/room.schema.json b/schemas/room.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..a4ab82df9e157cc2d368ec1813ea601c1f96cbe4 --- /dev/null +++ b/schemas/room.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "/schema/room.schema.json", + "title": "Room", + "type": "object", + "properties": { + "id": { + "$ref": "/schema/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": "/schema/common/constraints.schema.json", + "description": "Constraints fulfilled by this room" + }, + "time_constraints": { + "$ref": "/schema/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"], + "additionalProperties": false + } + }, + "required": ["id", "capacity", "fulfilled_room_constraints", "time_constraints", "info"], + "additionalProperties": 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..a55057dba34c514435a46e991b3671eb2f289d5e --- /dev/null +++ b/schemas/solver-input.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "/schema/solver-input.schema.json", + "type": "object", + "properties": { + "aks": {"type": "array", "items": {"$ref": "/schema/ak.schema.json"}, "uniqueItems": true}, + "rooms": {"type": "array", "items": {"$ref": "/schema/room.schema.json"}, "uniqueItems": true}, + "participants": {"type": "array", "items": {"$ref": "/schema/participant.schema.json"}, "uniqueItems": true}, + "timeslots": {"$ref": "/schema/timeslot.schema.json"}, + "info": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "slug": {"type": "string"}, + "place": {"type": "string"}, + "contact_email": {"type": "string"} + }, + "additionalProperties": false + } + }, + "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..2a15247f85c1b13a8b08a48e4fe5cb59ea30bfc9 --- /dev/null +++ b/schemas/solver-output.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "/schema/solver-output.schema.json", + "type": "object", + "additionalProperties": false, + "required": ["input", "scheduled_aks"], + "properties": { + "input": {"$ref": "/schema/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.schema.json b/schemas/timeslot.schema.json new file mode 100644 index 0000000000000000000000000000000000000000..0240c4969741e5d5934938c7000566ef64889f13 --- /dev/null +++ b/schemas/timeslot.schema.json @@ -0,0 +1,61 @@ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "/schema/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"], + "additionalProperties": false + }, + "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": "/schema/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"], + "additionalProperties": false + }, + "fulfilled_time_constraints": { + "$ref": "/schema/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