Skip to content

Commit c1f107f

Browse files
authored
feat: support proctoring assessment control (#366)
* feat: support assessment control claims * test: unit tests * docs: docstring update * feat: only enable ACS on start message
1 parent 3eaf8aa commit c1f107f

5 files changed

Lines changed: 99 additions & 2 deletions

File tree

lti_consumer/data.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from attrs import define, field, validators
77

8+
from lti_consumer.lti_1p3.constants import LTI_PROCTORING_ASSESSMENT_CONTROL_ACTIONS
9+
810

911
@define
1012
class Lti1p3ProctoringLaunchData:
@@ -24,9 +26,19 @@ class Lti1p3ProctoringLaunchData:
2426
assessment message to after it has completed the proctoring setup and verification. This attribute is required
2527
if the message_type attribute of the Lti1p3LaunchData instance is "LtiStartProctoring". It is optional and
2628
unused otherwise.
29+
* assessment_control_url (optional): The Platform URL that the Tool will send assessment control messages to.
30+
* assessment_control_actions (optional): A list of assessment control actions supported by the platform.
2731
"""
2832
attempt_number = field()
2933
start_assessment_url = field(default=None)
34+
assessment_control_url = field(default=None)
35+
assessment_control_actions = field(
36+
default=[],
37+
validator=[validators.deep_iterable(
38+
member_validator=validators.in_(LTI_PROCTORING_ASSESSMENT_CONTROL_ACTIONS),
39+
iterable_validator=validators.instance_of(list),
40+
)],
41+
)
3042

3143

3244
@define

lti_consumer/lti_1p3/constants.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,19 @@ class LTI_1P3_CONTEXT_TYPE(Enum): # pylint: disable=invalid-name
8585
course_template = 'http://purl.imsglobal.org/vocab/lis/v2/course#CourseTemplate'
8686

8787

88-
LTI_PROCTORING_DATA_KEYS = ['attempt_number', 'resource_link_id', 'session_data', 'start_assessment_url']
88+
LTI_PROCTORING_DATA_KEYS = [
89+
'attempt_number',
90+
'resource_link_id',
91+
'session_data',
92+
'start_assessment_url',
93+
'assessment_control_url',
94+
'assessment_control_actions'
95+
]
96+
97+
LTI_PROCTORING_ASSESSMENT_CONTROL_ACTIONS = [
98+
'pauseRequest',
99+
'resumeRequest',
100+
'terminateRequest',
101+
'update',
102+
'flagRequest',
103+
]

lti_consumer/lti_1p3/consumer.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,17 @@ def get_start_proctoring_claims(self):
822822

823823
return proctoring_claims
824824

825+
def get_assessment_control_claim(self):
826+
"""
827+
Returns LTI Proctoring Services ACS Claim to be injected in the LTI launch message.
828+
"""
829+
return {
830+
"https://purl.imsglobal.org/spec/lti-ap/claim/acs": {
831+
"assessment_control_url": self.proctoring_data.get("assessment_control_url"),
832+
"actions": self.proctoring_data.get("assessment_control_actions"),
833+
}
834+
}
835+
825836
def get_end_assessment_claims(self):
826837
"""
827838
Returns claims specific to LTI Proctoring Services LtiEndAssessment LTI launch message,
@@ -849,6 +860,12 @@ def generate_launch_request(
849860

850861
if launch_data.message_type == "LtiStartProctoring":
851862
proctoring_claims = self.get_start_proctoring_claims()
863+
864+
# Enable ACS if assessment_control_url is present on the launch data.
865+
# Normally we would enable this using a field on the LtiConfiguration model like other
866+
# Advantage services, but this isn't actually optional or configurable for Open edX.
867+
if self.proctoring_data.get("assessment_control_url"):
868+
self.set_extra_claim(self.get_assessment_control_claim())
852869
elif launch_data.message_type == "LtiEndAssessment":
853870
proctoring_claims = self.get_end_assessment_claims()
854871
else:

lti_consumer/lti_1p3/tests/test_consumer.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,27 @@ def test_get_end_assessment_claims(self):
10091009

10101010
self.assertEqual(expected_get_end_assessment_claims, actual_get_end_assessment_claims)
10111011

1012+
def test_assessment_control_claims(self):
1013+
"""
1014+
Ensure the correct claims are returned for the assessment control service.
1015+
"""
1016+
self.lti_consumer.set_proctoring_data(
1017+
attempt_number="attempt_number",
1018+
session_data="session_data",
1019+
start_assessment_url="start_assessment_url",
1020+
assessment_control_url="assessment_control_url",
1021+
assessment_control_actions=["flagRequest", "terminateRequest"],
1022+
)
1023+
proctoring_acs_claims = self.lti_consumer.get_assessment_control_claim()
1024+
1025+
expected_acs_claims = {
1026+
"https://purl.imsglobal.org/spec/lti-ap/claim/acs": {
1027+
"assessment_control_url": "assessment_control_url",
1028+
"actions": ["flagRequest", "terminateRequest"],
1029+
},
1030+
}
1031+
self.assertDictEqual(proctoring_acs_claims, expected_acs_claims)
1032+
10121033
@ddt.data("LtiStartProctoring", "LtiEndAssessment")
10131034
@patch('lti_consumer.lti_1p3.consumer.get_data_from_cache')
10141035
def test_generate_launch_request(self, message_type, mock_get_data_from_cache):
@@ -1045,6 +1066,36 @@ def test_generate_launch_request(self, message_type, mock_get_data_from_cache):
10451066
for claim in expected_claims.items():
10461067
self.assertIn(claim, decoded_token_claims)
10471068

1069+
@patch('lti_consumer.lti_1p3.consumer.get_data_from_cache')
1070+
def test_enable_assessment_control(self, mock_get_data_from_cache):
1071+
"""
1072+
Ensure that the correct claims are included in LTI launch messages with an ACS url set.
1073+
"""
1074+
1075+
mock_launch_data = self.get_launch_data(message_type="LtiStartProctoring")
1076+
mock_get_data_from_cache.return_value = mock_launch_data
1077+
self._setup_proctoring()
1078+
1079+
self.lti_consumer.set_proctoring_data(
1080+
attempt_number="attempt_number",
1081+
session_data="session_data",
1082+
start_assessment_url="start_assessment_url",
1083+
assessment_control_url="assessment_control_url",
1084+
assessment_control_actions=["flagRequest", "terminateRequest"],
1085+
)
1086+
1087+
token = self.lti_consumer.generate_launch_request(
1088+
self.preflight_response,
1089+
)['id_token']
1090+
1091+
decoded_token = self.lti_consumer.key_handler.validate_and_decode(token)
1092+
expected_claims = self.lti_consumer.get_start_proctoring_claims()
1093+
expected_claims.update(self.lti_consumer.get_assessment_control_claim())
1094+
1095+
decoded_token_claims = decoded_token.items()
1096+
for claim in expected_claims.items():
1097+
self.assertIn(claim, decoded_token_claims)
1098+
10481099
@patch('lti_consumer.lti_1p3.consumer.get_data_from_cache')
10491100
def test_generate_launch_request_invalid_message(self, mock_get_data_from_cache):
10501101
"""

lti_consumer/plugin/views.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,9 @@ def launch_gate_endpoint(request, suffix=None): # pylint: disable=unused-argume
275275
lti_consumer.set_proctoring_data(
276276
attempt_number=launch_data.proctoring_launch_data.attempt_number,
277277
session_data=session_data,
278-
start_assessment_url=launch_data.proctoring_launch_data.start_assessment_url
278+
start_assessment_url=launch_data.proctoring_launch_data.start_assessment_url,
279+
assessment_control_url=launch_data.proctoring_launch_data.assessment_control_url,
280+
assessment_control_actions=launch_data.proctoring_launch_data.assessment_control_actions,
279281
)
280282
elif launch_data.message_type == 'LtiEndAssessment':
281283
lti_consumer.set_proctoring_data(

0 commit comments

Comments
 (0)