Repository: openfun/marsha

Issue:
Add video and document downloading xAPI events
# Purpose

Download a video or a document from marsha can be tracked as an xAPI statement. It is now a high priority to implement so that this activity can be recorded in current open courses

# Proposal

- [ ] download a video
Statement example:
```
{
        "id": "cb83247c-83db-43ae-9fc2-653c060f40be",
	"timestamp": "2021-09-27T15:44:01.323126+00:00",
	"actor": {
		"objectType": "Agent",
		"account": {
			"name": "56255f3807599c377bf0e5bf072359fd",
			"homePage": "http://localhost"
		}
	},
	"object": {
		"definition": {
			"type": "https://w3id.org/xapi/video/activity-type/video",
			"name": {
				"en-US": "video.mp4"
			}
		},
		"id": "uuid://aa29be06-7593-4912-9aae-7599c6b50dd9",
		"objectType": "Activity"
	},
	"verb": {
		"id": "http://id.tincanapi.com/verb/downloaded",
		"display": {
			"en-US": "downloaded"
		}
	},
	"context": {
		"extensions": {
			"https://w3id.org/xapi/video/extensions/session-id": "3908784c-bf7a-445f-a887-0d1565baa06d",
			"https://w3id.org/xapi/video/extensions/length": 75,
		},
		"contextActivities": {
			"category": [{
				"id": "https://w3id.org/xapi/video"
			}],
			"parent": [{
				"id": "course-v1:ufr+mathematics+0001",
				"objectType": "Activity",
				"definition": {
					"type": "http://adlnet.gov/expapi/activities/course"
				}
			}]
		}
	}
}
```

- [ ] download a document
Statement example:
```
{
        "id": "cb83247c-83db-43ae-9fc2-653c060f40be",
	"timestamp": "2021-09-27T15:44:01.323126+00:00",
	"actor": {
		"objectType": "Agent",
		"account": {
			"name": "56255f3807599c377bf0e5bf072359fd",
			"homePage": "http://localhost"
		}
	},
	"object": {
		"definition": {
			"type": "http://id.tincanapi.com/activitytype/document",
			"name": {
				"en-US": "document.pdf"
			},
		},
		"id": "uuid://aa29be06-7593-4912-9aae-7599c6b50dd9",
		"objectType": "Activity"
	}
	"verb": {
		"id": "http://id.tincanapi.com/verb/downloaded",
		"display": {
			"en-US": "downloaded"
		}
	},
	"context": {
		"contextActivities": {
			"parent": [{
				"id": "course-v1:ufr+mathematics+0001",
				"objectType": "Activity",
				"definition": {
					"type": "http://adlnet.gov/expapi/activities/course"
				}
			}]
		},
                "extensions": {
			"https://w3id.org/xapi/cmi5/context/extensions/sessionid": "3908784c-bf7a-445f-a887-0d1565baa06d",
		},
	}
}
```


Discussion:
```
"extensions": {
                                "https://w3id.org/xapi/acrossx/extensions/total-pages": 416
                        },
```

Is this is related to a document containing pages, I can't implement it. A document in marsha, for now, is everything else than a video. It can a PDF, an open office file, a zip, an executable file, etc.
If the document is something containing pages (pdf, epub, etc), we never parse it so we have no information about it.

Resolving patch:
diff --git a/src/backend/marsha/core/api.py b/src/backend/marsha/core/api.py
--- a/src/backend/marsha/core/api.py
+++ b/src/backend/marsha/core/api.py
@@ -8,6 +8,7 @@
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.db.models import Q
+from django.http import HttpResponseNotFound
 from django.utils import timezone
 
 import requests
@@ -41,7 +42,7 @@
 from .utils.s3_utils import create_presigned_post
 from .utils.time_utils import to_timestamp
 from .utils.xmpp_utils import close_room, create_room
-from .xapi import XAPI, XAPIStatement
+from .xapi import XAPI, get_xapi_statement
 
 
 logger = logging.getLogger(__name__)
@@ -915,7 +916,7 @@ class XAPIStatementView(APIView):
     permission_classes = [permissions.IsVideoToken]
     http_method_names = ["post"]
 
