Repository: openfun/marsha

Issue:
Set xAPI timestamp property in the backend
## Bug Report

**Actual Behavior**
When a statement is sent, the front application sets in its payload the timestamp statement, so we use the client timestamp. In some cases this timestamp is not relevant at all, it can be configured in the future or in the past...


**Possible Solution**

Instead of setting the timestamp in the `XAPIStatement` [front class](https://github.com/openfun/marsha/blob/446e3c3d7ca82870514d1c2a55e4ab8ee85a818b/src/frontend/XAPI/XAPIStatement.ts#L382), we should set it in the `xapi` [backend module](https://github.com/openfun/marsha/blob/446e3c3d7ca82870514d1c2a55e4ab8ee85a818b/src/backend/marsha/core/xapi.py#L89). The timestamp should also be removed in the [backend seralizer](https://github.com/openfun/marsha/blob/446e3c3d7ca82870514d1c2a55e4ab8ee85a818b/src/backend/marsha/core/serializers.py#L445)
It will not be exactly the timestamp when the statement occurred but at least it will be more relevant than the timestamp we can have today.




Discussion:

Resolving patch:
diff --git a/src/backend/marsha/core/serializers.py b/src/backend/marsha/core/serializers.py
--- a/src/backend/marsha/core/serializers.py
+++ b/src/backend/marsha/core/serializers.py
@@ -442,7 +442,6 @@ class XAPIStatementSerializer(serializers.Serializer):
         required=False,
         default=str(uuid.uuid4()),
     )
-    timestamp = serializers.DateTimeField()
 
     def validate(self, attrs):
         """Check if there is no extra arguments in the submitted payload."""
diff --git a/src/backend/marsha/core/xapi.py b/src/backend/marsha/core/xapi.py
--- a/src/backend/marsha/core/xapi.py
+++ b/src/backend/marsha/core/xapi.py
@@ -2,6 +2,8 @@
 import re
 import uuid
 
+from django.utils import timezone
+
 import requests
 
 from .exceptions import MissingUserIdError
@@ -86,8 +88,7 @@ def _enrich_statement(self, statement, video, lti_user):
         if "id" not in statement:
             statement["id"] = str(uuid.uuid4())
 
-        statement["timestamp"] = statement["timestamp"].isoformat()
-
+        statement["timestamp"] = timezone.now().isoformat()
         statement["context"].update(
             {"contextActivities": {"category": [{"id": "https://w3id.org/xapi/video"}]}}
         )


Test patch:
diff --git a/src/backend/marsha/core/tests/test_api_xapi_statement.py b/src/backend/marsha/core/tests/test_api_xapi_statement.py
--- a/src/backend/marsha/core/tests/test_api_xapi_statement.py
+++ b/src/backend/marsha/core/tests/test_api_xapi_statement.py
@@ -72,7 +72,6 @@ def test_xapi_statement_api_with_invalid_payload(self):
             {
                 "verb": ["This field is required."],
                 "context": ["This field is required."],
-                "timestamp": ["This field is required."],
             },
         )
 
@@ -95,7 +94,6 @@ def test_xapi_statement_with_invalid_video(self, video_model_mock):
             "context": {
                 "extensions": {"https://w3id.org/xapi/video/extensions/volume": 1}
             },
-            "timestamp": "2018-12-31T16:17:35.717Z",
         }
 
         response = self.client.post(
@@ -129,7 +127,6 @@ def test_xapi_statement_with_request_error_to_lrs(
             "context": {
                 "extensions": {"https://w3id.org/xapi/video/extensions/volume": 1}
             },
-            "timestamp": "2018-12-31T16:17:35.717Z",
         }
 
         mock_response = mock.Mock()
@@ -175,7 +172,6 @@ def test_xapi_statement_with_request_to_lrs_successful(
             "context": {
                 "extensions": {"https://w3id.org/xapi/video/extensions/volume": 1}
             },
-            "timestamp": "2018-12-31T16:17:35.717Z",
         }
 
         video_model_mock.return_value = video
@@ -211,7 +207,6 @@ def test_xapi_statement_with_missing_user_id(self, xapi_mock, video_model_mock):
             "context": {
                 "extensions": {"https://w3id.org/xapi/video/extensions/volume": 1}
             },
-            "timestamp": "2018-12-31T16:17:35.717Z",
         }
 
         video_model_mock.return_value = video
