From c5d095783b901ebbcdda6d35300d950242e2cfe9 Mon Sep 17 00:00:00 2001 From: Quitterie Lucas Date: Fri, 3 May 2024 16:05:59 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(xapi)=20add=20ralph-malph=20library?= =?UTF-8?q?=20for=20statements=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finally, we have integrated Ralph in marsha to help generate xAPI statements with Pydantic! However the integration is partial as statements are initialized from the frontend. --- CHANGELOG.md | 1 + .../api/xapi/document/test_from_website.py | 1 + src/backend/marsha/core/tests/test_xapi.py | 96 ++++++++--- .../document/test_statement_from_website.py | 12 +- .../xapi/video/test_statement_from_website.py | 36 +++- src/backend/marsha/core/xapi.py | 160 ++++++++++-------- src/backend/setup.cfg | 1 + 7 files changed, 206 insertions(+), 101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc5154493f..a157407578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Integrate `ralph-malph` library for xAPI statements generation - Add scaleway storage configuration - Add Peertube pipeline to VOD - Celery task queue diff --git a/src/backend/marsha/core/tests/api/xapi/document/test_from_website.py b/src/backend/marsha/core/tests/api/xapi/document/test_from_website.py index 95d55c0ff4..6de5cfa746 100644 --- a/src/backend/marsha/core/tests/api/xapi/document/test_from_website.py +++ b/src/backend/marsha/core/tests/api/xapi/document/test_from_website.py @@ -24,6 +24,7 @@ def test_xapi_statement_document_resource(self): session_id = str(uuid.uuid4()) jwt_token = UserAccessTokenFactory() + print(jwt_token.__dict__) data = { "verb": { diff --git a/src/backend/marsha/core/tests/test_xapi.py b/src/backend/marsha/core/tests/test_xapi.py index 5d76034d08..cb91fa5db8 100644 --- a/src/backend/marsha/core/tests/test_xapi.py +++ b/src/backend/marsha/core/tests/test_xapi.py @@ -80,7 +80,6 @@ def test_xapi_statement_missing_user(self): "name": {"en-US": "test video xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -91,11 +90,17 @@ def test_xapi_statement_missing_user(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/video"}], + "category": [ + { + "id": "https://w3id.org/xapi/video", + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + } + ], "parent": [ { "id": "course-v1:ufr+mathematics+0001", - "objectType": "Activity", "definition": { "type": "http://adlnet.gov/expapi/activities/course" }, @@ -121,6 +126,7 @@ def test_xapi_statement_enrich_statement(self): session_id="326c0689-48c1-493e-8d2d-9fb0c289de7f", context_id="course-v1:ufr+mathematics+0001", user__id="b2584aa405540758db2a6278521b6478", + user__username="johndoe", ) base_statement = { @@ -153,6 +159,7 @@ def test_xapi_statement_enrich_statement(self): self.assertEqual( statement["actor"], { + "name": "johndoe", "objectType": "Agent", "account": { "name": "b2584aa405540758db2a6278521b6478", @@ -168,7 +175,6 @@ def test_xapi_statement_enrich_statement(self): "name": {"en-US": "test video xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -179,11 +185,17 @@ def test_xapi_statement_enrich_statement(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/video"}], + "category": [ + { + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + "id": "https://w3id.org/xapi/video", + } + ], "parent": [ { "id": "course-v1:ufr+mathematics+0001", - "objectType": "Activity", "definition": { "type": "http://adlnet.gov/expapi/activities/course" }, @@ -210,6 +222,7 @@ def test_xapi_statement_live_video(self): session_id="326c0689-48c1-493e-8d2d-9fb0c289de7f", context_id="course-v1:ufr+mathematics+0001", user__id="b2584aa405540758db2a6278521b6478", + user__username="johndoe", ) base_statement = { @@ -242,6 +255,7 @@ def test_xapi_statement_live_video(self): self.assertEqual( statement["actor"], { + "name": "johndoe", "objectType": "Agent", "account": { "name": "b2584aa405540758db2a6278521b6478", @@ -257,7 +271,6 @@ def test_xapi_statement_live_video(self): "name": {"en-US": "test video xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -268,11 +281,17 @@ def test_xapi_statement_live_video(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/video"}], + "category": [ + { + "id": "https://w3id.org/xapi/video", + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + } + ], "parent": [ { "id": "course-v1:ufr+mathematics+0001", - "objectType": "Activity", "definition": { "type": "http://adlnet.gov/expapi/activities/course" }, @@ -300,6 +319,7 @@ def test_xapi_statement_live_video_ended(self): session_id="326c0689-48c1-493e-8d2d-9fb0c289de7f", context_id="course-v1:ufr+mathematics+0001", user__id="b2584aa405540758db2a6278521b6478", + user__username="johndoe", ) base_statement = { @@ -332,6 +352,7 @@ def test_xapi_statement_live_video_ended(self): self.assertEqual( statement["actor"], { + "name": "johndoe", "objectType": "Agent", "account": { "name": "b2584aa405540758db2a6278521b6478", @@ -347,7 +368,6 @@ def test_xapi_statement_live_video_ended(self): "name": {"en-US": "test video xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -358,11 +378,17 @@ def test_xapi_statement_live_video_ended(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/video"}], + "category": [ + { + "id": "https://w3id.org/xapi/video", + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + } + ], "parent": [ { "id": "course-v1:ufr+mathematics+0001", - "objectType": "Activity", "definition": { "type": "http://adlnet.gov/expapi/activities/course" }, @@ -434,7 +460,6 @@ def test_xapi_statement_missing_context_id(self): "name": {"en-US": "test video xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -445,7 +470,14 @@ def test_xapi_statement_missing_context_id(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/video"}] + "category": [ + { + "id": "https://w3id.org/xapi/video", + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + } + ] }, }, ) @@ -470,6 +502,7 @@ def test_xapi_statement_enrich_statement(self): session_id="326c0689-48c1-493e-8d2d-9fb0c289de7f", context_id="course-v1:ufr+mathematics+0001", user__id="b2584aa405540758db2a6278521b6478", + user__username="johndoe", ) base_statement = { @@ -493,6 +526,7 @@ def test_xapi_statement_enrich_statement(self): self.assertEqual( statement["actor"], { + "name": "johndoe", "objectType": "Agent", "account": { "name": "b2584aa405540758db2a6278521b6478", @@ -508,7 +542,6 @@ def test_xapi_statement_enrich_statement(self): "name": {"en-US": "test document xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -519,11 +552,17 @@ def test_xapi_statement_enrich_statement(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/lms"}], + "category": [ + { + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + "id": "https://w3id.org/xapi/lms", + } + ], "parent": [ { "id": "course-v1:ufr+mathematics+0001", - "objectType": "Activity", "definition": { "type": "http://adlnet.gov/expapi/activities/course" }, @@ -547,6 +586,7 @@ def test_xapi_statement_missing_context_id(self): jwt_token = LTIPlaylistAccessTokenFactory( session_id="326c0689-48c1-493e-8d2d-9fb0c289de7f", user__id="b2584aa405540758db2a6278521b6478", + user__username="johndoe", ) del jwt_token.payload["context_id"] @@ -571,6 +611,7 @@ def test_xapi_statement_missing_context_id(self): self.assertEqual( statement["actor"], { + "name": "johndoe", "objectType": "Agent", "account": { "name": "b2584aa405540758db2a6278521b6478", @@ -586,7 +627,6 @@ def test_xapi_statement_missing_context_id(self): "name": {"en-US": "test document xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -597,7 +637,14 @@ def test_xapi_statement_missing_context_id(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/lms"}] + "category": [ + { + "id": "https://w3id.org/xapi/lms", + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + } + ] }, }, ) @@ -655,7 +702,6 @@ def test_xapi_statement_missing_user_id(self): "name": {"en-US": "test document xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -666,11 +712,17 @@ def test_xapi_statement_missing_user_id(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/lms"}], + "category": [ + { + "id": "https://w3id.org/xapi/lms", + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + } + ], "parent": [ { "id": "course-v1:ufr+mathematics+0001", - "objectType": "Activity", "definition": { "type": "http://adlnet.gov/expapi/activities/course" }, diff --git a/src/backend/marsha/core/tests/xapi/document/test_statement_from_website.py b/src/backend/marsha/core/tests/xapi/document/test_statement_from_website.py index 8c91ed768b..23df7d53d6 100644 --- a/src/backend/marsha/core/tests/xapi/document/test_statement_from_website.py +++ b/src/backend/marsha/core/tests/xapi/document/test_statement_from_website.py @@ -41,11 +41,11 @@ def test_xapi_statement_enrich_statement(self): self.assertEqual( statement["actor"], { + "name": f"{user.username}", "objectType": "Agent", "account": { "name": f"{user.id}", "homePage": "http://marsha.education", - "mbox": "mailto:john@example.org", }, }, ) @@ -57,7 +57,6 @@ def test_xapi_statement_enrich_statement(self): "name": {"en-US": "test document xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -68,7 +67,14 @@ def test_xapi_statement_enrich_statement(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/lms"}] + "category": [ + { + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + "id": "https://w3id.org/xapi/lms", + } + ] }, }, ) diff --git a/src/backend/marsha/core/tests/xapi/video/test_statement_from_website.py b/src/backend/marsha/core/tests/xapi/video/test_statement_from_website.py index 1af2b0e0c2..91254cb308 100644 --- a/src/backend/marsha/core/tests/xapi/video/test_statement_from_website.py +++ b/src/backend/marsha/core/tests/xapi/video/test_statement_from_website.py @@ -51,11 +51,11 @@ def test_xapi_statement_enrich_statement(self): self.assertEqual( statement["actor"], { + "name": f"{user.username}", "objectType": "Agent", "account": { "name": f"{user.id}", "homePage": "http://marsha.education", - "mbox": "mailto:john@example.org", }, }, ) @@ -67,7 +67,6 @@ def test_xapi_statement_enrich_statement(self): "name": {"en-US": "test video xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -78,7 +77,14 @@ def test_xapi_statement_enrich_statement(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/video"}], + "category": [ + { + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + "id": "https://w3id.org/xapi/video", + } + ], }, }, ) @@ -128,11 +134,11 @@ def test_xapi_statement_live_video(self): self.assertEqual( statement["actor"], { + "name": "john", "objectType": "Agent", "account": { "name": f"{user.id}", "homePage": "http://marsha.education", - "mbox": "mailto:john@example.org", }, }, ) @@ -144,7 +150,6 @@ def test_xapi_statement_live_video(self): "name": {"en-US": "test video xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -155,7 +160,14 @@ def test_xapi_statement_live_video(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/video"}], + "category": [ + { + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + "id": "https://w3id.org/xapi/video", + }, + ], }, }, ) @@ -206,11 +218,11 @@ def test_xapi_statement_live_video_ended(self): self.assertEqual( statement["actor"], { + "name": "john", "objectType": "Agent", "account": { "name": f"{user.id}", "homePage": "http://marsha.education", - "mbox": "mailto:john@example.org", }, }, ) @@ -222,7 +234,6 @@ def test_xapi_statement_live_video_ended(self): "name": {"en-US": "test video xapi"}, }, "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838", - "objectType": "Activity", }, ) self.assertEqual( @@ -233,7 +244,14 @@ def test_xapi_statement_live_video_ended(self): "43b4-8452-2037fed588df" }, "contextActivities": { - "category": [{"id": "https://w3id.org/xapi/video"}], + "category": [ + { + "definition": { + "type": "http://adlnet.gov/expapi/activities/profile" + }, + "id": "https://w3id.org/xapi/video", + } + ], }, }, ) diff --git a/src/backend/marsha/core/xapi.py b/src/backend/marsha/core/xapi.py index dd019c6ebf..370d787b3b 100644 --- a/src/backend/marsha/core/xapi.py +++ b/src/backend/marsha/core/xapi.py @@ -7,6 +7,21 @@ from django.utils import timezone from django.utils.translation import to_locale +from ralph.models.xapi.base.agents import BaseXapiAgentWithAccount +from ralph.models.xapi.base.ifi import BaseXapiAccount +from ralph.models.xapi.concepts.activity_types.scorm_profile import CourseActivity +from ralph.models.xapi.concepts.activity_types.tincan_vocabulary import ( + DocumentActivity, + DocumentActivityDefinition, + WebinarActivity, + WebinarActivityDefinition, +) +from ralph.models.xapi.concepts.activity_types.video import ( + VideoActivity, + VideoActivityDefinition, +) +from ralph.models.xapi.lms.contexts import LMSProfileActivity +from ralph.models.xapi.video.contexts import VideoProfileActivity import requests @@ -33,6 +48,15 @@ def get_user_id(jwt_token): else jwt_token.payload["session_id"] ) + @staticmethod + def get_username(jwt_token): + """Return the user name if present in the JWT token or None otherwise.""" + return ( + jwt_token.payload["user"].get("username") + if jwt_token.payload.get("user") + else None + ) + @staticmethod def get_homepage(resource): """Return the domain associated to the playlist consumer site.""" @@ -45,24 +69,19 @@ def get_locale(self): def get_actor_from_website(self, homepage, user): """Return the actor property from a website context""" - return { - "objectType": "Agent", - "account": { - "homePage": homepage, - "mbox": f"mailto:{user.email}", - "name": str(user.id), - }, - } + return BaseXapiAgentWithAccount( + account=BaseXapiAccount(homePage=homepage, name=str(user.id)), + name=user.username, + ).model_dump(exclude_none=True) - def get_actor_from_lti(self, homepage, user_id): + def get_actor_from_lti(self, homepage, user_id, username): """Return the actor property from a LTI context""" - return { - "objectType": "Agent", - "account": {"name": user_id, "homePage": homepage}, - } + return BaseXapiAgentWithAccount( + name=username, account=BaseXapiAccount(homePage=homepage, name=user_id) + ).model_dump(exclude_none=True) def build_common_statement_properties( - self, statement, homepage, user=None, user_id=None + self, statement, homepage, user=None, user_id=None, username=None # pylint: disable=too-many-arguments ): """build statement properties common to all resources.""" if "id" not in statement: @@ -73,7 +92,7 @@ def build_common_statement_properties( statement["actor"] = ( self.get_actor_from_website(homepage, user) if user - else self.get_actor_from_lti(homepage, user_id) + else self.get_actor_from_lti(homepage, user_id, username) ) return statement @@ -83,28 +102,32 @@ class XAPIDocumentStatement(XAPIStatementMixin): """Object managing statement for document objects.""" # pylint: disable=too-many-arguments - def _build_statement(self, document, statement, homepage, user=None, user_id=None): + def _build_statement( + self, document, statement, homepage, user=None, user_id=None, username=None + ): """Build all common properties for a document.""" if re.match(r"^http(s?):\/\/.*", homepage) is None: homepage = f"http://{homepage}" statement = self.build_common_statement_properties( - statement, homepage, user=user, user_id=user_id + statement, homepage, user=user, user_id=user_id, username=username ) statement["context"].update( - {"contextActivities": {"category": [{"id": "https://w3id.org/xapi/lms"}]}} + { + "contextActivities": { + "category": [LMSProfileActivity().model_dump(exclude_none=True)] + } + } ) - statement["object"] = { - "definition": { - "type": "http://id.tincanapi.com/activitytype/document", - "name": {self.get_locale(): document.title}, - }, - "id": f"uuid://{document.id}", - "objectType": "Activity", - } + statement["object"] = DocumentActivity( + definition=DocumentActivityDefinition( + name={self.get_locale(): document.title} + ), + id=f"uuid://{document.id}", + ).model_dump(exclude_none=True) return statement @@ -148,19 +171,16 @@ def from_lti(self, document, statement, jwt_token): statement, homepage=self.get_homepage(document), user_id=self.get_user_id(jwt_token), + username=self.get_username(jwt_token), ) if jwt_token.payload.get("context_id"): statement["context"]["contextActivities"].update( { "parent": [ - { - "id": jwt_token.payload["context_id"], - "objectType": "Activity", - "definition": { - "type": "http://adlnet.gov/expapi/activities/course" - }, - } + CourseActivity(id=jwt_token.payload["context_id"]).model_dump( + exclude_none=True + ) ] } ) @@ -171,45 +191,54 @@ def from_lti(self, document, statement, jwt_token): class XAPIVideoStatement(XAPIStatementMixin): """Object managing statement for video objects.""" - def _get_activity_type(self, video): - """Return the activity type for a given video""" - - activity_type = "https://w3id.org/xapi/video/activity-type/video" - + def _get_object(self, video): + """Return the object xAPI instance for a given video""" # When the video is a live we change the activity to webinar if video.is_live: - activity_type = "http://id.tincanapi.com/activitytype/webinar" - - return activity_type + return WebinarActivity( + id=f"uuid://{video.id}", + definition=WebinarActivityDefinition( + name={self.get_locale(): video.title} + ), + ).model_dump(exclude_none=True) + + return VideoActivity( + id=f"uuid://{video.id}", + definition=VideoActivityDefinition(name={self.get_locale(): video.title}), + ).model_dump(exclude_none=True) # pylint: disable=too-many-arguments - def _build_statement(self, video, statement, homepage, user=None, user_id=None): + def _build_statement( + self, video, statement, homepage, user=None, user_id=None, username=None + ): """Build all common properties for a video.""" if re.match(r"^http(s?):\/\/.*", homepage) is None: homepage = f"http://{homepage}" statement = self.build_common_statement_properties( - statement, homepage, user=user, user_id=user_id - ) - - category_id = ( - "https://w3id.org/xapi/lms" - if statement["verb"]["id"] == "http://id.tincanapi.com/verb/downloaded" - else "https://w3id.org/xapi/video" + statement, homepage, user=user, user_id=user_id, username=username ) - statement["context"].update( - {"contextActivities": {"category": [{"id": category_id}]}} - ) + if statement["verb"]["id"] == "http://id.tincanapi.com/verb/downloaded": + statement["context"].update( + { + "contextActivities": { + "category": [LMSProfileActivity().model_dump(exclude_none=True)] + } + } + ) + else: + statement["context"].update( + { + "contextActivities": { + "category": [ + VideoProfileActivity().model_dump(exclude_none=True) + ] + } + } + ) - statement["object"] = { - "definition": { - "type": self._get_activity_type(video), - "name": {self.get_locale(): video.title}, - }, - "id": f"uuid://{video.id}", - "objectType": "Activity", - } + statement["object"] = self._get_object(video) return statement @@ -286,19 +315,16 @@ def from_lti(self, video, statement, jwt_token): statement, homepage=self.get_homepage(video), user_id=self.get_user_id(jwt_token), + username=self.get_username(jwt_token), ) if jwt_token.payload.get("context_id"): statement["context"]["contextActivities"].update( { "parent": [ - { - "id": jwt_token.payload["context_id"], - "objectType": "Activity", - "definition": { - "type": "http://adlnet.gov/expapi/activities/course" - }, - } + CourseActivity(id=jwt_token.payload["context_id"]).model_dump( + exclude_none=True + ) ] } ) diff --git a/src/backend/setup.cfg b/src/backend/setup.cfg index 218cc971c9..7de0d269f2 100644 --- a/src/backend/setup.cfg +++ b/src/backend/setup.cfg @@ -61,6 +61,7 @@ install_requires = pycaption==2.2.5 PyMuPDF==1.23.26 python-dateutil==2.9.0.post0 + # ralph-malph==5.0.0 requests==2.31.0 sentry-sdk==1.41.0 social-auth-app-django==5.4.0