Пример #1
0
    def get_passport(self):
        """Find and return the passport targeted by the LTI request or raise an LTIException."""
        consumer_key = self.request.POST.get("oauth_consumer_key", None)

        if not consumer_key:
            raise LTIException("An oauth consumer key is required.")

        # find a passport related to the oauth consumer key
        try:
            return LTIPassport.objects.get(oauth_consumer_key=consumer_key,
                                           is_enabled=True)
        except LTIPassport.DoesNotExist as err:
            raise LTIException(
                "Could not find a valid passport for this oauth consumer key: {:s}."
                .format(consumer_key)) from err
Пример #2
0
def valid_lti_request(user_payload, request):
    username = user_payload.get(settings.LTI_PERSON_SOURCED_ID_FIELD, None)
    email = user_payload.get(settings.LTI_EMAIL_FIELD, None)
    canvas_course_id = user_payload.get(settings.LTI_CANVAS_COURSE_ID_FIELD,
                                        None)
    first_name = user_payload.get(settings.LTI_FIRST_NAME, None)
    last_name = user_payload.get(settings.LTI_LAST_NAME, None)

    if username:
        try:
            user_obj = User.objects.get(username=username)
        except User.DoesNotExist:
            password = ''.join(
                random.sample(string.ascii_letters,
                              RANDOM_PASSWORD_DEFAULT_LENGTH))
            user_obj = User.objects.create_user(username=username,
                                                email=email,
                                                password=password,
                                                first_name=first_name,
                                                last_name=last_name)
        user_obj.backend = 'django.contrib.auth.backends.ModelBackend'
        login(request, user_obj)
    else:
        #handle no username from LTI launch
        raise LTIException(
            "No username supplied in the launch, you should check your provider and/or settings."
        )

    url = reverse('home')
    if canvas_course_id:
        url = reverse('courses', kwargs={'course_id': canvas_course_id})

    return url
Пример #3
0
def valid_lti_request(user_payload, request):
    logger.info(valid_lti_request.__name__)
    username = user_payload.get("custom_canvas_user_login_id", None)
    email = user_payload.get("lis_person_contact_email_primary", None)
    canvas_course_id = user_payload.get("custom_canvas_course_id", None)
    first_name = user_payload.get("lis_person_name_given", None)
    last_name = user_payload.get("lis_person_name_family", None)

    if username:
        try:
            user_obj = User.objects.get(username=username)
        except User.DoesNotExist:
            password = ''.join(
                random.sample(string.ascii_letters,
                              RANDOM_PASSWORD_DEFAULT_LENGTH))
            user_obj = User.objects.create_user(username=username,
                                                email=email,
                                                password=password,
                                                first_name=first_name,
                                                last_name=last_name)
        user_obj.backend = 'django.contrib.auth.backends.ModelBackend'
        login(request, user_obj)
        request.session['course_id'] = canvas_course_id
    else:
        # handle no username from LTI launch
        raise LTIException(
            "No username supplied in the launch, you should check your provider and/or settings."
        )

    url = reverse('home')

    return url
Пример #4
0
 def test_dispatch_lti_failure(self, mock_validate_request):
     """ Test LTI verification failure """
     mock_validate_request.side_effect = LTIException("Unknown request type")
     response = self.client.post(
         "/lti/lti_initializer/",
         {
             "lis_person_contact_email_primary": "*****@*****.**",
             "custom_canvas_course_id": "327",
             "context_title": "test title",
             "ext_roles": "urn:something/something-else/Instructor,urn:something/something-else/Student"
         }
     )
     self.assertContains(response, "Your session has expired. Please, relaunch the tool via your canvas course.")
Пример #5
0
    def test_it_caches_a_failed_verification_result(self, launch_verifier,
                                                    pylti):
        pylti.common.verify_request_common.side_effect = LTIException()

        # Even if verify_lti_launch_request() is called multiple times, the
        # actual verification is done only once per request.
        with pytest.raises(LTIOAuthError):
            launch_verifier.verify()
        with pytest.raises(LTIOAuthError):
            launch_verifier.verify()
        with pytest.raises(LTIOAuthError):
            launch_verifier.verify()

        assert pylti.common.verify_request_common.call_count == 1
Пример #6
0
    def verify(self, request):
        """
        Verify if LTI request is valid, validation
        depends on arguments

        :raises: LTIException
        """
        if self.request_type == 'session':
            self._verify_session(request)
        elif self.request_type == 'initial':
            self._verify_request(request)
        elif self.request_type == 'any':
            self._verify_any(request)
        else:
            raise LTIException('Unknown request type')

        return True
Пример #7
0
    def _validate_role(self):
        """
        Check that user is in accepted/specified role

        :exception: LTIException if user is not in roles
        """
        if self.role_type != u'any':
            if self.role_type in LTI_ROLES:
                role_list = LTI_ROLES[self.role_type]

                # find the intersection of the roles
                roles = set(role_list) & set(self.user_roles())
                if len(roles) < 1:
                    raise LTIRoleException('Not authorized.')
            else:
                raise LTIException("Unknown role {}.".format(self.role_type))

        return True