diff --git a/src/backend/marsha/core/tests/test_serializers_xapi_statement.py b/src/backend/marsha/core/tests/test_serializers_xapi_statement.py
--- a/src/backend/marsha/core/tests/test_serializers_xapi_statement.py
+++ b/src/backend/marsha/core/tests/test_serializers_xapi_statement.py
@@ -102,7 +102,6 @@ def test_xapi_statement_serializer_without_result_and_id(self):
         data = {
             "verb": {"id": "http://url.tld", "display": {"foo": "bar"}},
             "context": {"extensions": {"foo": "bar"}},
-            "timestamp": "2018-12-31T16:17:35.717Z",
         }
 
         serializer = XAPIStatementSerializer(data=data)
@@ -117,7 +116,6 @@ def test_xapi_statement_serializer_with_result_and_id(self):
         data = {
             "verb": {"id": "http://url.tld", "display": {"foo": "bar"}},
             "context": {"extensions": {"foo": "bar"}},
-            "timestamp": "2018-12-31T16:17:35.717Z",
             "result": {"extensions": {"foo": "bar"}},
             "id": "cc8868ac-d84b-4826-89df-a6171e9e3641",
         }
@@ -134,10 +132,7 @@ def test_xapi_statement_serializer_with_result_and_id(self):
 
     def test_xapi_statement_serializer_with_missing_verb(self):
         """The XAPIStatementSerializer should fail when verb is missing."""
-        data = {
-            "context": {"extensions": {"foo": "bar"}},
-            "timestamp": "2018-12-31T16:17:35.717Z",
-        }
+        data = {"context": {"extensions": {"foo": "bar"}}}
 
         serializer = XAPIStatementSerializer(data=data)
         self.assertFalse(serializer.is_valid())
@@ -147,10 +142,7 @@ def test_xapi_statement_serializer_with_missing_verb(self):
 
     def test_xapi_statement_serializer_with_missing_context(self):
         """The XAPIStatementSerializer should fail when context is missing."""
-        data = {
-            "verb": {"id": "http://url.tld", "display": {"foo": "bar"}},
-            "timestamp": "2018-12-31T16:17:35.717Z",
-        }
+        data = {"verb": {"id": "http://url.tld", "display": {"foo": "bar"}}}
 
         serializer = XAPIStatementSerializer(data=data)
         self.assertFalse(serializer.is_valid())
@@ -163,7 +155,6 @@ def test_xapi_statement_serializer_with_extra_data(self):
         data = {
             "verb": {"id": "http://url.tld", "display": {"foo": "bar"}},
             "context": {"extensions": {"foo": "bar"}},
-            "timestamp": "2018-12-31T16:17:35.717Z",
             "foo": "bar",
         }
 
