Skip to content
Snippets Groups Projects
Commit 295a3d8b authored by Lorenzo Conti's avatar Lorenzo Conti
Browse files

Merge branch 'feature/schema' into 'main'

Feature: Use JSON schema

See merge request !25
parents 631b8c4f e5db582c
No related branches found
No related tags found
1 merge request!25Feature: Use JSON schema
Pipeline #275771 passed
Showing
with 449 additions and 190 deletions
import json import json
import math import math
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable from collections.abc import Iterable
from datetime import datetime, timedelta from datetime import datetime, timedelta
...@@ -10,17 +9,11 @@ from bs4 import BeautifulSoup ...@@ -10,17 +9,11 @@ from bs4 import BeautifulSoup
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from jsonschema.exceptions import best_match
from AKModel.availability.models import Availability from AKModel.availability.models import Availability
from AKModel.models import ( from AKModel.models import AK, AKCategory, AKOwner, AKSlot, DefaultSlot, Event, Room
Event, from AKModel.utils import construct_schema_validator
AKOwner,
AKCategory,
AK,
Room,
AKSlot,
DefaultSlot,
)
class JSONExportTest(TestCase): class JSONExportTest(TestCase):
...@@ -44,6 +37,10 @@ class JSONExportTest(TestCase): ...@@ -44,6 +37,10 @@ class JSONExportTest(TestCase):
is_active=True, is_active=True,
) )
cls.json_export_validator = construct_schema_validator(
"solver-input-export.schema.json"
)
def setUp(self): def setUp(self):
self.client.force_login(self.admin_user) self.client.force_login(self.admin_user)
self.export_dict = {} self.export_dict = {}
...@@ -119,204 +116,43 @@ class JSONExportTest(TestCase): ...@@ -119,204 +116,43 @@ class JSONExportTest(TestCase):
if contained_type in {str, int}: if contained_type in {str, int}:
self._check_uniqueness(lst, name, key=None) self._check_uniqueness(lst, name, key=None)
def test_ak_conformity_to_spec(self): def test_conformity_to_schema(self):
"""Test if AK JSON structure and types conform to standard.""" """Test if JSON structure and types conform to schema."""
for event in Event.objects.all(): for event in Event.objects.all():
with self.subTest(event=event): with self.subTest(event=event):
self.set_up_event(event=event) self.set_up_event(event=event)
self._check_uniqueness(self.export_dict["aks"], "AK") error = best_match(
for ak in self.export_dict["aks"]: self.json_export_validator.iter_errors(self.export_dict)
item = f"AK {ak['id']}" )
self.assertEqual( msg = "" if not error else f"{error.message} at {error.json_path}"
ak.keys(), self.assertFalse(error, msg)
{
"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
)
def test_room_conformity_to_spec(self): def test_id_uniqueness(self):
"""Test if Room JSON structure and types conform to standard.""" """Test if objects are only exported once."""
for event in Event.objects.all(): for event in Event.objects.all():
with self.subTest(event=event): with self.subTest(event=event):
self.set_up_event(event=event) self.set_up_event(event=event)
self._check_uniqueness(self.export_dict["rooms"], "Room") self._check_uniqueness(self.export_dict["aks"], "AKs")
for room in self.export_dict["rooms"]: self._check_uniqueness(self.export_dict["rooms"], "Rooms")
item = f"Room {room['id']}" self._check_uniqueness(self.export_dict["participants"], "Participants")
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._check_uniqueness(
chain.from_iterable(self.export_dict["timeslots"]["blocks"]), chain.from_iterable(self.export_dict["timeslots"]["blocks"]),
"Timeslots", "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( def test_timeslot_ids_consecutive(self):
self.export_dict["timeslots"]["blocks"], """Test if Timeslots ids are chronologically consecutive."""
"blocks", for event in Event.objects.all():
item=item, with self.subTest(event=event):
contained_type=list, self.set_up_event(event=event)
)
prev_id = None prev_id = None
for timeslot in chain.from_iterable( for timeslot in chain.from_iterable(
self.export_dict["timeslots"]["blocks"] 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: if prev_id is not None:
self.assertLess( self.assertLess(
prev_id, prev_id,
...@@ -351,8 +187,6 @@ class JSONExportTest(TestCase): ...@@ -351,8 +187,6 @@ class JSONExportTest(TestCase):
getattr(self.event, attr_field), self.export_dict["info"][attr] getattr(self.event, attr_field), self.export_dict["info"][attr]
) )
self._check_uniqueness(self.export_dict["participants"], "Participants")
def test_ak_durations(self): def test_ak_durations(self):
"""Test if all AK durations are correct.""" """Test if all AK durations are correct."""
for event in Event.objects.all(): for event in Event.objects.all():
......
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)
...@@ -17,6 +17,7 @@ djangorestframework==3.15.2 ...@@ -17,6 +17,7 @@ djangorestframework==3.15.2
fontawesomefree==6.6.0 # Makes static files (css, fonts) available locally fontawesomefree==6.6.0 # Makes static files (css, fonts) available locally
mysqlclient==2.2.7 # for production deployment mysqlclient==2.2.7 # for production deployment
tzdata==2025.1 tzdata==2025.1
jsonschema==4.23.0
# Documentation # Documentation
Sphinx==8.2.3 Sphinx==8.2.3
......
{
"$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
}
}
}
{
"$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
}
{
"$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
{
"$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
{
"$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
{
"$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
}
}
}
{
"$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
}
{
"$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
}
}
}
{
"$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
{
"$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
{
"$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
}
{
"$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
}
}
}
{
"$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
}
}
}
}
}
}
{
"$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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment