Skip to content
Snippets Groups Projects
Commit 5f53d1ff authored by Felix Blanke's avatar Felix Blanke
Browse files

Merge branch 'feature/schema' into feature/json-import-file-upload-button

parents e584858c b27360e3
No related branches found
No related tags found
No related merge requests found
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():
......
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)
......@@ -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
......
{
"$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
}
{
"$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
{
"$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
{
"$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
{
"$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
}
{
"$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
{
"$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
}
{
"$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
}
}
}
{
"$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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment