Skip to content

Commit 0b6c973

Browse files
committed
🐛(xAPI) use domain from lti jwt token when no consumer site used
When a xAPI request is made from a LTI context but the video was created on the website, there is no consumer_site attached to the playlist. We are using the domain from the consumer_site when the xAPI request is made in a LTI context. To fix this issue, in the xAPI endpoint is testing if there is a consumer site and if not the one from the LTI passport is used.
1 parent 7013ab5 commit 0b6c973

File tree

5 files changed

+416
-18
lines changed

5 files changed

+416
-18
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Versioning](https://semver.org/spec/v2.0.0.html).
1919
### Fixed
2020

2121
- Enable join classroom button if username is populated from local storage
22+
- Use domain from lti jwt token when no consumer site used
2223

2324
## [5.2.0] - 2024-10-22
2425

src/backend/marsha/core/api/xapi.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from marsha.core import permissions, serializers
1818
from marsha.core.api.base import APIViewMixin
1919
from marsha.core.defaults import XAPI_STATEMENT_ID_CACHE
20+
from marsha.core.models import ConsumerSite
2021
from marsha.core.xapi import XAPI, get_xapi_statement
2122

2223

@@ -34,6 +35,14 @@ def _statement_from_lti(
3435
):
3536
consumer_site = object_instance.playlist.consumer_site
3637

38+
if consumer_site is None:
39+
# The resource is used in a LTI context but have been created in the website context
40+
# so the consumer site does not exists on the playlist.
41+
# We have to find it directly from the LTI information we have in the JWT token.
42+
consumer_site = ConsumerSite.objects.get(
43+
pk=request.resource.token.payload.get("consumer_site")
44+
)
45+
3746
# xapi statements are sent to a consumer-site-specific logger. We assume that the logger
3847
# name respects the following convention: "xapi.[consumer site domain]",
3948
# _e.g._ `xapi.foo.education` for the `foo.education` consumer site domain. Note that this
@@ -45,6 +54,7 @@ def _statement_from_lti(
4554
object_instance,
4655
partial_xapi_statement.validated_data,
4756
request.resource.token,
57+
consumer_site.domain,
4858
)
4959

5060
# Log the statement in the xapi logger
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Tests for the video xAPI statement sent from LTI."""
2+
3+
import io
4+
import json
5+
import logging
6+
7+
from django.core.cache import cache
8+
from django.test import TestCase
9+
10+
from logging_ldp.formatters import LDPGELFFormatter
11+
12+
from marsha.core.factories import (
13+
ConsumerSiteFactory,
14+
OrganizationFactory,
15+
PlaylistFactory,
16+
VideoFactory,
17+
)
18+
from marsha.core.simple_jwt.factories import StudentLtiTokenFactory
19+
20+
21+
class XAPIVideoFromLTITest(TestCase):
22+
"""Tests for the video xAPI statement sent from LTI."""
23+
24+
maxDiff = None
25+
26+
def setUp(self):
27+
self.logger = logging.getLogger("xapi.lti.example.com")
28+
self.logger.setLevel(logging.INFO)
29+
self.log_stream = io.StringIO()
30+
31+
handler = logging.StreamHandler(self.log_stream)
32+
handler.setFormatter(LDPGELFFormatter(token="foo", null_character=False))
33+
self.logger.addHandler(handler)
34+
35+
# Clear cache
36+
cache.clear()
37+
38+
super().setUp()
39+
40+
def test_send_xapi_statement_from_lti_request(self):
41+
"""
42+
A video xAPI statement should be sent when the video has been created in a LTI context.
43+
"""
44+
video = VideoFactory(
45+
id="7b18195e-e183-4bbf-b8ef-5145ef64ae19",
46+
title="Video 000",
47+
playlist__consumer_site__domain="lti.example.com",
48+
)
49+
jwt_token = StudentLtiTokenFactory(
50+
playlist=video.playlist,
51+
context_id="cf253c93-3738-496b-8c8f-1e8a1b09a6b1",
52+
)
53+
54+
data = {
55+
"verb": {
56+
"id": "http://adlnet.gov/expapi/verbs/initialized",
57+
"display": {"en-US": "initialized"},
58+
},
59+
"context": {
60+
"extensions": {"https://w3id.org/xapi/video/extensions/volume": 1}
61+
},
62+
}
63+
64+
response = self.client.post(
65+
f"/xapi/video/{video.id}/",
66+
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
67+
data=json.dumps(data),
68+
content_type="application/json",
69+
)
70+
71+
self.assertEqual(response.status_code, 200)
72+
log = json.loads(self.log_stream.getvalue())
73+
self.assertIn("short_message", log)
74+
message = json.loads(log["short_message"])
75+
self.assertEqual(
76+
message.get("verb"),
77+
{
78+
"id": "http://adlnet.gov/expapi/verbs/initialized",
79+
"display": {"en-US": "initialized"},
80+
},
81+
)
82+
self.assertEqual(
83+
message.get("context"),
84+
{
85+
"extensions": {"https://w3id.org/xapi/video/extensions/volume": 1},
86+
"contextActivities": {
87+
"category": [{"id": "https://w3id.org/xapi/video"}],
88+
"parent": [
89+
{
90+
"id": "cf253c93-3738-496b-8c8f-1e8a1b09a6b1",
91+
"objectType": "Activity",
92+
"definition": {
93+
"type": "http://adlnet.gov/expapi/activities/course"
94+
},
95+
}
96+
],
97+
},
98+
},
99+
)
100+
self.assertEqual(
101+
message.get("object"),
102+
{
103+
"definition": {
104+
"type": "https://w3id.org/xapi/video/activity-type/video",
105+
"name": {"en-US": "Video 000"},
106+
},
107+
"id": "uuid://7b18195e-e183-4bbf-b8ef-5145ef64ae19",
108+
"objectType": "Activity",
109+
},
110+
)
111+
112+
def test_send_xapi_statement_from_lti_request_video_no_consumer_site(self):
113+
"""
114+
A video xAPI statement should be sent when the video has not been created in a LTI context.
115+
"""
116+
organization = OrganizationFactory()
117+
playlist = PlaylistFactory(
118+
organization=organization, consumer_site=None, lti_id=None
119+
)
120+
consumer_site = ConsumerSiteFactory(domain="lti.example.com")
121+
video = VideoFactory(
122+
id="7b18195e-e183-4bbf-b8ef-5145ef64ae19",
123+
title="Video 000",
124+
playlist=playlist,
125+
)
126+
jwt_token = StudentLtiTokenFactory(
127+
playlist=video.playlist,
128+
context_id="cf253c93-3738-496b-8c8f-1e8a1b09a6b1",
129+
consumer_site=str(consumer_site.id),
130+
)
131+
self.assertIsNotNone(video.playlist.organization)
132+
self.assertIsNone(video.playlist.consumer_site)
133+
134+
data = {
135+
"verb": {
136+
"id": "http://adlnet.gov/expapi/verbs/initialized",
137+
"display": {"en-US": "initialized"},
138+
},
139+
"context": {
140+
"extensions": {"https://w3id.org/xapi/video/extensions/volume": 1}
141+
},
142+
}
143+
144+
response = self.client.post(
145+
f"/xapi/video/{video.id}/",
146+
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
147+
data=json.dumps(data),
148+
content_type="application/json",
149+
)
150+
151+
self.assertEqual(response.status_code, 200)
152+
log = json.loads(self.log_stream.getvalue())
153+
self.assertIn("short_message", log)
154+
message = json.loads(log["short_message"])
155+
self.assertEqual(
156+
message.get("verb"),
157+
{
158+
"id": "http://adlnet.gov/expapi/verbs/initialized",
159+
"display": {"en-US": "initialized"},
160+
},
161+
)
162+
self.assertEqual(
163+
message.get("context"),
164+
{
165+
"extensions": {"https://w3id.org/xapi/video/extensions/volume": 1},
166+
"contextActivities": {
167+
"category": [{"id": "https://w3id.org/xapi/video"}],
168+
"parent": [
169+
{
170+
"id": "cf253c93-3738-496b-8c8f-1e8a1b09a6b1",
171+
"objectType": "Activity",
172+
"definition": {
173+
"type": "http://adlnet.gov/expapi/activities/course"
174+
},
175+
}
176+
],
177+
},
178+
},
179+
)
180+
self.assertEqual(
181+
message.get("object"),
182+
{
183+
"definition": {
184+
"type": "https://w3id.org/xapi/video/activity-type/video",
185+
"name": {"en-US": "Video 000"},
186+
},
187+
"id": "uuid://7b18195e-e183-4bbf-b8ef-5145ef64ae19",
188+
"objectType": "Activity",
189+
},
190+
)

0 commit comments

Comments
 (0)