-    def post(self, request):
+    def post(self, request, resource):
         """Send a xAPI statement to a defined LRS.
 
         Parameters
@@ -930,16 +931,22 @@ def post(self, request):
             HttpResponse to reflect if the XAPI request failed or is successful
 
         """
+        try:
+            statement_object = get_xapi_statement(resource)
+        except NotImplementedError:
+            return HttpResponseNotFound()
+
         user = request.user
+        model = apps.get_model(app_label="core", model_name=resource)
         try:
-            video = Video.objects.get(pk=user.id)
-        except Video.DoesNotExist:
+            object_instance = model.objects.get(pk=user.id)
+        except model.DoesNotExist:
             return Response(
-                {"reason": f"video with id {user.id} does not exist"},
+                {"reason": f"{resource} with id {user.id} does not exist"},
                 status=404,
             )
 
-        consumer_site = video.playlist.consumer_site
+        consumer_site = object_instance.playlist.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]",
@@ -953,8 +960,8 @@ def post(self, request):
             return Response(partial_xapi_statement.errors, status=400)
 
         # xapi statement enriched with video and jwt_token informations
-        xapi_statement = XAPIStatement(
-            video, partial_xapi_statement.validated_data, user.token
+        xapi_statement = statement_object(
+            object_instance, partial_xapi_statement.validated_data, user.token
         )
 
         # Log the statement in the xapi logger
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
@@ -9,11 +9,97 @@
 import requests
 
 
-class XAPIStatement:
-    """Object to work on a XAPI Statement."""
+def get_xapi_statement(resource):
+    """Return the xapi object statement based on the required resource type."""
+    if resource == "video":
+        return XAPIVideoStatement
+
+    if resource == "document":
+        return XAPIDocumentStatement
+
+    raise NotImplementedError
+
+
+class XAPIStatementMixin:
+    """Mixin used by xapi statements."""
 
     statement = None
 
+    def get_statement(self):
+        """Return the enriched statement."""
+        return self.statement
+
+    def get_user_id(self, jwt_token):
+        """Return the user id if present in the JWT token or the session_is otherwise."""
+        return (
+            jwt_token.payload["user"].get("id")
+            if jwt_token.payload.get("user")
+            else jwt_token.payload["session_id"]
+        )
+
+    def get_homepage(self, resource):
+        """Return the domain associated to the playlist consumer site."""
+        return resource.playlist.consumer_site.domain
+
+
+class XAPIDocumentStatement(XAPIStatementMixin):
+    """Object managing statement for document objects."""
+
+    def __init__(self, document, statement, jwt_token):
+        """Compute a valid xapi download activity statement."""
+        user_id = self.get_user_id(jwt_token)
+
+        homepage = self.get_homepage(document)
+
+        activity_type = "http://id.tincanapi.com/activitytype/document"
+
+        if re.match(r"^http(s?):\/\/.*", homepage) is None:
+            homepage = f"http://{homepage}"
+
+        if "id" not in statement:
+            statement["id"] = str(uuid.uuid4())
+
+        statement["timestamp"] = timezone.now().isoformat()
+
+        if jwt_token.payload.get("context_id"):
+            statement["context"].update(
+                {
+                    "contextActivities": {
+                        "parent": [
+                            {
+                                "id": jwt_token.payload["context_id"],
+                                "objectType": "Activity",
+                                "definition": {
+                                    "type": "http://adlnet.gov/expapi/activities/course"
+                                },
+                            }
+                        ]
+                    }
+                }
+            )
+
+        statement["actor"] = {
+            "objectType": "Agent",
+            "account": {"name": user_id, "homePage": homepage},
+        }
+
+        statement["object"] = {
+            "definition": {
+                "type": activity_type,
+                "name": {
+                    to_locale(settings.LANGUAGE_CODE).replace("_", "-"): document.title
+                },
+            },
+            "id": f"uuid://{document.id}",
+            "objectType": "Activity",
+        }
+
+        self.statement = statement
+
+
+class XAPIVideoStatement(XAPIStatementMixin):
+    """Object managing statement for video objects."""
+
     def __init__(self, video, statement, jwt_token):
         """Compute a valid xapi satement.
 
@@ -44,13 +130,9 @@ def __init__(self, video, statement, jwt_token):
             A jwt token containing the context used to enrich the xapi statement
 
         """
-        user_id = (
-            jwt_token.payload["user"].get("id")
-            if jwt_token.payload.get("user")
-            else jwt_token.payload["session_id"]
-        )
+        user_id = self.get_user_id(jwt_token)
 
-        homepage = video.playlist.consumer_site.domain
+        homepage = self.get_homepage(video)
 
         activity_type = "https://w3id.org/xapi/video/activity-type/video"
 
@@ -102,10 +184,6 @@ def __init__(self, video, statement, jwt_token):
 
         self.statement = statement
 
-    def get_statement(self):
-        """Return the enriched statement."""
-        return self.statement
-
 
 class XAPI:
     """The XAPI object compute statements and send them to a LRS."""
diff --git a/src/backend/marsha/urls.py b/src/backend/marsha/urls.py
--- a/src/backend/marsha/urls.py
+++ b/src/backend/marsha/urls.py
@@ -69,7 +69,11 @@
         name="schema",
     ),
     path("api/", include(router.urls)),
