Skip to content

Commit 0c43770

Browse files
committed
✨(models) add xAPI Profile model
We want to support xapi profile validation in Ralph. Therefore we implement the xAPI Profile model which should follow the xAPI profiles structures specification.
1 parent 13440b3 commit 0c43770

File tree

9 files changed

+692
-9
lines changed

9 files changed

+692
-9
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to
1313
- Implement Pydantic model for LRS Statements resource query parameters
1414
- Implement xAPI LMS Profile statements validation
1515
- `EdX` to `xAPI` converters for enrollment events
16+
- Implement xAPI JSON-LD profile validation
17+
(CLI command: `ralph validate -f xapi.profile`)
1618

1719
### Changed
1820

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ include_package_data = True
2929
install_requires =
3030
; By default, we only consider core dependencies required to use Ralph as a
3131
; library (mostly models).
32+
jsonpath-ng>=1.5.3, <2.0
33+
jsonschema>=4.0.0, <5.0 # Note: v4.18.0 dropped support for python 3.7.
3234
langcodes>=3.2.0
3335
pydantic[dotenv,email]>=1.10.0, <2.0
3436
rfc3987>=1.3.0

src/ralph/cli.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -446,9 +446,9 @@ def extract(parser):
446446
"-f",
447447
"--format",
448448
"format_",
449-
type=click.Choice(["edx", "xapi"]),
449+
type=click.Choice(["edx", "xapi", "xapi.profile"]),
450450
required=True,
451-
help="Input events format to validate",
451+
help="Input data format to validate",
452452
)
453453
@click.option(
454454
"-I",
@@ -462,7 +462,7 @@ def extract(parser):
462462
"--fail-on-unknown",
463463
default=False,
464464
is_flag=True,
465-
help="Stop validating at first unknown event",
465+
help="Stop validating at first unknown record",
466466
)
467467
def validate(format_, ignore_errors, fail_on_unknown):
468468
"""Validate input events of given format."""