Пример #8
0
    def verify(self):
        """Verify the LTI request.

        Raises
        ------
        LTIException
            Raised if request validation fails
        ImproperlyConfigured
            Raised if BYPASS_LTI_VERIFICATION is True but we are not in DEBUG mode

        Returns
        -------
        string
            It returns the consumer site related to the passport used in the LTI launch
            request if it is valid.
            If the BYPASS_LTI_VERIFICATION and DEBUG settings are True, it creates and return a
            consumer site with the consumer site domain passed in the LTI request.

        """
        if self._consumer_site:
            return True

        if not self.context_id:
            raise LTIException("A context ID is required.")

        request_domain = self.request_domain

        if settings.BYPASS_LTI_VERIFICATION:
            if not settings.DEBUG:
                raise ImproperlyConfigured(
                    "Bypassing LTI verification only works in DEBUG mode."
                )
            if not request_domain:
                raise LTIException(
                    "You must provide an http referer in your LTI launch request."
                )
            self._consumer_site, _created = ConsumerSite.objects.get_or_create(
                domain=request_domain, defaults={"name": request_domain}
            )
            return True

        passport = self.get_passport()
        consumers = {
            str(passport.oauth_consumer_key): {"secret": str(passport.shared_secret)}
        }

        # The LTI signature is computed using the url of the LTI launch request. But when Marsha
        # is behind a TLS termination proxy, the url as seen by Django is changed and starts with
        # "http". We need to revert this so that the signature we calculate matches the one
        # calculated by our LTI consumer.
        # Note that this is normally done in pylti's "verify_request_common" method but it does
        # not support WSGI normalized headers so let's do it ourselves.
        url = build_absolute_uri_behind_proxy(self.request)

        # A call to the verification function should raise an LTIException but
        # we can further check that it returns True.
        if (
            verify_request_common(
                consumers,
                url,
                self.request.method,
                self.request.META,
                dict(self.request.POST.items()),
            )
            is not True
        ):
            raise LTIException("LTI verification failed.")

        consumer_site = passport.consumer_site or passport.playlist.consumer_site

        # Make sure we only accept requests from domains in which the "top parts" match
        # the URL for the consumer_site associated with the passport.
        # eg. sub.example.com & example.com for an example.com consumer site.
        # Also referer matching ALLOWED_HOSTS are accepted
        if (
            request_domain
            and request_domain != consumer_site.domain
            and not (
                request_domain.endswith(f".{consumer_site.domain}")
                or validate_host(request_domain, settings.ALLOWED_HOSTS)
            )
        ):
            raise LTIException(
                (
                    f"Host domain ({request_domain}) does not match registered passport "
                    f"({consumer_site.domain})."
                )
            )

        self._consumer_site = consumer_site
        return True
