Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨(models) add xAPI Profile model #415

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to
- Implement Pydantic model for LRS Statements resource query parameters
- Implement xAPI LMS Profile statements validation
- `EdX` to `xAPI` converters for enrollment events
- Implement xAPI JSON-LD profile validation
(CLI command: `ralph validate -f xapi.profile`)

### Changed

Expand Down
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ include_package_data = True
install_requires =
; By default, we only consider core dependencies required to use Ralph as a
; library (mostly models).
jsonpath-ng>=1.5.3, <2.0
jsonschema>=4.0.0, <5.0 # Note: v4.18.0 dropped support for python 3.7.
langcodes>=3.2.0
pydantic[dotenv,email]>=1.10.0, <2.0
rfc3987>=1.3.0
Expand Down
6 changes: 3 additions & 3 deletions src/ralph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -446,9 +446,9 @@ def extract(parser):
"-f",
"--format",
"format_",
type=click.Choice(["edx", "xapi"]),
type=click.Choice(["edx", "xapi", "xapi.profile"]),
required=True,
help="Input events format to validate",
help="Input data format to validate",
)
@click.option(
"-I",
Expand All @@ -462,7 +462,7 @@ def extract(parser):
"--fail-on-unknown",
default=False,
is_flag=True,
help="Stop validating at first unknown event",
help="Stop validating at first unknown record",
)
def validate(format_, ignore_errors, fail_on_unknown):
"""Validate input events of given format."""
Expand Down
2 changes: 1 addition & 1 deletion src/ralph/models/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def _validate_event(self, event_str: str):
event_str (str): The cleaned JSON-formatted input event_str.
"""
event = json.loads(event_str)
return self.get_first_valid_model(event).json()
return self.get_first_valid_model(event).json(by_alias=True)

@staticmethod
def _log_error(message, event_str, error=None):
Expand Down
466 changes: 466 additions & 0 deletions src/ralph/models/xapi/profile.py

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion tests/fixtures/hypothesis_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from hypothesis import given
from hypothesis import strategies as st
from pydantic import BaseModel
from pydantic import AnyUrl, BaseModel

from ralph.models.edx.navigational.fields.events import NavigationalEventField
from ralph.models.edx.navigational.statements import UISeqNext, UISeqPrev
Expand All @@ -15,6 +15,7 @@
LMSContextContextActivities,
LMSProfileActivity,
)
from ralph.models.xapi.profile import ProfilePattern, ProfileTemplateRule
from ralph.models.xapi.video.contexts import (
VideoContextContextActivities,
VideoProfileActivity,
Expand Down Expand Up @@ -120,6 +121,18 @@ def custom_given(*args: Union[st.SearchStrategy, BaseModel], **kwargs):
"max": False,
},
LMSContextContextActivities: {"category": custom_builds(LMSProfileActivity)},
ProfilePattern: {
"primary": False,
"alternates": False,
"optional": st.from_type(AnyUrl),
"oneOrMore": False,
"sequence": False,
"zeroOrMore": False,
},
ProfileTemplateRule: {
"location": st.just("$.timestamp"),
"selector": False,
},
VideoContextContextActivities: {"category": custom_builds(VideoProfileActivity)},
VirtualClassroomContextContextActivities: {
"category": custom_builds(VirtualClassroomProfileActivity)
Expand Down
184 changes: 184 additions & 0 deletions tests/models/xapi/test_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Tests for the xAPI JSON-LD Profile."""
import json

import pytest
from pydantic import ValidationError

from ralph.models.selector import ModelSelector
from ralph.models.xapi.profile import Profile, ProfilePattern, ProfileTemplateRule

from tests.fixtures.hypothesis_strategies import custom_given


@custom_given(Profile)
def test_models_xapi_profile_with_json_ld_keywords(profile):
"""Test a `Profile` MAY include JSON-LD keywords."""
profile = json.loads(profile.json(by_alias=True))
profile["@base"] = None
try:
Profile(**profile)
except ValidationError as err:
pytest.fail(
f"A profile including JSON-LD keywords should not raise exceptions: {err}"
)


@pytest.mark.parametrize(
"missing", [("prefLabel",), ("definition",), ("prefLabel", "definition")]
)
@custom_given(ProfilePattern)
def test_models_xapi_profile_pattern_with_invalid_primary_value(missing, pattern):
"""Test a `ProfilePattern` MUST include `prefLabel` and `definition` fields."""
pattern = json.loads(pattern.json(by_alias=True))
pattern["primary"] = True
for field in missing:
del pattern[field]

msg = "A `primary` pattern MUST include `prefLabel` and `definition` fields"
with pytest.raises(ValidationError, match=msg):
ProfilePattern(**pattern)


@pytest.mark.parametrize(
"rules",
[
(),
("alternates", "optional"),
("oneOrMore", "sequence"),
("zeroOrMore", "alternates"),
],
)
@custom_given(ProfilePattern)
def test_models_xapi_profile_pattern_with_invalid_number_of_match_rules(rules, pattern):
"""Test a `ProfilePattern` MUST contain exactly one of `alternates`, `optional`,
`oneOrMore`, `sequence`, and `zeroOrMore`.
"""
rule_values = {
"alternates": ["https://example.com", "https://example.fr"],
"optional": "https://example.com",
"oneOrMore": "https://example.com",
"sequence": ["https://example.com", "https://example.fr"],
"zeroOrMore": "https://example.com",
}
pattern = json.loads(pattern.json(by_alias=True))
del pattern["optional"]
for rule in rules:
pattern[rule] = rule_values[rule]