diff --git a/src/backend/marsha/core/tests/test_xapi.py b/src/backend/marsha/core/tests/test_xapi.py
new file mode 100644
--- /dev/null
+++ b/src/backend/marsha/core/tests/test_xapi.py
@@ -0,0 +1,125 @@
+"""Tests for the xapi module of the Marsha project."""
+from unittest import mock
+
+from django.test import TestCase
+
+from ..exceptions import MissingUserIdError
+from ..factories import VideoFactory
+from ..lti import LTIUser
+from ..xapi import XAPI, requests
+
+
+class MockLtiUser:
+    """Mock LTIUser class."""
+
+    @property
+    def user_id(self):
+        """Force to raise a wanted exception."""
+        raise AttributeError("user_id")
+
+
+class XAPITest(TestCase):
+    """Test the xapi module."""
+
+    def test_xapi_missing_user_id(self):
+        """Missing lti user_id should raise an exception."""
+        xapi = XAPI("https://lrs.example.com", "auth_token")
+        video = VideoFactory()
+
+        with self.assertRaises(MissingUserIdError):
+            xapi.send(video, {}, MockLtiUser())
+
+    @mock.patch.object(requests, "post")
+    def test_xapi_enrich_and_send_statement(self, mock_requests_post):
+        """XAPI statement sent by the front application should be enriched.
+
+        Before sending a statement, the xapi module is responsible for enriching it.
+        """
+        xapi = XAPI("https://lrs.example.com", "auth_token")
+        video = VideoFactory(
+            id="68333c45-4b8c-4018-a195-5d5e1706b838",
+            playlist__consumer_site__domain="example.com",
+        )
+
+        mock_token_user = mock.MagicMock()
+        mock_token = mock.MagicMock()
+        type(mock_token).payload = mock.PropertyMock(
+            return_value={
+                "user_id": "foo",
+                "course": {
+                    "school_name": "ufr",
+                    "course_name": "mathematics",
+                    "course_run": "001",
+                },
+            }
+        )
+        type(mock_token_user).token = mock.PropertyMock(return_value=mock_token)
+
+        lti_user = LTIUser(mock_token_user)
+
+        mock_response = mock.MagicMock()
+        mock_response.raise_for_status.return_value = 200
+        mock_requests_post.return_value = mock_response
+        base_statement = {
+            "context": {
+                "extensions": {
+                    "https://w3id.org/xapi/video/extensions/session-id": "a6151456-18b7-"
+                    "43b4-8452-2037fed588df"
+                }
+            },
+            "result": {
+                "extensions": {
+                    "https://w3id.org/xapi/video/extensions/time-from": 0,
+                    "https://w3id.org/xapi/video/extensions/time-to": 0,
+                    "https://w3id.org/xapi/video/extensions/length": 104.304,
+                    "https://w3id.org/xapi/video/extensions/progress": 0,
+                    "https://w3id.org/xapi/video/extensions/played-segments": "0",
+                }
+            },
+            "verb": {
+                "display": {"en-US": "seeked"},
+                "id": "https://w3id.org/xapi/video/verbs/seeked",
+            },
+            "id": "17dfcd44-b3e0-403d-ab96-e3ef7da616d4",
+        }
+
+        xapi.send(video, base_statement, lti_user)
+
+        args, kwargs = mock_requests_post.call_args_list[0]
+        self.assertEqual(args[0], "https://lrs.example.com")
+        self.assertEqual(
+            kwargs["headers"],
+            {
+                "Authorization": "auth_token",
+                "Content-Type": "application/json",
+                "X-Experience-API-Version": "1.0.3",
+            },
+        )
+        enriched_statement = kwargs["json"]
+
+        self.assertIsNotNone(enriched_statement["timestamp"])
+        self.assertEqual(
+            enriched_statement["actor"],
+            {
+                "objectType": "Agent",
+                "account": {"name": "foo", "homePage": "http://example.com"},
+            },
+        )
+        self.assertEqual(
+            enriched_statement["object"],
+            {
+                "definition": {
+                    "type": "https://w3id.org/xapi/video/activity-type/video",
+                    "extensions": {
+                        "https://w3id.org/xapi/acrossx/extensions/school": "ufr",
+                        "http://adlnet.gov/expapi/activities/course": "mathematics",
+                        "http://adlnet.gov/expapi/activities/module": "001",
+                    },
+                },
+                "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838",
+                "objectType": "Activity",
+            },
+        )
+        self.assertEqual(enriched_statement["verb"], base_statement["verb"])
+        self.assertEqual(enriched_statement["id"], base_statement["id"])
+        self.assertEqual(enriched_statement["result"], base_statement["result"])