Пример #9
0
class DocumentLTIViewTestCase(TestCase):
    """Test case for the file LTI view."""

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_document_instructor_same_playlist(
        self, mock_get_consumer_site, mock_verify
    ):
        """Validate the format of the response returned by the view for an instructor request."""
        passport = ConsumerSiteLTIPassportFactory()
        document = DocumentFactory(
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            playlist__consumer_site=passport.consumer_site,
            upload_state=random.choice([s[0] for s in STATE_CHOICES]),
            uploaded_on="2019-09-24 07:24:40+00",
        )
        data = {
            "resource_link_id": document.lti_id,
            "context_id": document.playlist.lti_id,
            "roles": random.choice(["instructor", "administrator"]),
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "lis_person_sourcedid": "jane_doe",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post(f"/lti/documents/{document.pk}", data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(html.unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(document.id))
        self.assertEqual(
            jwt_token.payload["user"],
            {
                "email": None,
                "id": "56255f3807599c377bf0e5bf072359fd",
                "username": "******",
                "user_fullname": None,
            },
        )
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "fr_FR")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {"can_access_dashboard": True, "can_update": True},
        )
        self.assertEqual(context.get("state"), "success")
        self.assertIsNotNone(context.get("resource"))
        self.assertEqual(context.get("modelName"), "documents")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_document_instructor_other_playlist(
        self, mock_get_consumer_site, mock_verify
    ):
        """Validate the response returned by the view for an instructor from another playlist."""
        passport = ConsumerSiteLTIPassportFactory()
        document = DocumentFactory(
            playlist__consumer_site=passport.consumer_site,
            playlist__is_portable_to_playlist=True,
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            upload_state=random.choice([s[0] for s in STATE_CHOICES]),
            uploaded_on="2019-09-24 07:24:40+00",
        )
        other_playlist = PlaylistFactory(
            consumer_site=passport.consumer_site,
            lti_id="course-v1:amd+litterature+00003",
        )
        data = {
            "resource_link_id": document.lti_id,
            "context_id": other_playlist.lti_id,
            "roles": random.choice(["instructor", "administrator"]),
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "lis_person_sourcedid": "jane_doe",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post(f"/lti/documents/{document.pk}", data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(html.unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(document.id))
        self.assertEqual(
            jwt_token.payload["user"],
            {
                "email": None,
                "id": "56255f3807599c377bf0e5bf072359fd",
                "username": "******",
                "user_fullname": None,
            },
        )
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "fr_FR")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {"can_access_dashboard": True, "can_update": False},
        )

        self.assertEqual(context.get("state"), "success")
        self.assertIsNotNone(context.get("resource"))
        self.assertEqual(context.get("modelName"), "documents")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_document_student_with_video(
        self, mock_get_consumer_site, mock_verify
    ):
        """Validate the format of the response returned by the view for a student request."""
        passport = ConsumerSiteLTIPassportFactory()
        document = DocumentFactory(
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            playlist__consumer_site=passport.consumer_site,
            upload_state=random.choice([s[0] for s in STATE_CHOICES]),
            uploaded_on="2019-09-24 07:24:40+00",
        )
        data = {
            "resource_link_id": document.lti_id,
            "context_id": document.playlist.lti_id,
            "roles": "student",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "lis_person_sourcedid": "jane_doe",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post(f"/lti/documents/{document.pk}", data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(html.unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(document.id))
        self.assertEqual(
            jwt_token.payload["user"],
            {
                "email": None,
                "id": "56255f3807599c377bf0e5bf072359fd",
                "username": "******",
                "user_fullname": None,
            },
        )
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "fr_FR")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {"can_access_dashboard": False, "can_update": False},
        )

        self.assertEqual(context.get("state"), "success")
        self.assertIsNotNone(context.get("resource"))
        self.assertEqual(context.get("modelName"), "documents")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_document_student_no_video(
        self, mock_get_consumer_site, mock_verify
    ):
        """Validate the response returned for a student request when there is no file."""
        passport = ConsumerSiteLTIPassportFactory()
        data = {
            "resource_link_id": "example.com-123",
            "context_id": "course-v1:ufr+mathematics+00001",
            "roles": "student",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "lis_person_sourcedid": "jane_doe",
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post(f"/lti/documents/{uuid.uuid4()}", data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(html.unescape(match.group(1)))
        self.assertEqual(context.get("state"), "success")
        self.assertIsNone(context.get("resource"))
        self.assertEqual(context.get("modelName"), "documents")

        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_document_instructor_no_video(
        self, mock_get_consumer_site, mock_verify
    ):
        """Validate the response returned for an instructor request when there is no file."""
        passport = ConsumerSiteLTIPassportFactory()
        data = {
            "resource_link_id": "example.com-123",
            "context_id": "course-v1:ufr+mathematics+00001",
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "lis_person_sourcedid": "jane_doe",
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post(f"/lti/documents/{uuid.uuid4()}", data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(html.unescape(match.group(1)))
        self.assertIsNotNone(context.get("jwt"))
        self.assertEqual(context.get("state"), "success")
        self.assertIsNotNone(context.get("resource"))
        self.assertEqual(context.get("modelName"), "documents")

        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(Logger, "warning")
    @mock.patch.object(LTI, "verify", side_effect=LTIException("lti error"))
    def test_views_lti_document_post_error(self, mock_verify, mock_logger):
        """Validate the response returned in case of an LTI exception."""
        role = random.choice(["instructor", "student"])
        data = {"resource_link_id": "123", "roles": role, "context_id": "abc"}
        response = self.client.post(f"/lti/documents/{uuid.uuid4()}", data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        mock_logger.assert_called_once_with("lti error")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(html.unescape(match.group(1)))
        self.assertEqual(context.get("state"), "error")
        self.assertIsNone(context.get("resource"))
Пример #10
0
class VideoLTIViewTestCase(TestCase):
    """Test the video view in the ``core`` app of the Marsha project."""
    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_post_instructor(self, mock_get_consumer_site,
                                             mock_verify):
        """Validate the format of the response returned by the view for an instructor request."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            playlist__consumer_site=passport.consumer_site,
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content)

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["user_id"], data["user_id"])
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "fr_FR")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {
                "can_access_dashboard": True,
                "can_update": True
            },
        )
        self.assertDictEqual(
            jwt_token.payload["course"],
            {
                "school_name": "ufr",
                "course_name": "mathematics",
                "course_run": "00001"
            },
        )

        self.assertEqual(context.get("state"), "success")
        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": None,
                "is_ready_to_show": False,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": "pending",
                "timed_text_tracks": [],
                "thumbnail": None,
                "title": video.title,
                "urls": None,
            },
        )
        self.assertEqual(context.get("modelName"), "videos")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_post_administrator(self, mock_get_consumer_site,
                                                mock_verify):
        """Validate the format of the response returned by the view for an admin request."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            playlist__consumer_site=passport.consumer_site,
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "administrator",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content)

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["user_id"], data["user_id"])
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "fr_FR")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {
                "can_access_dashboard": True,
                "can_update": True
            },
        )
        self.assertDictEqual(
            jwt_token.payload["course"],
            {
                "school_name": "ufr",
                "course_name": "mathematics",
                "course_run": "00001"
            },
        )

        self.assertEqual(context.get("state"), "success")
        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": None,
                "is_ready_to_show": False,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": "pending",
                "timed_text_tracks": [],
                "thumbnail": None,
                "title": video.title,
                "urls": None,
            },
        )
        self.assertEqual(context.get("modelName"), "videos")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_read_other_playlist(self, mock_get_consumer_site,
                                                 mock_verify):
        """A video from another portable playlist should have "can_update" set to False."""
        passport = ConsumerSiteLTIPassportFactory(
            consumer_site__domain="example.com")
        video = VideoFactory(
            playlist__is_portable_to_playlist=True,
            playlist__is_portable_to_consumer_site=True,
            upload_state="ready",
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": "another-playlist",
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content)

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(
            jwt_token.payload["permissions"],
            {
                "can_access_dashboard": True,
                "can_update": False
            },
        )
        self.assertEqual(context.get("state"), "success")
        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": None,
                "is_ready_to_show": False,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": "ready",
                "timed_text_tracks": [],
                "thumbnail": None,
                "title": video.title,
                "urls": None,
            },
        )
        self.assertEqual(context.get("modelName"), "videos")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_post_student_with_video(self,
                                                     mock_get_consumer_site,
                                                     mock_verify):
        """Validate the format of the response returned by the view for a student request."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            playlist__consumer_site=passport.consumer_site,
            upload_state="ready",
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "student",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content)

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["user_id"], data["user_id"])
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "en_US")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {
                "can_access_dashboard": False,
                "can_update": False
            },
        )
        self.assertDictEqual(
            jwt_token.payload["course"],
            {
                "school_name": "ufr",
                "course_name": "mathematics",
                "course_run": "00001"
            },
        )

        self.assertEqual(context.get("state"), "success")
        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": None,
                "is_ready_to_show": False,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": "ready",
                "timed_text_tracks": [],
                "thumbnail": None,
                "title": video.title,
                "urls": None,
            },
        )
        self.assertEqual(context.get("modelName"), "videos")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_without_user_id_parameter(self,
                                                       mock_get_consumer_site,
                                                       mock_verify):
        """Ensure JWT is created if user_id is missing in the LTI request."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(playlist__consumer_site=passport.consumer_site,
                             upload_state="ready")
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "context_title": "mathematics",
            "tool_consumer_instance_name": "ufr",
            "roles": "student",
            "oauth_consumer_key": passport.oauth_consumer_key,
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")
        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content)

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "en_US")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {
                "can_access_dashboard": False,
                "can_update": False
            },
        )
        self.assertDictEqual(
            jwt_token.payload["course"],
            {
                "school_name": "ufr",
                "course_name": "mathematics",
                "course_run": None
            },
        )
        self.assertEqual(context.get("modelName"), "videos")

        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_post_student_no_video(self,
                                                   mock_get_consumer_site,
                                                   mock_verify):
        """Validate the response returned for a student request when there is no video."""
        passport = ConsumerSiteLTIPassportFactory()
        data = {
            "resource_link_id": "example.com-123",
            "context_id": "course-v1:ufr+mathematics+00001",
            "roles": "student",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post("/lti/videos/{!s}".format(uuid.uuid4()),
                                    data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content)

        context = json.loads(unescape(match.group(1)))
        self.assertEqual(context.get("state"), "success")
        self.assertIsNone(context.get("resource"))
        self.assertEqual(context.get("modelName"), "videos")

        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(Logger, "warning")
    @mock.patch.object(LTI, "verify", side_effect=LTIException("lti error"))
    def test_views_lti_video_post_error(self, mock_verify, mock_logger):
        """Validate the response returned in case of an LTI exception."""
        role = random.choice(["instructor", "student"])
        data = {"resource_link_id": "123", "roles": role, "context_id": "abc"}
        response = self.client.post("/lti/videos/{!s}".format(uuid.uuid4()),
                                    data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        mock_logger.assert_called_once_with("lti error")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content)

        context = json.loads(unescape(match.group(1)))
        self.assertEqual(context.get("state"), "error")
        self.assertIsNone(context.get("resource"))
        self.assertEqual(context.get("modelName"), "videos")

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_with_timed_text(self, mock_get_consumer_site,
                                             mock_verify):
        """Make sure the LTI Video view functions when the Video has associated TimedTextTracks.

        NB: This is a bug-reproducing test case.
        The comprehensive test suite in test_api_video does not cover this case as it uses a JWT
        and therefore falls in another case when it comes to handling of video ids.
        """
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
        # Create a TimedTextTrack associated with the video to trigger the error
        TimedTextTrackFactory(video=video)

        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")
        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content)

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "en_US")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {
                "can_access_dashboard": True,
                "can_update": True
            },
        )
        self.assertEqual(context.get("modelName"), "videos")

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    @mock.patch.object(staticfiles_storage, "url")
    def test_views_lti_video_static_base_url_with_trailing_slash(
            self, mock_staticfiles_storage_url, mock_get_consumer_site,
            mock_verify):
        """Trailing slash is kept on static base url when present."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }
        mock_get_consumer_site.return_value = passport.consumer_site
        mock_staticfiles_storage_url.return_value = "/static/"

        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        self.assertContains(response,
                            '<meta name="public-path" value="/static/" />')

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    @mock.patch.object(staticfiles_storage, "url")
    def test_views_lti_video_static_base_url_without_trailing_slash(
            self, mock_staticfiles_storage_url, mock_get_consumer_site,
            mock_verify):
        """Trailing slash is added on static base url when missing."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }
        mock_get_consumer_site.return_value = passport.consumer_site
        mock_staticfiles_storage_url.return_value = "/static"

        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        self.assertContains(response,
                            '<meta name="public-path" value="/static/" />')
