From edec2a68282a2a1fc2b2036e1989d60688fa6b19 Mon Sep 17 00:00:00 2001
From: Giovanni Cimolin da Silva <giovannicimolin@gmail.com>
Date: Thu, 18 Mar 2021 18:25:41 -0300
Subject: [PATCH] feat: improve content presentation and add support for
 displaying multiple content items (#148)

* Improve content presentation and add multiple content display support

This commit:
* Improves LTI content presentation handling, and correctly displays LTI DL content in the LMS
* Adds support for presenting multiple DL content items on the same block

* Addressing review comments

* Nits

* Improve test and fix issue

[BD-24][TNL-8072]
---
 README.rst                                    |  20 +++
 lti_consumer/api.py                           |  54 +++++--
 lti_consumer/lti_1p3/deep_linking.py          |   4 +-
 .../lti_1p3/tests/test_deep_linking.py        |   2 +-
 lti_consumer/lti_xblock.py                    |   5 +-
 lti_consumer/plugin/views.py                  |   1 +
 .../html/lti-dl/render_dl_content.html        |  19 ++-
 .../html/lti-dl/render_lti_resource_link.html |   9 ++
 .../templatetags/get_dl_lti_launch_url.py     |  26 ++++
 .../plugin/test_views_lti_deep_linking.py     |  25 ++++
 lti_consumer/tests/unit/test_api.py           | 139 ++++++++++++------
 lti_consumer/tests/unit/test_lti_xblock.py    |   2 +-
 lti_consumer/utils.py                         |  12 ++
 13 files changed, 249 insertions(+), 69 deletions(-)
 create mode 100644 lti_consumer/templates/html/lti-dl/render_lti_resource_link.html
 create mode 100644 lti_consumer/templatetags/get_dl_lti_launch_url.py

diff --git a/README.rst b/README.rst
index abc5243..9181a43 100644
--- a/README.rst
+++ b/README.rst
@@ -222,6 +222,26 @@ To configure parameter processors add the following snippet to your Ansible vari
           - 'customer_package.lti_processors:team_and_cohort'
           - 'example_package.lti_processors:extra_lti_params'
 
+LTI Advantage Features
+======================
+
+This XBlock supports LTI 1.3 and the following LTI Avantage services:
+
+* Deep Linking (LTI-DL)
+* Assignments and Grades services (LTI-AGS)
+
+To enable LTI-AGS, your block needs two properties set: `has_score` and `weight`. Once that is configured, the
+service will be automatically enabled and the tool will be able to send scores back to the platform through
+a `LineItem`.
+
+To enable LTI-DL and its capabilities, you need to set the following feature flag:
+
+.. code:: yaml
+
+    FEATURES:
+        LTI_DEEP_LINKING_ENABLED: true
+
+
 Development
 ===========
 
diff --git a/lti_consumer/api.py b/lti_consumer/api.py
index 5490f35..4a83181 100644
--- a/lti_consumer/api.py
+++ b/lti_consumer/api.py
@@ -7,6 +7,7 @@ return plaintext to allow easy testing/mocking.
 from .exceptions import LtiError
 from .models import LtiConfiguration, LtiDlContentItem
 from .utils import (
+    get_lti_deeplinking_content_url,
     get_lms_lti_keyset_link,
     get_lms_lti_launch_link,
     get_lms_lti_access_token_link,
@@ -112,7 +113,7 @@ def get_lti_1p3_launch_info(config_id=None, block=None):
     }
 
 
-def get_lti_1p3_launch_start_url(config_id=None, block=None, deep_link_launch=False, hint=""):
+def get_lti_1p3_launch_start_url(config_id=None, block=None, deep_link_launch=False, dl_content_id=None, hint=""):
     """
     Computes and retrieves the LTI URL that starts the OIDC flow.
     """
@@ -122,18 +123,14 @@ def get_lti_1p3_launch_start_url(config_id=None, block=None, deep_link_launch=Fa
 
     # Change LTI hint depending on LTI launch type
     lti_hint = ""
+    # Case 1: Performs Deep Linking configuration flow. Triggered by staff users to
+    # configure tool options and select content to be presented.
     if deep_link_launch:
         lti_hint = "deep_linking_launch"
-    else:
-        # Check if there's any LTI DL content item
-        # This should only yield one result since we
-        # don't support multiple content items yet.
-        content_items = lti_config.ltidlcontentitem_set.filter(
-            content_type=LtiDlContentItem.LTI_RESOURCE_LINK,
-        ).only('id')
-
-        if content_items.count():
-            lti_hint = f"deep_linking_content_launch:{content_items.get().id}"
+    # Case 2: Perform a LTI Launch for `ltiResourceLink` content types, since they
+    # need to use the launch mechanism from the callback view.
+    elif dl_content_id:
+        lti_hint = f"deep_linking_content_launch:{dl_content_id}"
 
     # Prepare and return OIDC flow start url
     return lti_consumer.prepare_preflight_url(
@@ -143,6 +140,41 @@ def get_lti_1p3_launch_start_url(config_id=None, block=None, deep_link_launch=Fa
     )
 
 
+def get_lti_1p3_content_url(config_id=None, block=None, hint=""):
+    """
+    Computes and returns which URL the LTI consumer should launch to.
+
+    This can return:
+    1. A LTI Launch link if:
+        a. No deep linking is set
+        b. Deep Linking is configured, but a single ltiResourceLink was selected.
+    2. The Deep Linking content presentation URL if there's more than one
+       Lti DL content in the database.
+    """
+    # Retrieve LTI consumer
+    lti_config = _get_lti_config(config_id, block)
+
+    # List content items
+    content_items = lti_config.ltidlcontentitem_set.all()
+
+    # If there's no content items, return normal LTI launch URL
+    if not content_items.count():
+        return get_lti_1p3_launch_start_url(config_id, block, hint=hint)
+
+    # If there's a single `ltiResourceLink` content, return the launch
+    # url for that specif deep link
+    if content_items.count() == 1 and content_items.get().content_type == LtiDlContentItem.LTI_RESOURCE_LINK:
+        return get_lti_1p3_launch_start_url(
+            config_id,
+            block,
+            dl_content_id=content_items.get().id,
+            hint=hint,
+        )
+
+    # If there's more than one content item, return content presentation URL
+    return get_lti_deeplinking_content_url(lti_config.id)
+
+
 def get_deep_linking_data(deep_linking_id, config_id=None, block=None):
     """
     Retrieves deep linking attributes.
diff --git a/lti_consumer/lti_1p3/deep_linking.py b/lti_consumer/lti_1p3/deep_linking.py
index 9b3cb0d..d483e5b 100644
--- a/lti_consumer/lti_1p3/deep_linking.py
+++ b/lti_consumer/lti_1p3/deep_linking.py
@@ -52,8 +52,8 @@ class LtiDeepLinking:
                 "window",
                 "embed"
             ],
-            # Only accept a single item return from Deep Linking operation.
-            "accept_multiple": False,
+            # Accept multiple items on from Deep Linking responses.
+            "accept_multiple": True,
             # Automatically saves Content Items without asking to user
             "auto_create": True,
             # Other parameters
diff --git a/lti_consumer/lti_1p3/tests/test_deep_linking.py b/lti_consumer/lti_1p3/tests/test_deep_linking.py
index 5bfee8a..cf21c18 100644
--- a/lti_consumer/lti_1p3/tests/test_deep_linking.py
+++ b/lti_consumer/lti_1p3/tests/test_deep_linking.py
@@ -65,7 +65,7 @@ class TestLtiDeepLinking(TestCase):
                         'window',
                         'embed'
                     ],
-                    'accept_multiple': False,
+                    'accept_multiple': True,
                     'auto_create': True,
                     'title': '',
                     'text': '',
diff --git a/lti_consumer/lti_xblock.py b/lti_consumer/lti_xblock.py
index 58a9432..1d5985a 100644
--- a/lti_consumer/lti_xblock.py
+++ b/lti_consumer/lti_xblock.py
@@ -1420,12 +1420,11 @@ class LtiConsumerXBlock(StudioEditableXBlockMixin, XBlock):
         else:
             # Runtime import since this will only run in the
             # Open edX LMS/Studio environments.
-            from lti_consumer.api import get_lti_1p3_launch_start_url  # pylint: disable=import-outside-toplevel
+            from lti_consumer.api import get_lti_1p3_content_url  # pylint: disable=import-outside-toplevel
 
             # Retrieve and set LTI 1.3 Launch start URL
-            lti_block_launch_handler = get_lti_1p3_launch_start_url(
+            lti_block_launch_handler = get_lti_1p3_content_url(
                 block=self,
-                deep_link_launch=False,
                 hint=str(self.location)  # pylint: disable=no-member
             )
 
diff --git a/lti_consumer/plugin/views.py b/lti_consumer/plugin/views.py
index 53101b0..9b63416 100644
--- a/lti_consumer/plugin/views.py
+++ b/lti_consumer/plugin/views.py
@@ -259,6 +259,7 @@ def deep_linking_response_endpoint(request, lti_config_id=None):
 
 
 @require_http_methods(['GET'])
+@xframe_options_sameorigin
 def deep_linking_content_endpoint(request, lti_config_id=None):
     """
     Deep Linking endpoint for rendering Deep Linking Content Items.
diff --git a/lti_consumer/templates/html/lti-dl/render_dl_content.html b/lti_consumer/templates/html/lti-dl/render_dl_content.html
index 7e00c3b..0afa083 100644
--- a/lti_consumer/templates/html/lti-dl/render_dl_content.html
+++ b/lti_consumer/templates/html/lti-dl/render_dl_content.html
@@ -6,13 +6,18 @@
     </head>
     <body>
         {% for item in content_items %}
-            {% if item.content_type == 'link' %}
-                {% include "html/lti-dl/render_link.html" with item=item attrs=item.attributes %}
-            {% elif item.content_type == 'html' %}
-                {% include "html/lti-dl/render_html.html" with item=item attrs=item.attributes %}
-            {% elif item.content_type == 'image' %}
-                {% include "html/lti-dl/render_image.html" with item=item attrs=item.attributes %}
-            {% endif %}
+            <div>
+                {% if item.content_type == 'ltiResourceLink' %}
+                    {% include "html/lti-dl/render_lti_resource_link.html" with item=item attrs=item.attributes %}
+                {% elif item.content_type == 'link' %}
+                    {% include "html/lti-dl/render_link.html" with item=item attrs=item.attributes %}
+                {% elif item.content_type == 'html' %}
+                    {% include "html/lti-dl/render_html.html" with item=item attrs=item.attributes %}
+                {% elif item.content_type == 'image' %}
+                    {% include "html/lti-dl/render_image.html" with item=item attrs=item.attributes %}
+                {% endif %}
+            </div>
+            <br />
         {% endfor %}
     </body>
 </html>
diff --git a/lti_consumer/templates/html/lti-dl/render_lti_resource_link.html b/lti_consumer/templates/html/lti-dl/render_lti_resource_link.html
new file mode 100644
index 0000000..8e5b10a
--- /dev/null
+++ b/lti_consumer/templates/html/lti-dl/render_lti_resource_link.html
@@ -0,0 +1,9 @@
+{% load get_dl_lti_launch_url %}
+
+{% if attrs.title %}<h2>{{ attrs.title }}</h2>{% endif %}
+{% if attrs.text %}<p>{{ attrs.text }}</p>{% endif %}
+
+<a href="{{ item | get_dl_lti_launch_url }}" target="_blank">
+    Click here to launch the LTI content in a new tab
+    <i class="icon fa fa-external-link"></i>
+</a>
diff --git a/lti_consumer/templatetags/get_dl_lti_launch_url.py b/lti_consumer/templatetags/get_dl_lti_launch_url.py
new file mode 100644
index 0000000..1dd97c9
--- /dev/null
+++ b/lti_consumer/templatetags/get_dl_lti_launch_url.py
@@ -0,0 +1,26 @@
+"""
+Template tags and helper functions for sanitizing html.
+"""
+from django import template
+from lti_consumer.api import get_lti_1p3_launch_start_url
+
+
+register = template.Library()
+
+
+@register.filter()
+def get_dl_lti_launch_url(content_item):
+    """
+    Template tag to retrive the LTI launch URL for `ltiResourceLink` content.
+
+    This uses the LTI Consumer API, but hardcodes the `hint` parameter to the
+    block id (used when LTI launches are tied to XBlocks).
+
+    TODO: Refactor `hint` to use generic ID once LTI launches out of XBlocks are
+    supported.
+    """
+    return get_lti_1p3_launch_start_url(
+        config_id=content_item.lti_configuration.id,
+        dl_content_id=content_item.id,
+        hint=str(content_item.lti_configuration.location),
+    )
diff --git a/lti_consumer/tests/unit/plugin/test_views_lti_deep_linking.py b/lti_consumer/tests/unit/plugin/test_views_lti_deep_linking.py
index 364b258..894ef3f 100644
--- a/lti_consumer/tests/unit/plugin/test_views_lti_deep_linking.py
+++ b/lti_consumer/tests/unit/plugin/test_views_lti_deep_linking.py
@@ -604,3 +604,28 @@ class LtiDeepLinkingContentEndpointTestCase(LtiDeepLinkingTestCase):
 
         if test_data.get('height'):
             self.assertContains(resp, 'height="{}"'.format(test_data['height']))
+
+    @patch('lti_consumer.plugin.views.has_block_access', return_value=True)
+    def test_dl_content_multiple_lti_resource_links(self, has_block_access):  # pylint: disable=unused-argument
+        """
+        Test if multiple `ltiResourceLink` content types are successfully rendered.
+        """
+        content_items = []
+        for _ in range(3):
+            content_items.append(
+                LtiDlContentItem.objects.create(
+                    lti_configuration=self.lti_config,
+                    content_type=LtiDlContentItem.LTI_RESOURCE_LINK,
+                    attributes={}
+                )
+            )
+
+        resp = self.client.get(self.url)
+        self.assertEqual(resp.status_code, 200)
+
+        # Check that there's three LTI Resource links presented
+        for item in content_items:
+            self.assertContains(
+                resp,
+                f"lti_message_hint=deep_linking_content_launch%3A{item.id}"
+            )
diff --git a/lti_consumer/tests/unit/test_api.py b/lti_consumer/tests/unit/test_api.py
index 4a7b0e4..64af91f 100644
--- a/lti_consumer/tests/unit/test_api.py
+++ b/lti_consumer/tests/unit/test_api.py
@@ -8,6 +8,7 @@ from django.test.testcases import TestCase
 
 from lti_consumer.api import (
     _get_or_create_local_lti_config,
+    get_lti_1p3_content_url,
     get_deep_linking_data,
     get_lti_1p3_launch_info,
     get_lti_1p3_launch_start_url,
@@ -18,6 +19,45 @@ from lti_consumer.models import LtiConfiguration, LtiDlContentItem
 from lti_consumer.tests.unit.test_utils import make_xblock
 
 
+class Lti1P3TestCase(TestCase):
+    """
+    Reusable test case for testing LTI 1.3 configurations.
+    """
+    def setUp(self):
+        """
+        Set up an empty block configuration.
+        """
+        self.xblock = None
+        self.lti_config = None
+
+        return super().setUp()
+
+    def _setup_lti_block(self):
+        """
+        Set's up an LTI block that is used in some tests.
+        """
+        # Generate RSA and save exports
+        rsa_key = RSA.generate(1024)
+        public_key = rsa_key.publickey().export_key()
+
+        xblock_attributes = {
+            'lti_version': 'lti_1p3',
+            'lti_1p3_launch_url': 'http://tool.example/launch',
+            'lti_1p3_oidc_url': 'http://tool.example/oidc',
+            # We need to set the values below because they are not automatically
+            # generated until the user selects `lti_version == 'lti_1p3'` on the
+            # Studio configuration view.
+            'lti_1p3_tool_public_key': public_key,
+        }
+        self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, xblock_attributes)
+        # Set dummy location so that UsageKey lookup is valid
+        self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test'
+        # Create lti configuration
+        self.lti_config = LtiConfiguration.objects.create(
+            location=self.xblock.location
+        )
+
+
 class TestGetOrCreateLocalLtiConfiguration(TestCase):
     """
     Unit tests for _get_or_create_local_lti_config API method.
@@ -164,69 +204,44 @@ class TestGetLti1p3LaunchInfo(TestCase):
         block = Mock()
         block.location = 'block-v1:course+test+2020+type@problem+block@test'
         block.lti_version = LtiConfiguration.LTI_1P3
-        LtiConfiguration.objects.create(location=block.location)
+
+        # Create LTI Config and Deep linking object
+        lti_config = LtiConfiguration.objects.create(location=block.location)
+        LtiDlContentItem.objects.create(
+            lti_configuration=lti_config,
+            content_type=LtiDlContentItem.IMAGE,
+            attributes={"test": "this is a test attribute"}
+        )
 
         # Call API
         launch_info = get_lti_1p3_launch_info(block=block)
 
         # Retrieve created config and check full launch info data
         lti_config = LtiConfiguration.objects.get()
-        self.assertCountEqual(
+        self.assertEqual(
             launch_info,
             {
                 'client_id': lti_config.lti_1p3_client_id,
                 'keyset_url': 'https://example.com/api/lti_consumer/v1/public_keysets/{}'.format(
-                    lti_config.lti_1p3_client_id
+                    lti_config.location
                 ),
                 'deployment_id': '1',
                 'oidc_callback': 'https://example.com/api/lti_consumer/v1/launch/',
                 'token_url': 'https://example.com/api/lti_consumer/v1/token/{}'.format(
-                    lti_config.lti_1p3_client_id
+                    lti_config.location
                 ),
-                'deep_linking_launch_url': 'https://example.com',
-                'deep_linking_content_items': []
+                'deep_linking_launch_url': 'http://example.com',
+                'deep_linking_content_items': [{
+                    "test": "this is a test attribute",
+                }]
             }
         )
 
 
-class TestGetLti1p3LaunchUrl(TestCase):
+class TestGetLti1p3LaunchUrl(Lti1P3TestCase):
     """
     Unit tests for get_lti_1p3_launch_start_url API method.
     """
-    def setUp(self):
-        """
-        Set up an empty block configuration.
-        """
-        self.xblock = None
-        self.lti_config = None
-
-        return super().setUp()
-
-    def _setup_lti_block(self):
-        """
-        Set's up an LTI block that is used in some tests.
-        """
-        # Generate RSA and save exports
-        rsa_key = RSA.generate(1024)
-        public_key = rsa_key.publickey().export_key()
-
-        xblock_attributes = {
-            'lti_version': 'lti_1p3',
-            'lti_1p3_launch_url': 'http://tool.example/launch',
-            'lti_1p3_oidc_url': 'http://tool.example/oidc',
-            # We need to set the values below because they are not automatically
-            # generated until the user selects `lti_version == 'lti_1p3'` on the
-            # Studio configuration view.
-            'lti_1p3_tool_public_key': public_key,
-        }
-        self.xblock = make_xblock('lti_consumer', LtiConsumerXBlock, xblock_attributes)
-        # Set dummy location so that UsageKey lookup is valid
-        self.xblock.location = 'block-v1:course+test+2020+type@problem+block@test'
-        # Create lti configuration
-        self.lti_config = LtiConfiguration.objects.create(
-            location=self.xblock.location
-        )
-
     def test_no_parameters(self):
         """
         Check if the API creates a model if no object matching properties is found.
@@ -249,7 +264,21 @@ class TestGetLti1p3LaunchUrl(TestCase):
         launch_url = get_lti_1p3_launch_start_url(block=self.xblock, deep_link_launch=True)
         self.assertIn('lti_message_hint=deep_linking_launch', launch_url)
 
-    def test_lti_content_presentation(self):
+
+class TestGetLti1p3ContentUrl(Lti1P3TestCase):
+    """
+    Unit tests for get_lti_1p3_launch_start_url API method.
+    """
+    @patch("lti_consumer.api.get_lti_1p3_launch_start_url")
+    def test_lti_content_presentation(self, mock_get_launch_url):
+        """
+        Check if the correct LTI content presentation is returned on a normal LTI Launch.
+        """
+        mock_get_launch_url.return_value = 'test_url'
+        self._setup_lti_block()
+        self.assertEqual(get_lti_1p3_content_url(block=self.xblock), 'test_url')
+
+    def test_lti_content_presentation_single_link(self):
         """
         Check if the correct LTI content presentation is returned if a `ltiResourceLink`
         content type is present.
@@ -264,12 +293,34 @@ class TestGetLti1p3LaunchUrl(TestCase):
         )
 
         # Call API to retrieve content item URL
-        launch_url = get_lti_1p3_launch_start_url(block=self.xblock)
+        launch_url = get_lti_1p3_content_url(block=self.xblock)
         self.assertIn(
             # Checking for `deep_linking_content_launch:<content_item_id>`
             # URL Encoded `:` is `%3A`
             f'lti_message_hint=deep_linking_content_launch%3A{lti_content.id}',
-            launch_url
+            launch_url,
+        )
+
+    def test_lti_content_presentation_multiple_links(self):
+        """
+        Check if the correct LTI content presentation is returned if multiple LTI DL
+        content items are set up.
+        """
+        self._setup_lti_block()
+
+        # Create LTI DL content items
+        for _ in range(3):
+            LtiDlContentItem.objects.create(
+                lti_configuration=self.lti_config,
+                content_type=LtiDlContentItem.IMAGE,
+                attributes={},
+            )
+
+        # Call API to retrieve content item URL
+        self.assertIn(
+            # Checking for the content presentation URL
+            f"/api/lti_consumer/v1/lti/{self.lti_config.id}/lti-dl/content",
+            get_lti_1p3_content_url(block=self.xblock),
         )
 
 
diff --git a/lti_consumer/tests/unit/test_lti_xblock.py b/lti_consumer/tests/unit/test_lti_xblock.py
index 94841d2..0072c7a 100644
--- a/lti_consumer/tests/unit/test_lti_xblock.py
+++ b/lti_consumer/tests/unit/test_lti_xblock.py
@@ -1077,7 +1077,7 @@ class TestGetContext(TestLtiConsumerXBlock):
     """
 
     @ddt.data('lti_1p1', 'lti_1p3')
-    @patch('lti_consumer.api.get_lti_1p3_launch_start_url')
+    @patch('lti_consumer.api.get_lti_1p3_content_url')
     def test_context_keys(self, lti_version, lti_api_patch):
         """
         Test `_get_context_for_template` returns dict with correct keys
diff --git a/lti_consumer/utils.py b/lti_consumer/utils.py
index 5ddb016..33f62ee 100644
--- a/lti_consumer/utils.py
+++ b/lti_consumer/utils.py
@@ -102,3 +102,15 @@ def get_lti_deeplinking_response_url(lti_config_id):
         lms_base=get_lms_base(),
         lti_config_id=str(lti_config_id),
     )
+
+
+def get_lti_deeplinking_content_url(lti_config_id):
+    """
+    Return the LTI Deep Linking content presentation endpoint
+
+    :param lti_config_id: LTI configuration id
+    """
+    return "{lms_base}/api/lti_consumer/v1/lti/{lti_config_id}/lti-dl/content".format(
+        lms_base=get_lms_base(),
+        lti_config_id=str(lti_config_id),
+    )
-- 
GitLab