src/ralph/models/validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def _validate_event(self, event_str: str):
7474
event_str (str): The cleaned JSON-formatted input event_str.
7575
"""
7676
event = json.loads(event_str)
77-
return self.get_first_valid_model(event).json()
77+
return self.get_first_valid_model(event).json(by_alias=True)
7878

7979
@staticmethod
8080
def _log_error(message, event_str, error=None):

src/ralph/models/xapi/profile.py

Lines changed: 466 additions & 0 deletions
Large diffs are not rendered by default.

tests/fixtures/hypothesis_strategies.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from hypothesis import given
77
from hypothesis import strategies as st
8-
from pydantic import BaseModel
8+
from pydantic import AnyUrl, BaseModel
99

1010
from ralph.models.edx.navigational.fields.events import NavigationalEventField
1111
from ralph.models.edx.navigational.statements import UISeqNext, UISeqPrev
@@ -15,6 +15,7 @@
1515
LMSContextContextActivities,
1616
LMSProfileActivity,
1717
)
18+
from ralph.models.xapi.profile import ProfilePattern, ProfileTemplateRule
1819
from ralph.models.xapi.video.contexts import (
1920
VideoContextContextActivities,
2021
VideoProfileActivity,
@@ -120,6 +121,18 @@ def custom_given(*args: Union[st.SearchStrategy, BaseModel], **kwargs):
120121
"max": False,
121122
},
122123
LMSContextContextActivities: {"category": custom_builds(LMSProfileActivity)},
124+
ProfilePattern: {
125+
"primary": False,
126+
"alternates": False,
127+
"optional": st.from_type(AnyUrl),
128+
"oneOrMore": False,
129+
"sequence": False,
130+
"zeroOrMore": False,
131+
},
132+
ProfileTemplateRule: {
133+
"location": st.just("$.timestamp"),
134+
"selector": False,
135+
},
123136
VideoContextContextActivities: {"category": custom_builds(VideoProfileActivity)},
124137
VirtualClassroomContextContextActivities: {
125138
"category": custom_builds(VirtualClassroomProfileActivity)

tests/models/xapi/test_profile.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""Tests for the xAPI JSON-LD Profile."""
2+
import json
3+
4+
import pytest
5+
from pydantic import ValidationError
6+
7+
from ralph.models.selector import ModelSelector
8+
from ralph.models.xapi.profile import Profile, ProfilePattern, ProfileTemplateRule
9+
10+
from tests.fixtures.hypothesis_strategies import custom_given
11+
12+
13+
@custom_given(Profile)
14+
def test_models_xapi_profile_with_json_ld_keywords(profile):
15+
"""Test a `Profile` MAY include JSON-LD keywords."""
16+
profile = json.loads(profile.json(by_alias=True))
17+
profile["@base"] = None
18+
try:
19+
Profile(**profile)
20+
except ValidationError as err:
21+
pytest.fail(
22+
f"A profile including JSON-LD keywords should not raise exceptions: {err}"
23+
)
24+
25+
26+
@pytest.mark.parametrize(
27+
"missing", [("prefLabel",), ("definition",), ("prefLabel", "definition")]
28+
)
29+
@custom_given(ProfilePattern)
30+
def test_models_xapi_profile_pattern_with_invalid_primary_value(missing, pattern):
31+
"""Test a `ProfilePattern` MUST include `prefLabel` and `definition` fields."""
32+
pattern = json.loads(pattern.json(by_alias=True))
33+
pattern["primary"] = True
34+
for field in missing:
35+
del pattern[field]
36+
37+
msg = "A `primary` pattern MUST include `prefLabel` and `definition` fields"
38+
with pytest.raises(ValidationError, match=msg):
39+
ProfilePattern(**pattern)
40+
41+
42+
@pytest.mark.parametrize(
43+
"rules",
44+
[
45+
(),
46+
("alternates", "optional"),
47+
("oneOrMore", "sequence"),
48+
("zeroOrMore", "alternates"),
49+
],
50+
)
51+
@custom_given(ProfilePattern)
52+
def test_models_xapi_profile_pattern_with_invalid_number_of_match_rules(rules, pattern):
53+
"""Test a `ProfilePattern` MUST contain exactly one of `alternates`, `optional`,
54+
`oneOrMore`, `sequence`, and `zeroOrMore`.
55+
"""
56+
rule_values = {
57+
"alternates": ["https://example.com", "https://example.fr"],
58+
"optional": "https://example.com",
59+
"oneOrMore": "https://example.com",
60+
"sequence": ["https://example.com", "https://example.fr"],
61+
"zeroOrMore": "https://example.com",
62+
}
63+
pattern = json.loads(pattern.json(by_alias=True))
64+
del pattern["optional"]
65+
for rule in rules:
66+
pattern[rule] = rule_values[rule]
67+
68+
msg = (
69+
"A pattern MUST contain exactly one of `alternates`, `optional`, "
70+
"`oneOrMore`, `sequence`, and `zeroOrMore` fields"
71+
)
72+
with pytest.raises(ValidationError, match=msg):
73+
ProfilePattern(**pattern)
74+
75+
76+
@custom_given(Profile)
77+
def test_models_xapi_profile_selector_with_valid_model(profile):
78+
"""Test given a valid profile, the `get_first_model` method of the model
79+
selector should return the corresponding model.
80+
"""
81+
profile = json.loads(profile.json())
82+
model_selector = ModelSelector(module="ralph.models.xapi.profile")
83+
assert model_selector.get_first_model(profile) is Profile
84+
85+
86+
@pytest.mark.parametrize("field", ["location", "selector"])
87+
@custom_given(ProfileTemplateRule)
88+
def test_models_xapi_profile_template_rules_with_invalid_json_path(field, rule):
89+
"""Test given a profile template rule with a `location` or `selector` containing an
90+
invalid JSONPath, the `ProfileTemplateRule` model should raise an exception.
91+
"""
92+
rule = json.loads(rule.json())
93+
rule[field] = ""
94+
msg = "Invalid JSONPath: empty string is not a valid path"
95+
with pytest.raises(ValidationError, match=msg):
96+
ProfileTemplateRule(**rule)
97+
98+
rule[field] = "not a JSONPath"
99+
msg = (
100+
f"1 validation error for ProfileTemplateRule\n{field}\n Invalid JSONPath: "
101+
r"Parse error at 1:4 near token a \(ID\) \(type=value_error\)"
102+
)
103+
with pytest.raises(ValidationError, match=msg):
104+
ProfileTemplateRule(**rule)
105+
106+
107+
@pytest.mark.parametrize("field", ["location", "selector"])
108+
@custom_given(ProfileTemplateRule)
109+
def test_models_xapi_profile_template_rules_with_valid_json_path(field, rule):
110+
"""Test given a profile template rule with a `location` or `selector` containing an
111+
valid JSONPath, the `ProfileTemplateRule` model should not raise exceptions.
112+
"""
113+
rule = json.loads(rule.json())
114+
rule[field] = "$.context.extensions['http://example.com/extension']"
115+
try:
116+
ProfileTemplateRule(**rule)
117+
except ValidationError as err:
118+
pytest.fail(
119+
"A `ProfileTemplateRule` with a valid JSONPath should not raise exceptions:"
120+
f" {err}"
121+
)
122+
123+
124+
@custom_given(Profile)
125+
def test_models_xapi_profile_with_valid_json_schema(profile):
126+
"""Test given a profile with an extension concept containing a valid JSONSchema,
127+
should not raise exceptions.
128+
"""
129+
profile = json.loads(profile.json(by_alias=True))
130+
profile["concepts"] = [
131+
{
132+
"id": "http://example.com",
133+
"type": "ContextExtension",
134+
"inScheme": "http://example.profile.com",
135+
"prefLabel": {
136+
"en-us": "Example context extension",
137+
},
138+
"definition": {
139+
"en-us": "To use when an example happens",
140+
},
141+
"inlineSchema": json.dumps(
142+
{
143+
"$id": "https://example.com/example.schema.json",
144+
"$schema": "https://json-schema.org/draft/2020-12/schema",
145+
"title": "Example",
146+
"type": "object",
147+
"properties": {
148+
"example": {"type": "string", "description": "The example."},
149+
},
150+
}
151+
),
152+
}
153+
]
154+
try:
155+
Profile(**profile)
156+
except ValidationError as err:
157+
pytest.fail(
158+
f"A profile including a valid JSONSchema should not raise exceptions: {err}"
159+
)
160+
161+
162+
@custom_given(Profile)
163+
def test_models_xapi_profile_with_invalid_json_schema(profile):
164+
"""Test given a profile with an extension concept containing an invalid JSONSchema,
165+
should raise an exception.
166+
"""
167+
profile = json.loads(profile.json(by_alias=True))
168+
profile["concepts"] = [
169+
{
170+
"id": "http://example.com",
171+
"type": "ContextExtension",
172+
"inScheme": "http://example.profile.com",
173+
"prefLabel": {
174+
"en-us": "Example context extension",
175+
},
176+
"definition": {
177+
"en-us": "To use when an example happens",
178+
},
179+
"inlineSchema": json.dumps({"type": "example"}),
180+
}
181+
]
182+
msg = "Invalid JSONSchema: 'example' is not valid under any of the given schemas"
183+
with pytest.raises(ValidationError, match=msg):
184+
Profile(**profile)