Пример #11
0
    def verify(self):
        """Verify the LTI request.

        Raises
        ------
        LTIException
            Exception raised if request validation fails

        Returns
        -------
        boolean
            True if the request is a valid LTI launch request

        """
        consumer_key = self.request.POST.get("oauth_consumer_key", None)
        consumer_site_name = self.consumer_site_name

        try:
            assert consumer_key
        except AssertionError:
            raise LTIException("An oauth consumer key is required.")

        try:
            assert self.context_id
        except AssertionError:
            raise LTIException("A context ID is required.")

        try:
            assert consumer_site_name
        except AssertionError:
            raise LTIException("A consumer site name is required.")

        # find a passport related to either the consumer site or the playlist
        try:
            lti_passport = LTIPassport.objects.get(
                Q(
                    oauth_consumer_key=consumer_key,
                    is_enabled=True,
                    consumer_site__name=consumer_site_name,
                )
                | Q(
                    oauth_consumer_key=consumer_key,
                    is_enabled=True,
                    playlist__consumer_site__name=consumer_site_name,
                ))
        except LTIPassport.DoesNotExist:
            raise LTIException(
                "Could not find a valid passport for this consumer site and this "
                "oauth consumer key: {:s}/{:s}.".format(
                    consumer_site_name, consumer_key))

        consumers = {
            str(lti_passport.oauth_consumer_key): {
                "secret": str(lti_passport.shared_secret)
            }
        }
        # A call to the verification function should raise an LTIException but
        # we can further check that it returns True.
        if (verify_request_common(
                consumers,
                self.request.build_absolute_uri(),
                self.request.method,
                self.request.META,
                dict(self.request.POST.items()),
        ) is not True):
            raise LTIException()

        self._is_verified = True
        return True
