diff --git a/.gitignore b/.gitignore index 0e78518..05f4cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,6 @@ tests/testcontent/ # Pycharm project settings .idea/ *.iml + +# pytest +.pytest_cache/ diff --git a/js/EmbedRequest.js b/js/EmbedRequest.js new file mode 100644 index 0000000..7d26785 --- /dev/null +++ b/js/EmbedRequest.js @@ -0,0 +1,112 @@ +// -*- coding: utf-8 -*- +// Generated by scripts/generate_from_specs.py +// EmbedRequest + + +export const SCHEMA = { + "$id": "/schemas/embed_request", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "Schema for embed requests received by RayServe", + "additionalProperties": false, + "definitions": { + "ancestors": { + "type": "array", + "description": "The ancestors of the topic, in order, from the parent to the root", + "items": { + "$ref": "#/definitions/topic" + } + }, + "language": { + "type": "string", + "description": "Language code from https://github.com/learningequality/le-utils/blob/main/le_utils/resources/languagelookup.json", + "pattern": "^[a-z]{2,3}(?:-[a-zA-Z]+)?$" + }, + "topic": { + "type": "object", + "description": "A topic in the tree structure", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The ID of the topic content node on Studio" + }, + "title": { + "type": "string", + "description": "The title of the topic" + }, + "description": { + "type": "string", + "description": "The description of the topic" + }, + "language": { + "$ref": "#/definitions/language" + }, + "ancestors": { + "$ref": "#/definitions/ancestors" + } + }, + "required": [ + "id", + "title", + "description" + ] + }, + "resource": { + "type": "object", + "description": "The key textual metadata and data for a content resource", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The ID of the content resource" + }, + "title": { + "type": "string", + "description": "The title of the content resource" + }, + "description": { + "type": "string", + "description": "The description of the content resource" + }, + "text": { + "type": "string", + "description": "The cleaned up text extracted from the content resource (in markdown or plaintext format)" + }, + "language": { + "$ref": "#/definitions/language" + } + }, + "required": [ + "id", + "title", + "description", + "text" + ] + } + }, + "properties": { + "topics": { + "type": "array", + "description": "A list of topics to embed", + "items": { + "$ref": "#/definitions/topic" + } + }, + "resources": { + "type": "array", + "description": "A list of content resources to embed", + "items": { + "$ref": "#/definitions/resource" + } + }, + "metadata": { + "type": "object", + "description": "The metadata of the channel for logging purposes" + } + }, + "required": [ + "topics", + "resources" + ] +}; diff --git a/le_utils/constants/embed_request.py b/le_utils/constants/embed_request.py new file mode 100644 index 0000000..851e4d6 --- /dev/null +++ b/le_utils/constants/embed_request.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +# Generated by scripts/generate_from_specs.py +from __future__ import unicode_literals + +# EmbedRequest + + +choices = () + +EMBEDREQUESTLIST = [] + +SCHEMA = { + "$id": "/schemas/embed_request", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "Schema for embed requests received by RayServe", + "additionalProperties": False, + "definitions": { + "ancestors": { + "type": "array", + "description": "The ancestors of the topic, in order, from the parent to the root", + "items": {"$ref": "#/definitions/topic"}, + }, + "language": { + "type": "string", + "description": "Language code from https://github.com/learningequality/le-utils/blob/main/le_utils/resources/languagelookup.json", + "pattern": "^[a-z]{2,3}(?:-[a-zA-Z]+)?$", + }, + "topic": { + "type": "object", + "description": "A topic in the tree structure", + "additionalProperties": False, + "properties": { + "id": { + "type": "string", + "description": "The ID of the topic content node on Studio", + }, + "title": {"type": "string", "description": "The title of the topic"}, + "description": { + "type": "string", + "description": "The description of the topic", + }, + "language": {"$ref": "#/definitions/language"}, + "ancestors": {"$ref": "#/definitions/ancestors"}, + }, + "required": ["id", "title", "description"], + }, + "resource": { + "type": "object", + "description": "The key textual metadata and data for a content resource", + "additionalProperties": False, + "properties": { + "id": { + "type": "string", + "description": "The ID of the content resource", + }, + "title": { + "type": "string", + "description": "The title of the content resource", + }, + "description": { + "type": "string", + "description": "The description of the content resource", + }, + "text": { + "type": "string", + "description": "The cleaned up text extracted from the content resource (in markdown or plaintext format)", + }, + "language": {"$ref": "#/definitions/language"}, + }, + "required": ["id", "title", "description", "text"], + }, + }, + "properties": { + "topics": { + "type": "array", + "description": "A list of topics to embed", + "items": {"$ref": "#/definitions/topic"}, + }, + "resources": { + "type": "array", + "description": "A list of content resources to embed", + "items": {"$ref": "#/definitions/resource"}, + }, + "metadata": { + "type": "object", + "description": "The metadata of the channel for logging purposes", + }, + }, + "required": ["topics", "resources"], +} diff --git a/le_utils/validators/__init__.py b/le_utils/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/le_utils/validators/embed_request.py b/le_utils/validators/embed_request.py new file mode 100644 index 0000000..d7584d2 --- /dev/null +++ b/le_utils/validators/embed_request.py @@ -0,0 +1,11 @@ +import jsonschema + +from le_utils.constants.embed_request import SCHEMA + + +def validate(data): + """ + :param data: Dictionary of data to validate + :raises: jsonschema.ValidationError: When invalid + """ + jsonschema.validate(instance=data, schema=SCHEMA) diff --git a/scripts/generate_from_specs.py b/scripts/generate_from_specs.py index 8032f58..cbcadcf 100644 --- a/scripts/generate_from_specs.py +++ b/scripts/generate_from_specs.py @@ -229,10 +229,11 @@ def write_js_file(output_file, name, ordered_output, schema=None): write_js_header(f) f.write("// {}\n".format(name)) f.write("\n") - f.write("export default {\n") - for key, value in ordered_output.items(): - f.write(' {key}: "{value}",\n'.format(key=key, value=value)) - f.write("};\n") + if ordered_output: + f.write("export default {\n") + for key, value in ordered_output.items(): + f.write(' {key}: "{value}",\n'.format(key=key, value=value)) + f.write("};\n") if schema: f.write("\n") f.write("export const SCHEMA = {};\n".format(schema)) @@ -255,16 +256,20 @@ def write_labels_src_files(label_outputs): def write_constants_src_files(constants_outputs, schemas): output_files = [] - for constant_type, ordered_output in constants_outputs.items(): + keys = set(list(constants_outputs.keys()) + list(schemas.keys())) + + for key in keys: + constant_outputs = constants_outputs.get(key, {}) + schema = schemas.get(key, None) + py_output_file = os.path.join( - py_output_dir, "{}.py".format(pascal_to_snake(constant_type)) + py_output_dir, "{}.py".format(pascal_to_snake(key)) ) - schema = schemas.get(constant_type) - write_python_file(py_output_file, constant_type, ordered_output, schema=schema) + write_python_file(py_output_file, key, constant_outputs, schema=schema) output_files.append(py_output_file) - js_output_file = os.path.join(js_output_dir, "{}.js".format(constant_type)) - write_js_file(js_output_file, constant_type, ordered_output, schema=schema) + js_output_file = os.path.join(js_output_dir, "{}.js".format(key)) + write_js_file(js_output_file, key, constant_outputs, schema=schema) output_files.append(js_output_file) return output_files diff --git a/spec/schema-embed_request.json b/spec/schema-embed_request.json new file mode 100644 index 0000000..983f8d7 --- /dev/null +++ b/spec/schema-embed_request.json @@ -0,0 +1,107 @@ +{ + "$id": "/schemas/embed_request", + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "Schema for embed requests received by RayServe", + "additionalProperties": false, + "definitions": { + "ancestors": { + "type": "array", + "description": "The ancestors of the topic, in order, from the parent to the root", + "items": { + "$ref": "#/definitions/topic" + } + }, + "language": { + "type": "string", + "description": "Language code from https://github.com/learningequality/le-utils/blob/main/le_utils/resources/languagelookup.json", + "pattern": "^[a-z]{2,3}(?:-[a-zA-Z]+)?$" + }, + "topic": { + "type": "object", + "description": "A topic in the tree structure", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The ID of the topic content node on Studio" + }, + "title": { + "type": "string", + "description": "The title of the topic" + }, + "description": { + "type": "string", + "description": "The description of the topic" + }, + "language": { + "$ref": "#/definitions/language" + }, + "ancestors": { + "$ref": "#/definitions/ancestors" + } + }, + "required": [ + "id", + "title", + "description" + ] + }, + "resource": { + "type": "object", + "description": "The key textual metadata and data for a content resource", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The ID of the content resource" + }, + "title": { + "type": "string", + "description": "The title of the content resource" + }, + "description": { + "type": "string", + "description": "The description of the content resource" + }, + "text": { + "type": "string", + "description": "The cleaned up text extracted from the content resource (in markdown or plaintext format)" + }, + "language": { + "$ref": "#/definitions/language" + } + }, + "required": [ + "id", + "title", + "description", + "text" + ] + } + }, + "properties": { + "topics": { + "type": "array", + "description": "A list of topics to embed", + "items": { + "$ref": "#/definitions/topic" + } + }, + "resources": { + "type": "array", + "description": "A list of content resources to embed", + "items": { + "$ref": "#/definitions/resource" + } + }, + "metadata": { + "type": "object", + "description": "The metadata of the channel for logging purposes" + } + }, + "required": [ + "topics", + "resources" + ] +} \ No newline at end of file diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 4553173..c8fb573 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -7,6 +7,9 @@ from le_utils.constants import completion_criteria from le_utils.constants import mastery_criteria +from le_utils.validators.embed_request import ( + validate as validate_embed_request, +) try: @@ -26,7 +29,7 @@ ) -def _validate(data): +def _validate_completion_criteria(data): """ :param data: Dictionary of data to validate :raises: jsonschema.ValidationError: When invalid @@ -51,8 +54,10 @@ def _assert_not_raises(not_expected): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__time_model__valid(): with _assert_not_raises(jsonschema.ValidationError): - _validate({"model": "time", "threshold": 2, "learner_managed": False}) - _validate( + _validate_completion_criteria( + {"model": "time", "threshold": 2, "learner_managed": False} + ) + _validate_completion_criteria( { "model": "time", "threshold": 1200123, @@ -63,7 +68,7 @@ def test_completion_criteria__time_model__valid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__time_model__invalid(): with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "time", "threshold": {"mastery_model": "not_real"}, @@ -71,7 +76,7 @@ def test_completion_criteria__time_model__invalid(): } ) with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "time", "threshold": -1, @@ -82,8 +87,10 @@ def test_completion_criteria__time_model__invalid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__approx_time_model__valid(): with _assert_not_raises(jsonschema.ValidationError): - _validate({"model": "approx_time", "threshold": 2, "learner_managed": False}) - _validate( + _validate_completion_criteria( + {"model": "approx_time", "threshold": 2, "learner_managed": False} + ) + _validate_completion_criteria( { "model": "approx_time", "threshold": 1200123, @@ -94,7 +101,7 @@ def test_completion_criteria__approx_time_model__valid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__approx_time_model__invalid(): with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "approx_time", "threshold": {"mastery_model": "not_real"}, @@ -102,7 +109,7 @@ def test_completion_criteria__approx_time_model__invalid(): } ) with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "approx_time", "threshold": -1, @@ -113,8 +120,10 @@ def test_completion_criteria__approx_time_model__invalid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__pages_model__valid(): with _assert_not_raises(jsonschema.ValidationError): - _validate({"model": "pages", "threshold": 2, "learner_managed": False}) - _validate( + _validate_completion_criteria( + {"model": "pages", "threshold": 2, "learner_managed": False} + ) + _validate_completion_criteria( { "model": "pages", "threshold": 1200123, @@ -125,13 +134,13 @@ def test_completion_criteria__pages_model__valid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__pages_model__percentage__valid(): with _assert_not_raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "pages", "threshold": "99%", } ) - _validate( + _validate_completion_criteria( { "model": "pages", "threshold": "1%", @@ -142,7 +151,7 @@ def test_completion_criteria__pages_model__percentage__valid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__pages_model__invalid(): with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "pages", "threshold": {"mastery_model": "not_real"}, @@ -150,7 +159,7 @@ def test_completion_criteria__pages_model__invalid(): } ) with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "pages", "threshold": -1, @@ -161,7 +170,7 @@ def test_completion_criteria__pages_model__invalid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__pages_model__percentage__invalid(): with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "pages", "threshold": "0%", @@ -169,7 +178,7 @@ def test_completion_criteria__pages_model__percentage__invalid(): } ) with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "pages", "threshold": "101%", @@ -180,14 +189,14 @@ def test_completion_criteria__pages_model__percentage__invalid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__mastery_model__valid(): with _assert_not_raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "mastery", "threshold": {"mastery_model": "do_all"}, "learner_managed": False, } ) - _validate( + _validate_completion_criteria( { "model": "mastery", "threshold": {"mastery_model": "m_of_n", "m": 1, "n": 2}, @@ -198,7 +207,7 @@ def test_completion_criteria__mastery_model__valid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__mastery_model__invalid(): with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "mastery", "threshold": {"mastery_model": "m_of_n"}, @@ -206,7 +215,7 @@ def test_completion_criteria__mastery_model__invalid(): } ) with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "mastery", "threshold": {"mastery_model": "do_all", "m": 1}, @@ -214,7 +223,7 @@ def test_completion_criteria__mastery_model__invalid(): } ) with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "mastery", "threshold": {"mastery_model": "not_real"}, @@ -222,7 +231,7 @@ def test_completion_criteria__mastery_model__invalid(): } ) with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "mastery", "threshold": -1, @@ -233,8 +242,8 @@ def test_completion_criteria__mastery_model__invalid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__reference__valid(): with _assert_not_raises(jsonschema.ValidationError): - _validate({"model": "reference", "learner_managed": False}) - _validate( + _validate_completion_criteria({"model": "reference", "learner_managed": False}) + _validate_completion_criteria( { "model": "reference", } @@ -244,7 +253,7 @@ def test_completion_criteria__reference__valid(): @pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") def test_completion_criteria__reference__invalid(): with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "reference", "threshold": 1, @@ -252,10 +261,41 @@ def test_completion_criteria__reference__invalid(): } ) with pytest.raises(jsonschema.ValidationError): - _validate( + _validate_completion_criteria( { "model": "reference", "threshold": {"mastery_model": "do_all"}, "learner_managed": False, } ) + + +@pytest.mark.skipif(jsonschema is None, reason="jsonschema package is unavailable") +def test_recommendations__topic__valid(): + with _assert_not_raises(jsonschema.ValidationError): + validate_embed_request( + { + "topics": [ + { + "id": "456", + "title": "Target topic", + "description": "Target description", + "language": "en", + } + ], + "resources": [ + { + "id": "123", + "title": "Resource title", + "description": "Resource description", + "text": "Resource text", + "language": "en", + }, + ], + "metadata": { + "channel_id": "000", + "channel_title": "Channel title", + "some_additional_field": "some_random_value", + }, + } + )