tests/test_cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ralph.exceptions import ConfigurationException
2424
from ralph.models.edx.navigational.statements import UIPageClose
2525
from ralph.models.xapi.navigation.statements import PageTerminated
26+
from ralph.models.xapi.profile import Profile
2627

2728
from tests.fixtures.backends import (
2829
ES_TEST_HOSTS,
@@ -482,6 +483,16 @@ def test_cli_validate_command_with_edx_format(event):
482483
assert event_str in result.output
483484

484485

486+
@custom_given(Profile)
487+
def test_cli_validate_command_with_xapi_profile_format(event):
488+
"""Test the validate command using the xAPI profile format."""
489+
490+
event_str = event.json(by_alias=True)
491+
runner = CliRunner()
492+
result = runner.invoke(cli, "validate -f xapi.profile".split(), input=event_str)
493+
assert event_str in result.output
494+
495+
485496
@hypothesis_settings(deadline=None)
486497
@custom_given(UIPageClose)
487498
@pytest.mark.parametrize("valid_uuid", ["ee241f8b-174f-5bdb-bae9-c09de5fe017f"])

tests/test_cli_usage.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,20 @@ def test_cli_validate_command_usage():
6262
assert result.exit_code == 0
6363
assert (
6464
"Options:\n"
65-
" -f, --format [edx|xapi] Input events format to validate [required]\n"
66-
" -I, --ignore-errors Continue validating regardless of raised errors\n"
67-
" -F, --fail-on-unknown Stop validating at first unknown event\n"
65+
" -f, --format [edx|xapi|xapi.profile]\n"
66+
" Input data format to validate [required]\n"
67+
" -I, --ignore-errors Continue validating regardless of raised\n"
68+
" errors\n"
69+
" -F, --fail-on-unknown Stop validating at first unknown record\n"
6870
) in result.output
6971

7072
result = runner.invoke(cli, ["validate"])
7173
assert result.exit_code > 0
7274
assert (
73-
"Error: Missing option '-f' / '--format'. Choose from:\n\tedx,\n\txapi\n"
75+
"Error: Missing option '-f' / '--format'. Choose from:\n"
76+
"\tedx,\n"
77+
"\txapi,\n"
78+
"\txapi.profile\n"
7479
) in result.output
7580

7681

0 commit comments

Comments
 (0)