Пример #12
0
class VideoLTIViewTestCase(TestCase):
    """Test the video view in the ``core`` app of the Marsha project."""

    maxDiff = None

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    @override_settings(SENTRY_DSN="https://sentry.dsn")
    @override_settings(RELEASE="1.2.3")
    def test_views_lti_video_post_instructor(self, mock_get_consumer_site, mock_verify):
        """Validate the format of the response returned by the view for an instructor request."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            playlist__consumer_site=passport.consumer_site,
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["user_id"], data["user_id"])
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "fr_FR")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {"can_access_dashboard": True, "can_update": True},
        )
        self.assertDictEqual(
            jwt_token.payload["course"],
            {"school_name": "ufr", "course_name": "mathematics", "course_run": "00001"},
        )

        self.assertEqual(context.get("state"), "success")
        self.assertEqual(
            context.get("static"), {"svg": {"plyr": "/static/svg/plyr.svg"}}
        )
        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": None,
                "is_ready_to_show": False,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": "pending",
                "timed_text_tracks": [],
                "thumbnail": None,
                "title": video.title,
                "urls": None,
                "should_use_subtitle_as_transcript": False,
                "has_transcript": False,
            },
        )
        self.assertEqual(context.get("modelName"), "videos")
        self.assertEqual(context.get("sentry_dsn"), "https://sentry.dsn")
        self.assertEqual(context.get("environment"), "test")
        self.assertEqual(context.get("release"), "1.2.3")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    @override_settings(EXTERNAL_JAVASCRIPT_SCRIPTS=["https://example.com/test.js"])
    def test_views_lti_video_with_external_js_sources(
        self, mock_get_consumer_site, mock_verify
    ):
        """Validate external js sources are added in the lti template."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            playlist__consumer_site=passport.consumer_site,
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "administrator",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        self.assertContains(response, '<script src="https://example.com/test.js" >')

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_post_administrator(
        self, mock_get_consumer_site, mock_verify
    ):
        """Validate the format of the response returned by the view for an admin request."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            playlist__consumer_site=passport.consumer_site,
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "administrator",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["user_id"], data["user_id"])
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "fr_FR")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {"can_access_dashboard": True, "can_update": True},
        )
        self.assertDictEqual(
            jwt_token.payload["course"],
            {"school_name": "ufr", "course_name": "mathematics", "course_run": "00001"},
        )

        self.assertEqual(context.get("state"), "success")
        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": None,
                "is_ready_to_show": False,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": "pending",
                "timed_text_tracks": [],
                "thumbnail": None,
                "title": video.title,
                "urls": None,
                "should_use_subtitle_as_transcript": False,
                "has_transcript": False,
            },
        )
        self.assertEqual(context.get("modelName"), "videos")
        self.assertEqual(context.get("sentry_dsn"), None)
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_read_other_playlist(
        self, mock_get_consumer_site, mock_verify
    ):
        """A video from another portable playlist should have "can_update" set to False."""
        passport = ConsumerSiteLTIPassportFactory(consumer_site__domain="example.com")
        video = VideoFactory(
            id="301b5f4f-b9f1-4a5f-897d-f8f1bf22c396",
            playlist__is_portable_to_playlist=True,
            playlist__is_portable_to_consumer_site=True,
            playlist__title="playlist-003",
            upload_state=random.choice([s[0] for s in STATE_CHOICES]),
            uploaded_on="2019-09-24 07:24:40+00",
            resolutions=[144, 240, 480, 720, 1080],
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": "another-playlist",
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(
            jwt_token.payload["permissions"],
            {"can_access_dashboard": True, "can_update": False},
        )
        self.assertEqual(context.get("state"), "success")
        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": "1569309880",
                "is_ready_to_show": True,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": video.upload_state,
                "timed_text_tracks": [],
                "thumbnail": None,
                "title": video.title,
                "urls": {
                    "mp4": {
                        "144": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "mp4/1569309880_144.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-003_1569309880.mp4",
                        "240": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "mp4/1569309880_240.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-003_1569309880.mp4",
                        "480": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "mp4/1569309880_480.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-003_1569309880.mp4",
                        "720": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "mp4/1569309880_720.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-003_1569309880.mp4",
                        "1080": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "mp4/1569309880_1080.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-003_1569309880.mp4",
                    },
                    "thumbnails": {
                        "144": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "thumbnails/1569309880_144.0000000.jpg",
                        "240": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "thumbnails/1569309880_240.0000000.jpg",
                        "480": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "thumbnails/1569309880_480.0000000.jpg",
                        "720": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "thumbnails/1569309880_720.0000000.jpg",
                        "1080": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "thumbnails/1569309880_1080.0000000.jpg",
                    },
                    "manifests": {
                        "dash": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "cmaf/1569309880.mpd",
                        "hls": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                        "cmaf/1569309880.m3u8",
                    },
                    "previews": "https://abc.cloudfront.net/301b5f4f-b9f1-4a5f-897d-f8f1bf22c396/"
                    "previews/1569309880_100.jpg",
                },
                "should_use_subtitle_as_transcript": False,
                "has_transcript": False,
            },
        )
        self.assertEqual(context.get("modelName"), "videos")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_restristed_resolutions_list(
        self, mock_get_consumer_site, mock_verify
    ):
        """Urls list should contains resolutions from resolutions field."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(
            id="59c0fc7a-0f64-46c0-993f-bdf47ecd837f",
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            playlist__consumer_site=passport.consumer_site,
            playlist__title="playlist-002",
            upload_state=random.choice([s[0] for s in STATE_CHOICES]),
            uploaded_on="2019-09-24 07:24:40+00",
            resolutions=[144, 240, 480],
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "student",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["user_id"], data["user_id"])
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "en_US")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {"can_access_dashboard": False, "can_update": False},
        )
        self.assertDictEqual(
            jwt_token.payload["course"],
            {"school_name": "ufr", "course_name": "mathematics", "course_run": "00001"},
        )

        self.assertEqual(context.get("state"), "success")

        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": "1569309880",
                "is_ready_to_show": True,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": video.upload_state,
                "timed_text_tracks": [],
                "thumbnail": None,
                "title": video.title,
                "urls": {
                    "mp4": {
                        "144": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "mp4/1569309880_144.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-002_1569309880.mp4",
                        "240": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "mp4/1569309880_240.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-002_1569309880.mp4",
                        "480": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "mp4/1569309880_480.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-002_1569309880.mp4",
                    },
                    "thumbnails": {
                        "144": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "thumbnails/1569309880_144.0000000.jpg",
                        "240": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "thumbnails/1569309880_240.0000000.jpg",
                        "480": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "thumbnails/1569309880_480.0000000.jpg",
                    },
                    "manifests": {
                        "dash": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "cmaf/1569309880.mpd",
                        "hls": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "cmaf/1569309880.m3u8",
                    },
                    "previews": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                    "previews/1569309880_100.jpg",
                },
                "should_use_subtitle_as_transcript": False,
                "has_transcript": False,
            },
        )
        self.assertEqual(context.get("modelName"), "videos")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_post_student_with_video(
        self, mock_get_consumer_site, mock_verify
    ):
        """Validate the format of the response returned by the view for a student request."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(
            id="59c0fc7a-0f64-46c0-993f-bdf47ecd837f",
            playlist__lti_id="course-v1:ufr+mathematics+00001",
            playlist__consumer_site=passport.consumer_site,
            playlist__title="playlist-002",
            upload_state=random.choice([s[0] for s in STATE_CHOICES]),
            uploaded_on="2019-09-24 07:24:40+00",
            resolutions=[144, 240, 480, 720, 1080],
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "student",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["user_id"], data["user_id"])
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "en_US")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {"can_access_dashboard": False, "can_update": False},
        )
        self.assertDictEqual(
            jwt_token.payload["course"],
            {"school_name": "ufr", "course_name": "mathematics", "course_run": "00001"},
        )

        self.assertEqual(context.get("state"), "success")

        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": "1569309880",
                "is_ready_to_show": True,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": video.upload_state,
                "timed_text_tracks": [],
                "thumbnail": None,
                "title": video.title,
                "urls": {
                    "mp4": {
                        "144": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "mp4/1569309880_144.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-002_1569309880.mp4",
                        "240": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "mp4/1569309880_240.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-002_1569309880.mp4",
                        "480": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "mp4/1569309880_480.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-002_1569309880.mp4",
                        "720": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "mp4/1569309880_720.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-002_1569309880.mp4",
                        "1080": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "mp4/1569309880_1080.mp4?response-content-disposition=attachment%3B+"
                        "filename%3Dplaylist-002_1569309880.mp4",
                    },
                    "thumbnails": {
                        "144": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "thumbnails/1569309880_144.0000000.jpg",
                        "240": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "thumbnails/1569309880_240.0000000.jpg",
                        "480": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "thumbnails/1569309880_480.0000000.jpg",
                        "720": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "thumbnails/1569309880_720.0000000.jpg",
                        "1080": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "thumbnails/1569309880_1080.0000000.jpg",
                    },
                    "manifests": {
                        "dash": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "cmaf/1569309880.mpd",
                        "hls": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                        "cmaf/1569309880.m3u8",
                    },
                    "previews": "https://abc.cloudfront.net/59c0fc7a-0f64-46c0-993f-bdf47ecd837f/"
                    "previews/1569309880_100.jpg",
                },
                "should_use_subtitle_as_transcript": False,
                "has_transcript": False,
            },
        )
        self.assertEqual(context.get("modelName"), "videos")
        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_without_user_id_parameter(
        self, mock_get_consumer_site, mock_verify
    ):
        """Ensure JWT is created if user_id is missing in the LTI request."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(
            playlist__consumer_site=passport.consumer_site,
            upload_state=random.choice([s[0] for s in STATE_CHOICES]),
            uploaded_on="2019-09-24 07:24:40+00",
            resolutions=[144, 240, 480, 720, 1080],
        )
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "context_title": "mathematics",
            "tool_consumer_instance_name": "ufr",
            "roles": "student",
            "oauth_consumer_key": passport.oauth_consumer_key,
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")
        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "en_US")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {"can_access_dashboard": False, "can_update": False},
        )
        self.assertDictEqual(
            jwt_token.payload["course"],
            {"school_name": "ufr", "course_name": "mathematics", "course_run": None},
        )
        self.assertEqual(context.get("modelName"), "videos")

        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_post_student_no_video(
        self, mock_get_consumer_site, mock_verify
    ):
        """Validate the response returned for a student request when there is no video."""
        passport = ConsumerSiteLTIPassportFactory()
        data = {
            "resource_link_id": "example.com-123",
            "context_id": "course-v1:ufr+mathematics+00001",
            "roles": "student",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post("/lti/videos/{!s}".format(uuid.uuid4()), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))
        self.assertEqual(context.get("state"), "success")
        self.assertIsNone(context.get("resource"))
        self.assertEqual(context.get("modelName"), "videos")

        # Make sure we only go through LTI verification once as it is costly (getting passport +
        # signature)
        self.assertEqual(mock_verify.call_count, 1)

    @mock.patch.object(Logger, "warning")
    @mock.patch.object(LTI, "verify", side_effect=LTIException("lti error"))
    def test_views_lti_video_post_error(self, mock_verify, mock_logger):
        """Validate the response returned in case of an LTI exception."""
        role = random.choice(["instructor", "student"])
        data = {"resource_link_id": "123", "roles": role, "context_id": "abc"}
        response = self.client.post("/lti/videos/{!s}".format(uuid.uuid4()), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        mock_logger.assert_called_once_with("lti error")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))
        self.assertEqual(context.get("state"), "error")
        self.assertIsNone(context.get("resource"))
        self.assertEqual(context.get("modelName"), "videos")

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_with_timed_text(self, mock_get_consumer_site, mock_verify):
        """Make sure the LTI Video view functions when the Video has associated TimedTextTracks.

        NB: This is a bug-reproducing test case.
        The comprehensive test suite in test_api_video does not cover this case as it uses a JWT
        and therefore falls in another case when it comes to handling of video ids.
        """
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
        # Create a TimedTextTrack associated with the video to trigger the error
        TimedTextTrackFactory(video=video)

        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")
        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))
        jwt_token = AccessToken(context.get("jwt"))
        self.assertEqual(jwt_token.payload["resource_id"], str(video.id))
        self.assertEqual(jwt_token.payload["context_id"], data["context_id"])
        self.assertEqual(jwt_token.payload["roles"], [data["roles"]])
        self.assertEqual(jwt_token.payload["locale"], "en_US")
        self.assertEqual(
            jwt_token.payload["permissions"],
            {"can_access_dashboard": True, "can_update": True},
        )
        self.assertEqual(context.get("modelName"), "videos")

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    @override_settings(ABSOLUTE_STATIC_URL="/static/")
    def test_views_lti_video_static_base_url(self, mock_get_consumer_site, mock_verify):
        """Meta tag public-path should be the ABSOLUTE_STATIC_URL settings with js/ at the end."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }
        mock_get_consumer_site.return_value = passport.consumer_site

        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        self.assertContains(response, '<meta name="public-path" value="/static/js/" />')

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_has_transcript(self, mock_get_consumer_site, mock_verify):
        """Compute has_transcript when a transcript is uploaded."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
        # Create a TimedTextTrack associated with the video to trigger the error
        transcript = TimedTextTrackFactory(
            video=video,
            mode="ts",
            upload_state=READY,
            uploaded_on="2019-09-24 07:24:40+00",
        )

        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))

        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": None,
                "is_ready_to_show": False,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": "pending",
                "timed_text_tracks": [
                    {
                        "active_stamp": "1569309880",
                        "id": str(transcript.id),
                        "is_ready_to_show": True,
                        "mode": "ts",
                        "language": transcript.language,
                        "upload_state": "ready",
                        "url": "https://abc.cloudfront.net/{!s}/timedtext/"
                        "1569309880_{!s}_ts.vtt".format(video.id, transcript.language),
                        "video": str(video.id),
                    }
                ],
                "thumbnail": None,
                "title": video.title,
                "urls": None,
                "should_use_subtitle_as_transcript": False,
                "has_transcript": True,
            },
        )

    @mock.patch.object(LTI, "verify")
    @mock.patch.object(LTI, "get_consumer_site")
    def test_views_lti_video_has_transcript_false(
        self, mock_get_consumer_site, mock_verify
    ):
        """Compute has_transcript when a transcript is uploaded and not ready to be shown."""
        passport = ConsumerSiteLTIPassportFactory()
        video = VideoFactory(playlist__consumer_site=passport.consumer_site)
        # Create a TimedTextTrack associated with the video to trigger the error
        transcript = TimedTextTrackFactory(
            video=video, mode="ts", upload_state=PENDING,
        )

        data = {
            "resource_link_id": video.lti_id,
            "context_id": video.playlist.lti_id,
            "roles": "instructor",
            "oauth_consumer_key": passport.oauth_consumer_key,
            "user_id": "56255f3807599c377bf0e5bf072359fd",
            "launch_presentation_locale": "fr",
        }

        mock_get_consumer_site.return_value = passport.consumer_site
        response = self.client.post("/lti/videos/{!s}".format(video.pk), data)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "<html>")
        content = response.content.decode("utf-8")

        match = re.search(
            '<div id="marsha-frontend-data" data-context="(.*)">', content
        )

        context = json.loads(unescape(match.group(1)))

        self.assertEqual(
            context.get("resource"),
            {
                "active_stamp": None,
                "is_ready_to_show": False,
                "show_download": True,
                "description": video.description,
                "id": str(video.id),
                "upload_state": "pending",
                "timed_text_tracks": [
                    {
                        "active_stamp": None,
                        "id": str(transcript.id),
                        "is_ready_to_show": False,
                        "mode": "ts",
                        "language": transcript.language,
                        "upload_state": "pending",
                        "url": None,
                        "video": str(video.id),
                    }
                ],
                "thumbnail": None,
                "title": video.title,
                "urls": None,
                "should_use_subtitle_as_transcript": False,
                "has_transcript": False,
            },
        )
