Skip to content

Commit

Permalink
🐛(xAPI) use domain from lti jwt token when no consumer site used
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
lunika committed Nov 4, 2024
1 parent 7013ab5 commit 0b6c973
Show file tree
Hide file tree
Showing 5 changed files with 416 additions and 18 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Versioning](https://semver.org/spec/v2.0.0.html).
### Fixed

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

## [5.2.0] - 2024-10-22

Expand Down
10 changes: 10 additions & 0 deletions src/backend/marsha/core/api/xapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from marsha.core import permissions, serializers
from marsha.core.api.base import APIViewMixin
from marsha.core.defaults import XAPI_STATEMENT_ID_CACHE
from marsha.core.models import ConsumerSite
from marsha.core.xapi import XAPI, get_xapi_statement


Expand All @@ -34,6 +35,14 @@ def _statement_from_lti(
):
consumer_site = object_instance.playlist.consumer_site

if consumer_site is None:
# The resource is used in a LTI context but have been created in the website context
# so the consumer site does not exists on the playlist.
# We have to find it directly from the LTI information we have in the JWT token.
consumer_site = ConsumerSite.objects.get(
pk=request.resource.token.payload.get("consumer_site")
)

# xapi statements are sent to a consumer-site-specific logger. We assume that the logger
# name respects the following convention: "xapi.[consumer site domain]",
# _e.g._ `xapi.foo.education` for the `foo.education` consumer site domain. Note that this
Expand All @@ -45,6 +54,7 @@ def _statement_from_lti(
object_instance,
partial_xapi_statement.validated_data,
request.resource.token,
consumer_site.domain,
)

# Log the statement in the xapi logger
Expand Down
190 changes: 190 additions & 0 deletions src/backend/marsha/core/tests/api/xapi/video/test_from_lti.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
"""Tests for the video xAPI statement sent from LTI."""

import io
import json
import logging

from django.core.cache import cache
from django.test import TestCase

from logging_ldp.formatters import LDPGELFFormatter

from marsha.core.factories import (
ConsumerSiteFactory,
OrganizationFactory,
PlaylistFactory,
VideoFactory,
)
from marsha.core.simple_jwt.factories import StudentLtiTokenFactory


class XAPIVideoFromLTITest(TestCase):
"""Tests for the video xAPI statement sent from LTI."""

maxDiff = None

def setUp(self):
self.logger = logging.getLogger("xapi.lti.example.com")
self.logger.setLevel(logging.INFO)
self.log_stream = io.StringIO()

handler = logging.StreamHandler(self.log_stream)
handler.setFormatter(LDPGELFFormatter(token="foo", null_character=False))
self.logger.addHandler(handler)

# Clear cache
cache.clear()

super().setUp()

def test_send_xapi_statement_from_lti_request(self):
"""
A video xAPI statement should be sent when the video has been created in a LTI context.
"""
video = VideoFactory(
id="7b18195e-e183-4bbf-b8ef-5145ef64ae19",
title="Video 000",
playlist__consumer_site__domain="lti.example.com",
)
jwt_token = StudentLtiTokenFactory(
playlist=video.playlist,
context_id="cf253c93-3738-496b-8c8f-1e8a1b09a6b1",
)

data = {
"verb": {
"id": "http://adlnet.gov/expapi/verbs/initialized",
"display": {"en-US": "initialized"},
},
"context": {
"extensions": {"https://w3id.org/xapi/video/extensions/volume": 1}
},
}

response = self.client.post(
f"/xapi/video/{video.id}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
data=json.dumps(data),
content_type="application/json",
)

self.assertEqual(response.status_code, 200)
log = json.loads(self.log_stream.getvalue())
self.assertIn("short_message", log)
message = json.loads(log["short_message"])
self.assertEqual(
message.get("verb"),
{
"id": "http://adlnet.gov/expapi/verbs/initialized",
"display": {"en-US": "initialized"},
},
)
self.assertEqual(
message.get("context"),
{
"extensions": {"https://w3id.org/xapi/video/extensions/volume": 1},
"contextActivities": {
"category": [{"id": "https://w3id.org/xapi/video"}],
"parent": [
{
"id": "cf253c93-3738-496b-8c8f-1e8a1b09a6b1",
"objectType": "Activity",
"definition": {
"type": "http://adlnet.gov/expapi/activities/course"
},
}
],
},
},
)
self.assertEqual(
message.get("object"),
{
"definition": {
"type": "https://w3id.org/xapi/video/activity-type/video",
"name": {"en-US": "Video 000"},
},
"id": "uuid://7b18195e-e183-4bbf-b8ef-5145ef64ae19",
"objectType": "Activity",
},
)

def test_send_xapi_statement_from_lti_request_video_no_consumer_site(self):
"""
A video xAPI statement should be sent when the video has not been created in a LTI context.
"""
organization = OrganizationFactory()
playlist = PlaylistFactory(
organization=organization, consumer_site=None, lti_id=None
)
consumer_site = ConsumerSiteFactory(domain="lti.example.com")
video = VideoFactory(
id="7b18195e-e183-4bbf-b8ef-5145ef64ae19",
title="Video 000",
playlist=playlist,
)
jwt_token = StudentLtiTokenFactory(
playlist=video.playlist,
context_id="cf253c93-3738-496b-8c8f-1e8a1b09a6b1",
consumer_site=str(consumer_site.id),
)
self.assertIsNotNone(video.playlist.organization)
self.assertIsNone(video.playlist.consumer_site)

data = {
"verb": {
"id": "http://adlnet.gov/expapi/verbs/initialized",
"display": {"en-US": "initialized"},
},
"context": {
"extensions": {"https://w3id.org/xapi/video/extensions/volume": 1}
},
}

response = self.client.post(
f"/xapi/video/{video.id}/",
HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
data=json.dumps(data),
content_type="application/json",
)

self.assertEqual(response.status_code, 200)
log = json.loads(self.log_stream.getvalue())
self.assertIn("short_message", log)
message = json.loads(log["short_message"])
self.assertEqual(
message.get("verb"),
{
"id": "http://adlnet.gov/expapi/verbs/initialized",
"display": {"en-US": "initialized"},
},
)
self.assertEqual(
message.get("context"),
{
"extensions": {"https://w3id.org/xapi/video/extensions/volume": 1},
"contextActivities": {
"category": [{"id": "https://w3id.org/xapi/video"}],
"parent": [
{
"id": "cf253c93-3738-496b-8c8f-1e8a1b09a6b1",
"objectType": "Activity",
"definition": {
"type": "http://adlnet.gov/expapi/activities/course"
},
}
],
},
},
)
self.assertEqual(
message.get("object"),
{
"definition": {
"type": "https://w3id.org/xapi/video/activity-type/video",
"name": {"en-US": "Video 000"},
},
"id": "uuid://7b18195e-e183-4bbf-b8ef-5145ef64ae19",
"objectType": "Activity",
},
)
Loading

0 comments on commit 0b6c973

Please sign in to comment.