-    path("xapi/", XAPIStatementView.as_view(), name="xapi"),
+    re_path(
+        r"^xapi/(?P<resource>video|document)/$",
+        XAPIStatementView.as_view(),
+        name="xapi",
+    ),
 ]
 
 if settings.DEBUG:


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
@@ -8,8 +8,7 @@
 import requests
 from rest_framework_simplejwt.tokens import AccessToken
 
-from ..factories import VideoFactory
-from ..models import Video
+from ..factories import DocumentFactory, VideoFactory
 
 
 # We don't enforce arguments documentation in tests
@@ -21,7 +20,7 @@ class XAPIStatementApiTest(TestCase):
 
     def test_xapi_statement_api_with_anonymous_user(self):
         """Anonymous users should not be allowed to send xAPI statement."""
-        response = self.client.post("/xapi/")
+        response = self.client.post("/xapi/video/")
         self.assertEqual(response.status_code, 401)
         content = json.loads(response.content)
         self.assertEqual(
@@ -38,11 +37,6 @@ def test_xapi_statement_with_no_lrs_configured(self):
             "id": "John Doe",
             "username": "john_doe",
         }
-        jwt_token.payload["course"] = {
-            "school_name": None,
-            "course_name": None,
-            "course_run": None,
-        }
 
         data = {
             "verb": {
@@ -55,7 +49,7 @@ def test_xapi_statement_with_no_lrs_configured(self):
         }
 
         response = self.client.post(
-            "/xapi/",
+            "/xapi/video/",
             HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
             data=json.dumps(data),
             content_type="application/json",
@@ -76,7 +70,7 @@ def test_xapi_statement_api_with_invalid_payload(self):
         data = {"foo": "bar"}
 
         response = self.client.post(
-            "/xapi/",
+            "/xapi/video/",
             HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
             data=json.dumps(data),
             content_type="application/json",
@@ -92,15 +86,10 @@ def test_xapi_statement_api_with_invalid_payload(self):
             },
         )
 
-    @mock.patch("marsha.core.api.Video.objects.get", side_effect=Video.DoesNotExist)
-    def test_xapi_statement_with_invalid_video(self, video_model_mock):
+    def test_xapi_statement_with_invalid_video(self):
         """The video in the JWT Token does not exist in our database."""
-        video = VideoFactory(
-            playlist__consumer_site__lrs_url="http://lrs.com/data/xAPI",
-            playlist__consumer_site__lrs_auth_token="Basic ThisIsABasicAuth",
-        )
         jwt_token = AccessToken()
-        jwt_token.payload["resource_id"] = str(video.id)
+        jwt_token.payload["resource_id"] = str(uuid.uuid4())
         jwt_token.payload["roles"] = ["student"]
 
         data = {
@@ -114,7 +103,7 @@ def test_xapi_statement_with_invalid_video(self, video_model_mock):
         }
 
         response = self.client.post(
-            "/xapi/",
+            "/xapi/video/",
             HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
             data=json.dumps(data),
             content_type="application/json",
@@ -122,11 +111,8 @@ def test_xapi_statement_with_invalid_video(self, video_model_mock):
 
         self.assertEqual(response.status_code, 404)
 
-    @mock.patch("marsha.core.api.Video.objects.get")
     @mock.patch("marsha.core.api.XAPI")
-    def test_xapi_statement_with_request_error_to_lrs(
-        self, xapi_mock, video_model_mock
-    ):
+    def test_xapi_statement_with_request_error_to_lrs(self, xapi_mock):
         """Sending a request to the LRS fails. The response should reflect this failure."""
         video = VideoFactory(
             playlist__consumer_site__lrs_url="http://lrs.com/data/xAPI",
@@ -139,11 +125,6 @@ def test_xapi_statement_with_request_error_to_lrs(
             "id": "John Doe",
             "username": "john_doe",
         }
-        jwt_token.payload["course"] = {
-            "school_name": None,
-            "course_name": None,
-            "course_run": None,
-        }
 
         data = {
             "verb": {
@@ -160,12 +141,11 @@ def test_xapi_statement_with_request_error_to_lrs(
         mock_response.status_code.return_value = 400
 
         exception = requests.exceptions.HTTPError(response=mock_response)
-        video_model_mock.return_value = video
         xapi_instance = xapi_mock.return_value
         xapi_instance.send.side_effect = exception
 
         response = self.client.post(
-            "/xapi/",
+            "/xapi/video/",
             HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
             data=json.dumps(data),
             content_type="application/json",
@@ -176,8 +156,7 @@ def test_xapi_statement_with_request_error_to_lrs(
             response.json().get("status"), "Impossible to send xAPI request to LRS."
         )
 
-    @mock.patch("marsha.core.api.Video.objects.get")
-    def test_xapi_statement_with_request_to_lrs_successful(self, video_model_mock):
+    def test_xapi_statement_with_request_to_lrs_successful(self):
         """Successful request should return a 204 status code."""
         video = VideoFactory(
             playlist__consumer_site__lrs_url="http://lrs.com/data/xAPI",
@@ -190,11 +169,6 @@ def test_xapi_statement_with_request_to_lrs_successful(self, video_model_mock):
             "id": "John Doe",
             "username": "john_doe",
         }
-        jwt_token.payload["course"] = {
-            "school_name": None,
-            "course_name": None,
-            "course_run": None,
-        }
 
         data = {
             "verb": {
@@ -206,11 +180,9 @@ def test_xapi_statement_with_request_to_lrs_successful(self, video_model_mock):
             },
         }
 
-        video_model_mock.return_value = video
-
         with mock.patch("marsha.core.api.XAPI.send", return_value=None):
             response = self.client.post(
-                "/xapi/",
+                "/xapi/video/",
                 HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
                 data=json.dumps(data),
                 content_type="application/json",
@@ -218,8 +190,7 @@ def test_xapi_statement_with_request_to_lrs_successful(self, video_model_mock):
 
         self.assertEqual(response.status_code, 204)
 
-    @mock.patch("marsha.core.api.Video.objects.get")
-    def test_xapi_statement_with_missing_user(self, video_model_mock):
+    def test_xapi_statement_with_missing_user(self):
         """Missing user parameter in JWT will fail request to LRS."""
         video = VideoFactory(
             playlist__consumer_site__lrs_url="http://lrs.com/data/xAPI",
@@ -229,11 +200,6 @@ def test_xapi_statement_with_missing_user(self, video_model_mock):
         jwt_token.payload["resource_id"] = str(video.id)
         jwt_token.payload["session_id"] = str(uuid.uuid4())
         jwt_token.payload["roles"] = ["student"]
-        jwt_token.payload["course"] = {
-            "school_name": None,
-            "course_name": None,
-            "course_run": None,
-        }
 
         data = {
             "verb": {
@@ -245,10 +211,47 @@ def test_xapi_statement_with_missing_user(self, video_model_mock):
             },
         }
 
-        video_model_mock.return_value = video
         with mock.patch("marsha.core.api.XAPI.send", return_value=None):
             response = self.client.post(
-                "/xapi/",
+                "/xapi/video/",
+                HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
+                data=json.dumps(data),
+                content_type="application/json",
+            )
+
+        self.assertEqual(response.status_code, 204)
+
+    def test_xapi_statement_document_resource(self):
+        """Successful request for a document should return a 204."""
+        document = DocumentFactory(
+            playlist__consumer_site__lrs_url="http://lrs.com/data/xAPI",
+            playlist__consumer_site__lrs_auth_token="Basic ThisIsABasicAuth",
+        )
+        jwt_token = AccessToken()
+        session_id = str(uuid.uuid4())
+        jwt_token.payload["resource_id"] = str(document.id)
+        jwt_token.payload["session_id"] = session_id
+        jwt_token.payload["roles"] = ["student"]
+        jwt_token.payload["user"] = {
+            "id": "John Doe",
+            "username": "john_doe",
+        }
+
+        data = {
+            "verb": {
+                "id": "http://id.tincanapi.com/verb/downloaded",
+                "display": {"en-US": "downloaded"},
+            },
+            "context": {
+                "extensions": {
+                    "https://w3id.org/xapi/video/extensions/session-id": session_id
+                }
+            },
+        }
+
+        with mock.patch("marsha.core.api.XAPI.send", return_value=None):
+            response = self.client.post(
+                "/xapi/document/",
                 HTTP_AUTHORIZATION=f"Bearer {jwt_token}",
                 data=json.dumps(data),
                 content_type="application/json",
diff --git a/src/backend/marsha/core/tests/test_xapi.py b/src/backend/marsha/core/tests/test_xapi.py
--- a/src/backend/marsha/core/tests/test_xapi.py
+++ b/src/backend/marsha/core/tests/test_xapi.py
@@ -6,12 +6,18 @@
 from rest_framework_simplejwt.tokens import AccessToken
 
 from ..defaults import RAW, RUNNING
-from ..factories import VideoFactory
-from ..xapi import XAPI, XAPIStatement, requests
+from ..factories import DocumentFactory, VideoFactory
+from ..xapi import (
+    XAPI,
+    XAPIDocumentStatement,
+    XAPIVideoStatement,
+    get_xapi_statement,
+    requests,
+)
 
 
-class XAPIStatmentTest(TestCase):
-    """Test the XAPIStatement class."""
+class XAPIVideoStatmentTest(TestCase):
+    """Test the XAPIVideoStatement class."""
 
     def test_xapi_statement_missing_user(self):
         """Missing lti user should fallback on session_id."""
@@ -48,7 +54,7 @@ def test_xapi_statement_missing_user(self):
             "id": "17dfcd44-b3e0-403d-ab96-e3ef7da616d4",
         }
 
-        xapi_statement = XAPIStatement(video, base_statement, jwt_token)
+        xapi_statement = XAPIVideoStatement(video, base_statement, jwt_token)
         statement = xapi_statement.get_statement()
 
         self.assertIsNotNone(statement["timestamp"])
@@ -135,7 +141,7 @@ def test_xapi_statement_enrich_statement(self):
             "id": "17dfcd44-b3e0-403d-ab96-e3ef7da616d4",
         }
 
-        xapi_statement = XAPIStatement(video, base_statement, jwt_token)
+        xapi_statement = XAPIVideoStatement(video, base_statement, jwt_token)
         statement = xapi_statement.get_statement()
 
         self.assertIsNotNone(statement["timestamp"])
@@ -223,7 +229,7 @@ def test_xapi_statement_live_video(self):
             "id": "17dfcd44-b3e0-403d-ab96-e3ef7da616d4",
         }
 
-        xapi_statement = XAPIStatement(video, base_statement, jwt_token)
+        xapi_statement = XAPIVideoStatement(video, base_statement, jwt_token)
         statement = xapi_statement.get_statement()
 
         self.assertIsNotNone(statement["timestamp"])
@@ -307,7 +313,7 @@ def test_xapi_statement_missing_context_id(self):
             "id": "17dfcd44-b3e0-403d-ab96-e3ef7da616d4",
         }
 
-        xapi_statement = XAPIStatement(video, base_statement, jwt_token)
+        xapi_statement = XAPIVideoStatement(video, base_statement, jwt_token)
         statement = xapi_statement.get_statement()
 
         self.assertIsNotNone(statement["timestamp"])
@@ -349,6 +355,225 @@ def test_xapi_statement_missing_context_id(self):
         self.assertEqual(statement["result"], base_statement["result"])
 
 
+class XAPIDocumentStatementTest(TestCase):
+    """Test the XAPIDocumentStatement class."""
+
+    @override_settings(LANGUAGE_CODE="en-us")
+    def test_xapi_statement_enrich_statement(self):
+        """XAPI statement sent by the front application should be enriched."""
+        document = DocumentFactory(
+            id="68333c45-4b8c-4018-a195-5d5e1706b838",
+            playlist__consumer_site__domain="example.com",
+            title="test document xapi",
+        )
+
+        jwt_token = AccessToken()
+        jwt_token.payload["user"] = {"id": "b2584aa405540758db2a6278521b6478"}
+        jwt_token.payload["session_id"] = "326c0689-48c1-493e-8d2d-9fb0c289de7f"
+        jwt_token.payload["context_id"] = "course-v1:ufr+mathematics+0001"
+
+        base_statement = {
+            "context": {
+                "extensions": {
+                    "https://w3id.org/xapi/video/extensions/session-id": "a6151456-18b7-"
+                    "43b4-8452-2037fed588df"
+                }
+            },
+            "verb": {
+                "display": {"en-US": "downloaded"},
+                "id": "http://id.tincanapi.com/verb/downloaded",
+            },
+            "id": "17dfcd44-b3e0-403d-ab96-e3ef7da616d4",
+        }
+
+        xapi_statement = XAPIDocumentStatement(document, base_statement, jwt_token)
+        statement = xapi_statement.get_statement()
+
+        self.assertIsNotNone(statement["timestamp"])
+        self.assertEqual(
+            statement["actor"],
+            {
+                "objectType": "Agent",
+                "account": {
+                    "name": "b2584aa405540758db2a6278521b6478",
+                    "homePage": "http://example.com",
+                },
+            },
+        )
+        self.assertEqual(
+            statement["object"],
+            {
+                "definition": {
+                    "type": "http://id.tincanapi.com/activitytype/document",
+                    "name": {"en-US": "test document xapi"},
+                },
+                "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838",
+                "objectType": "Activity",
+            },
+        )
+        self.assertEqual(
+            statement["context"],
+            {
+                "extensions": {
+                    "https://w3id.org/xapi/video/extensions/session-id": "a6151456-18b7-"
+                    "43b4-8452-2037fed588df"
+                },
+                "contextActivities": {
+                    "parent": [
+                        {
+                            "id": "course-v1:ufr+mathematics+0001",
+                            "objectType": "Activity",
+                            "definition": {
+                                "type": "http://adlnet.gov/expapi/activities/course"
+                            },
+                        }
+                    ],
+                },
+            },
+        )
+        self.assertEqual(statement["verb"], base_statement["verb"])
+        self.assertEqual(statement["id"], base_statement["id"])
+
+    @override_settings(LANGUAGE_CODE="en-us")
+    def test_xapi_statement_missing_context_id(self):
+        """Parent contextActivities should be missing without context_id."""
+        document = DocumentFactory(
+            id="68333c45-4b8c-4018-a195-5d5e1706b838",
+            playlist__consumer_site__domain="example.com",
+            title="test document xapi",
+        )
+
+        jwt_token = AccessToken()
+        jwt_token.payload["user"] = {"id": "b2584aa405540758db2a6278521b6478"}
+        jwt_token.payload["session_id"] = "326c0689-48c1-493e-8d2d-9fb0c289de7f"
+
+        base_statement = {
+            "context": {
+                "extensions": {
+                    "https://w3id.org/xapi/video/extensions/session-id": "a6151456-18b7-"
+                    "43b4-8452-2037fed588df"
+                }
+            },
+            "verb": {
+                "display": {"en-US": "downloaded"},
+                "id": "http://id.tincanapi.com/verb/downloaded",
+            },
+            "id": "17dfcd44-b3e0-403d-ab96-e3ef7da616d4",
+        }
+
+        xapi_statement = XAPIDocumentStatement(document, base_statement, jwt_token)
+        statement = xapi_statement.get_statement()
+
+        self.assertIsNotNone(statement["timestamp"])
+        self.assertEqual(
+            statement["actor"],
+            {
+                "objectType": "Agent",
+                "account": {
+                    "name": "b2584aa405540758db2a6278521b6478",
+                    "homePage": "http://example.com",
+                },
+            },
+        )
+        self.assertEqual(
+            statement["object"],
+            {
+                "definition": {
+                    "type": "http://id.tincanapi.com/activitytype/document",
+                    "name": {"en-US": "test document xapi"},
+                },
+                "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838",
+                "objectType": "Activity",
+            },
+        )
+        self.assertEqual(
+            statement["context"],
+            {
+                "extensions": {
+                    "https://w3id.org/xapi/video/extensions/session-id": "a6151456-18b7-"
+                    "43b4-8452-2037fed588df"
+                },
+            },
+        )
+        self.assertEqual(statement["verb"], base_statement["verb"])
+        self.assertEqual(statement["id"], base_statement["id"])
+
+    @override_settings(LANGUAGE_CODE="en-us")
+    def test_xapi_statement_missing_user_id(self):
+        """Missing lti user should fallback on session_id."""
+        document = DocumentFactory(
+            id="68333c45-4b8c-4018-a195-5d5e1706b838",
+            playlist__consumer_site__domain="example.com",
+            title="test document xapi",
+        )
+
+        jwt_token = AccessToken()
+        jwt_token.payload["session_id"] = "326c0689-48c1-493e-8d2d-9fb0c289de7f"
+        jwt_token.payload["context_id"] = "course-v1:ufr+mathematics+0001"
+
+        base_statement = {
+            "context": {
+                "extensions": {
+                    "https://w3id.org/xapi/video/extensions/session-id": "a6151456-18b7-"
+                    "43b4-8452-2037fed588df"
+                }
+            },
+            "verb": {
+                "display": {"en-US": "downloaded"},
+                "id": "http://id.tincanapi.com/verb/downloaded",
+            },
+            "id": "17dfcd44-b3e0-403d-ab96-e3ef7da616d4",
+        }
+
+        xapi_statement = XAPIDocumentStatement(document, base_statement, jwt_token)
+        statement = xapi_statement.get_statement()
+
+        self.assertIsNotNone(statement["timestamp"])
+        self.assertEqual(
+            statement["actor"],
+            {
+                "objectType": "Agent",
+                "account": {
+                    "name": "326c0689-48c1-493e-8d2d-9fb0c289de7f",
+                    "homePage": "http://example.com",
+                },
+            },
+        )
+        self.assertEqual(
+            statement["object"],
+            {
+                "definition": {
+                    "type": "http://id.tincanapi.com/activitytype/document",
+                    "name": {"en-US": "test document xapi"},
+                },
+                "id": "uuid://68333c45-4b8c-4018-a195-5d5e1706b838",
+                "objectType": "Activity",
+            },
+        )
+        self.assertEqual(
+            statement["context"],
+            {
+                "extensions": {
+                    "https://w3id.org/xapi/video/extensions/session-id": "a6151456-18b7-"
+                    "43b4-8452-2037fed588df"
+                },
+                "contextActivities": {
+                    "parent": [
+                        {
+                            "id": "course-v1:ufr+mathematics+0001",
+                            "objectType": "Activity",
+                            "definition": {
+                                "type": "http://adlnet.gov/expapi/activities/course"
+                            },
+                        }
+                    ],
+                },
+            },
+        )
+        self.assertEqual(statement["verb"], base_statement["verb"])
+        self.assertEqual(statement["id"], base_statement["id"])
+
+
 class XAPITest(TestCase):
     """Test the xapi module."""
 
@@ -381,3 +606,22 @@ def test_xapi_enrich_and_send_statement(self, mock_requests_post):
             },
         )
         self.assertEqual(kwargs["json"], statement)
+
+
+class GetXapiStatementTest(TestCase):
+    """Test get_xapi_statement function."""
+
+    def test_get_xapi_statement_with_video(self):
+        """With video parameter must return XAPIVideoStatement."""
+        statement_class = get_xapi_statement("video")
+        self.assertEqual(statement_class, XAPIVideoStatement)
+
+    def test_get_xapi_statement_with_document(self):
+        """With document parameter must return XAPIDocumentStatement."""
+        statement_class = get_xapi_statement("document")
+        self.assertEqual(statement_class, XAPIDocumentStatement)
+
+    def test_get_xapi_statement_with_unknown_resource(self):
+        """With unknown resource must throw an exception."""
+        with self.assertRaises(NotImplementedError):
+            get_xapi_statement("unknown")