Пример #13
0
    def verify(self):
        """Verify the LTI request.

        Raises
        ------
        LTIException
            Raised if request validation fails
        ImproperlyConfigured
            Raised if BYPASS_LTI_VERIFICATION is True but we are not in DEBUG mode

        Returns
        -------
        string
            It returns the consumer site related to the passport used in the LTI launch
            request if it is valid.
            If the BYPASS_LTI_VERIFICATION and DEBUG settings are True, it creates and return a
            consumer site with the consumer site domain passed in the LTI request.

        """
        if settings.BYPASS_LTI_VERIFICATION:
            if not settings.DEBUG:
                raise ImproperlyConfigured(
                    "Bypassing LTI verification only works in DEBUG mode.")
            return ConsumerSite.objects.get_or_create(
                domain=self.consumer_site_domain,
                defaults={"name": self.consumer_site_domain},
            )[0]

        passport = self.get_passport()
        consumers = {
            str(passport.oauth_consumer_key): {
                "secret": str(passport.shared_secret)
            }
        }

        # The LTI signature is computed using the url of the LTI launch request. But when Marsha
        # is behind a TLS termination proxy, the url as seen by Django is changed and starts with
        # "http". We need to revert this so that the signature we calculate matches the one
        # calculated by our LTI consumer.
        # Note that this is normally done in pylti's "verify_request_common" method but it does
        # not support WSGI normalized headers so let's do it ourselves.
        url = self.request.build_absolute_uri()
        if self.request.META.get("HTTP_X_FORWARDED_PROTO", "http") == "https":
            url = url.replace("http:", "https:", 1)

        # A call to the verification function should raise an LTIException but
        # we can further check that it returns True.
        if (verify_request_common(
                consumers,
                url,
                self.request.method,
                self.request.META,
                dict(self.request.POST.items()),
        ) is not True):
            raise LTIException()

        consumer_site = passport.consumer_site or passport.playlist.consumer_site

        # Make sure we only accept requests from domains in which the "top parts" match
        # the URL for the consumer_site associated with the passport.
        # eg. sub.example.com & example.com for an example.com consumer site.
        domain_check = urlparse(self.request.META.get("HTTP_REFERER")).hostname
        if domain_check != consumer_site.domain and not domain_check.endswith(
                ".{:s}".format(consumer_site.domain)):
            raise LTIException(
                "Host domain does not match registered passport.")

        return consumer_site