msg = (
"A pattern MUST contain exactly one of `alternates`, `optional`, "
"`oneOrMore`, `sequence`, and `zeroOrMore` fields"
)
with pytest.raises(ValidationError, match=msg):
ProfilePattern(**pattern)


@custom_given(Profile)
def test_models_xapi_profile_selector_with_valid_model(profile):
"""Test given a valid profile, the `get_first_model` method of the model
selector should return the corresponding model.
"""
profile = json.loads(profile.json())
model_selector = ModelSelector(module="ralph.models.xapi.profile")
assert model_selector.get_first_model(profile) is Profile


@pytest.mark.parametrize("field", ["location", "selector"])
@custom_given(ProfileTemplateRule)
def test_models_xapi_profile_template_rules_with_invalid_json_path(field, rule):
"""Test given a profile template rule with a `location` or `selector` containing an
invalid JSONPath, the `ProfileTemplateRule` model should raise an exception.
"""
rule = json.loads(rule.json())
rule[field] = ""
msg = "Invalid JSONPath: empty string is not a valid path"
with pytest.raises(ValidationError, match=msg):
ProfileTemplateRule(**rule)

rule[field] = "not a JSONPath"
msg = (
f"1 validation error for ProfileTemplateRule\n{field}\n Invalid JSONPath: "
r"Parse error at 1:4 near token a \(ID\) \(type=value_error\)"
)
with pytest.raises(ValidationError, match=msg):
ProfileTemplateRule(**rule)


@pytest.mark.parametrize("field", ["location", "selector"])
@custom_given(ProfileTemplateRule)
def test_models_xapi_profile_template_rules_with_valid_json_path(field, rule):
"""Test given a profile template rule with a `location` or `selector` containing an
valid JSONPath, the `ProfileTemplateRule` model should not raise exceptions.
"""
rule = json.loads(rule.json())
rule[field] = "$.context.extensions['http://example.com/extension']"
try:
ProfileTemplateRule(**rule)
except ValidationError as err:
pytest.fail(
"A `ProfileTemplateRule` with a valid JSONPath should not raise exceptions:"
f" {err}"
)


@custom_given(Profile)
def test_models_xapi_profile_with_valid_json_schema(profile):
"""Test given a profile with an extension concept containing a valid JSONSchema,
should not raise exceptions.
"""
profile = json.loads(profile.json(by_alias=True))
profile["concepts"] = [
{
"id": "http://example.com",
"type": "ContextExtension",
"inScheme": "http://example.profile.com",
"prefLabel": {
"en-us": "Example context extension",
},
"definition": {
"en-us": "To use when an example happens",
},
"inlineSchema": json.dumps(
{
"$id": "https://example.com/example.schema.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Example",
"type": "object",
"properties": {
"example": {"type": "string", "description": "The example."},
},
}
),
}
]
try:
Profile(**profile)
except ValidationError as err:
pytest.fail(
f"A profile including a valid JSONSchema should not raise exceptions: {err}"
)


@custom_given(Profile)
def test_models_xapi_profile_with_invalid_json_schema(profile):
"""Test given a profile with an extension concept containing an invalid JSONSchema,
should raise an exception.
"""
profile = json.loads(profile.json(by_alias=True))
profile["concepts"] = [
{
"id": "http://example.com",
"type": "ContextExtension",
"inScheme": "http://example.profile.com",
"prefLabel": {
"en-us": "Example context extension",
},
"definition": {
"en-us": "To use when an example happens",
},
"inlineSchema": json.dumps({"type": "example"}),
}
]
msg = "Invalid JSONSchema: 'example' is not valid under any of the given schemas"
with pytest.raises(ValidationError, match=msg):
Profile(**profile)
11 changes: 11 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from ralph.exceptions import ConfigurationException
from ralph.models.edx.navigational.statements import UIPageClose
from ralph.models.xapi.navigation.statements import PageTerminated
from ralph.models.xapi.profile import Profile

from tests.fixtures.backends import (
ES_TEST_HOSTS,
Expand Down Expand Up @@ -482,6 +483,16 @@ def test_cli_validate_command_with_edx_format(event):
assert event_str in result.output


@custom_given(Profile)
def test_cli_validate_command_with_xapi_profile_format(event):
"""Test the validate command using the xAPI profile format."""

event_str = event.json(by_alias=True)
runner = CliRunner()
result = runner.invoke(cli, "validate -f xapi.profile".split(), input=event_str)
assert event_str in result.output


@hypothesis_settings(deadline=None)
@custom_given(UIPageClose)
@pytest.mark.parametrize("valid_uuid", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"])
Expand Down
13 changes: 9 additions & 4 deletions tests/test_cli_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,20 @@ def test_cli_validate_command_usage():
assert result.exit_code == 0
assert (
"Options:\n"
" -f, --format [edx|xapi] Input events format to validate [required]\n"
" -I, --ignore-errors Continue validating regardless of raised errors\n"
" -F, --fail-on-unknown Stop validating at first unknown event\n"
" -f, --format [edx|xapi|xapi.profile]\n"
" Input data format to validate [required]\n"
" -I, --ignore-errors Continue validating regardless of raised\n"
" errors\n"
" -F, --fail-on-unknown Stop validating at first unknown record\n"
) in result.output

result = runner.invoke(cli, ["validate"])
assert result.exit_code > 0
assert (
"Error: Missing option '-f' / '--format'. Choose from:\n\tedx,\n\txapi\n"
"Error: Missing option '-f' / '--format'. Choose from:\n"
"\tedx,\n"
"\txapi,\n"
"\txapi.profile\n"
) in result.output


Expand Down