Пример #14
0
    def get_or_create_video(self):
        """Get or create the video targeted by the LTI launch request.

        Create the playlist if it does not pre-exist (it can only happen with consumer site scope
        passports).

        Raises
        ------
        LTIException
            Exception raised if the request does not contain a context id (playlist).

        Returns
        -------
        core.models.video.Video
            The video instance targeted by the `resource_link_id` or None.

        """
        # Make sure LTI verification has run successfully. It raises an LTIException otherwise.
        consumer_site = self.verify()

        try:
            assert self.context_id
        except AssertionError:
            raise LTIException("A context ID is required.")

        # If the video already exist, retrieve it from database
        try:
            return Video.objects.get(
                lti_id=self.resource_link_id,
                playlist__lti_id=self.context_id,
                playlist__consumer_site=consumer_site,
            )
        except Video.DoesNotExist:
            # Look for a video with the same lti id from another playlist on the same
            # consumer site
            origin_video = (Video.objects.filter(
                lti_id=self.resource_link_id,
                playlist__consumer_site=consumer_site,
                playlist__is_portable_to_playlist=True,
                upload_state=READY,
            ).order_by("-uploaded_on").first())

            if origin_video is None:
                # Look for a video with the same lti id from the same playlist on another
                # consumer site that is portable to the current consumer site of the LTI request
                origin_video = (Video.objects.filter(
                    Q(playlist__is_portable_to_consumer_site=True)
                    | Q(playlist__consumer_site__in=consumer_site.
                        reachable_from.all()),
                    playlist__lti_id=self.context_id,
                    lti_id=self.resource_link_id,
                    upload_state=READY,
                ).order_by("-uploaded_on").first())

            if origin_video is None:
                # Look for a video with the same lti id from another playlist on another consumer
                # site that is portable to the current consumer site of the LTI request
                origin_video = (Video.objects.filter(
                    Q(playlist__is_portable_to_consumer_site=True)
                    | Q(playlist__consumer_site__in=consumer_site.
                        reachable_from.all()),
                    playlist__is_portable_to_playlist=True,
                    lti_id=self.resource_link_id,
                    upload_state=READY,
                ).order_by("-uploaded_on").first())

            # If we still didn't find any existing video, we will only create a new video if the
            # request comes from an instructor
            if origin_video is None and not self.is_instructor:
                return None

        # Creating the video...
        # - Get the playlist if it exists or create it
        playlist, _ = Playlist.objects.get_or_create(
            lti_id=self.context_id,
            consumer_site=consumer_site,
            defaults={
                "title":
                self.context_title,
                "is_portable_to_playlist":
                (origin_video.playlist.is_portable_to_playlist
                 if origin_video else True),
                "is_portable_to_consumer_site":
                (origin_video.playlist.is_portable_to_consumer_site
                 if origin_video else False),
            },
        )

        # Create the video, pointing to the file from the origin video if any
        if origin_video:
            return origin_video.duplicate(playlist)

        return Video.objects.create(
            lti_id=self.resource_link_id,
            playlist=playlist,
            upload_state=PENDING,
            title=self.resource_link_title,
        )