Repository: openfun/marsha

Issue:
Add automatic LTI configuration via XML
## Feature Request

**Is your feature request related to a problem or unsupported use case? Please describe.**
Our documentation for Moodle admin configuration is very good and appreciated by IT admins. But they suggested we should implement automatic configuration.

**Describe the solution you'd like**
In Moodle, external LTI tools can be configured by providing a link with the configuration as XML.

The XML configuration can include a link to an icon so we should add a 32x32 icon to our static files and include it in the configuration.


Discussion:
[Representing LTI links in an IMS Common Cartridge](https://www.imsglobal.org/spec/lti/v1p3/#cclinks )

Resolving patch:
diff --git a/src/backend/marsha/core/views.py b/src/backend/marsha/core/views.py
--- a/src/backend/marsha/core/views.py
+++ b/src/backend/marsha/core/views.py
@@ -593,6 +593,48 @@ class DocumentView(BaseLTIView):
     serializer_class = DocumentSerializer
 
 
+@method_decorator(csrf_exempt, name="dispatch")
+@method_decorator(xframe_options_exempt, name="dispatch")
+class LTIConfigView(TemplateResponseMixin, View):
+    """LTI view called to configure Marsha LTI provider."""
+
+    template_name = "core/lti_config.xml"
+    content_type = "text/xml; charset=utf-8"
+
+    # pylint: disable=unused-argument
+    def get(self, request, *args, **kwargs):
+        """Respond to GET request.
+
+        Parameters
+        ----------
+        request : Request
+            passed by Django
+        args : list
+            positional extra arguments
+        kwargs : dictionary
+            keyword extra arguments
+
+        Returns
+        -------
+        XML
+            generated from applying the data to the template
+
+        """
+        return self.render_to_response(
+            {
+                "code": settings.LTI_CONFIG_TITLE.lower()
+                if settings.LTI_CONFIG_TITLE
+                else None,
+                "contact_email": settings.LTI_CONFIG_CONTACT_EMAIL,
+                "description": settings.LTI_CONFIG_DESCRIPTION,
+                "host": request.get_host(),
+                "icon": settings.LTI_CONFIG_ICON,
+                "title": settings.LTI_CONFIG_TITLE,
+                "url": settings.LTI_CONFIG_URL,
+            }
+        )
+
+
 @method_decorator(csrf_exempt, name="dispatch")
 @method_decorator(xframe_options_exempt, name="dispatch")
 class LTISelectView(TemplateResponseMixin, View):
diff --git a/src/backend/marsha/settings.py b/src/backend/marsha/settings.py
--- a/src/backend/marsha/settings.py
+++ b/src/backend/marsha/settings.py
@@ -291,6 +291,15 @@ class Base(Configuration):
     AWS_MEDIALIVE_INPUT_WAITER_DELAY = values.PositiveIntegerValue(5)
     AWS_MEDIALIVE_INPUT_WAITER_MAX_ATTEMPTS = values.PositiveIntegerValue(84)
 
+    # LTI Config
+    LTI_CONFIG_TITLE = values.Value("Marsha")
+    LTI_CONFIG_DESCRIPTION = values.Value(
+        "An LTI first, opensource and extensible Learning Content Management System"
+    )
+    LTI_CONFIG_ICON = values.Value("marsha_32x32_blue.png")
+    LTI_CONFIG_URL = values.Value()
+    LTI_CONFIG_CONTACT_EMAIL = values.Value()
+
     # BBB
     BBB_ENABLED = values.BooleanValue(False)
     BBB_API_ENDPOINT = values.Value()
diff --git a/src/backend/marsha/urls.py b/src/backend/marsha/urls.py
--- a/src/backend/marsha/urls.py
+++ b/src/backend/marsha/urls.py
@@ -27,6 +27,7 @@
 )
 from marsha.core.views import (
     DocumentView,
+    LTIConfigView,
     LTIRespondView,
     LTISelectView,
     RemindersCancelView,
@@ -65,6 +66,7 @@
     # Admin
     path(f"{admin_site.name}/", admin_site.urls),
     # LTI
+    path("lti/config.xml", LTIConfigView.as_view(), name="config_lti_view"),
     path("lti/select/", LTISelectView.as_view(), name="select_lti_view"),
     path("lti/respond/", LTIRespondView.as_view(), name="respond_lti_view"),
     path("lti/videos/<uuid:uuid>", VideoView.as_view(), name="video_lti_view"),


Test patch:
diff --git a/src/backend/marsha/core/tests/test_views_lti_config.py b/src/backend/marsha/core/tests/test_views_lti_config.py
new file mode 100644
--- /dev/null
+++ b/src/backend/marsha/core/tests/test_views_lti_config.py
@@ -0,0 +1,255 @@
+"""Test LTI xml configuration views in the ``core`` app of the Marsha project."""
+from django.test import TestCase, override_settings
+
+import xmltodict
+
+
+# We don't enforce arguments documentation in tests
+# pylint: disable=unused-argument
+
+
+class LTIConfigViewTestCase(TestCase):
+    """Test LTI configuration view."""
+
+    maxDiff = None
+
+    @override_settings(LTI_CONFIG_TITLE="Marsha")
+    @override_settings(LTI_CONFIG_DESCRIPTION="Open source LTI resource provider")
+    @override_settings(LTI_CONFIG_ICON="marsha-icon.png")
+    @override_settings(LTI_CONFIG_CONTACT_EMAIL="contact@example.com")
+    @override_settings(LTI_CONFIG_URL="https://example.com")
+    def test_views_lti_config_strings(self):
+        """Validate that xml is correctly rendered."""
+        response = self.client.get("/lti/config.xml")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.context_data,
+            {
+                "code": "marsha",
+                "contact_email": "contact@example.com",
+                "description": "Open source LTI resource provider",
+                "host": "testserver",
+                "icon": "marsha-icon.png",
+                "title": "Marsha",
+                "url": "https://example.com",
+            },
+        )
+        self.assertEqual(response["Content-Type"], "text/xml; charset=utf-8")
+        self.assertEqual(
+            xmltodict.parse(response.content),
+            xmltodict.parse(
+                """<?xml version="1.0" encoding="UTF-8"?>
+            <cartridge_basiclti_link xmlns="http://www.imsglobal.org/xsd/imslticc_v1p0"
+                xmlns:blti = "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"
+                xmlns:lticm ="http://www.imsglobal.org/xsd/imslticm_v1p0"
+                xmlns:lticp ="http://www.imsglobal.org/xsd/imslticp_v1p0"
+                xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
+                xsi:schemaLocation = "http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd
+    http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0.xsd
+    http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd
+    http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd">
+                <blti:title>Marsha</blti:title>
+                <blti:description>Open source LTI resource provider</blti:description>
+                <blti:launch_url>http://testserver</blti:launch_url>
+                <blti:secure_launch_url>https://testserver</blti:secure_launch_url>
+                <blti:icon>http://testserver/static/marsha-icon.png</blti:icon>
+                <blti:secure_icon>https://testserver/static/marsha-icon.png</blti:secure_icon>
+                <blti:vendor>
+                    <lticp:code>marsha</lticp:code>
+                    <lticp:name>Marsha</lticp:name>
+                    <lticp:description>Open source LTI resource provider</lticp:description>
+                    <lticp:url>https://example.com</lticp:url>
+                    <lticp:contact>
+                        <lticp:email>contact@example.com</lticp:email>
+                    </lticp:contact>
+                </blti:vendor>
+                <cartridge_bundle identifierref="BLTI001_Bundle"/>
+                <cartridge_icon identifierref="BLTI001_Icon"/>
+            </cartridge_basiclti_link>
+                        """  # noqa: E501 pylint: disable=line-too-long
+            ),
+        )
+
+    @override_settings(LTI_CONFIG_TITLE="")
+    @override_settings(LTI_CONFIG_DESCRIPTION="")
+    @override_settings(LTI_CONFIG_ICON="")
+    @override_settings(LTI_CONFIG_CONTACT_EMAIL="")
+    @override_settings(LTI_CONFIG_URL="")
+    def test_views_lti_config_empty_strings(self):
+        """Validate that xml is correctly rendered with empty settings."""
+        response = self.client.get("/lti/config.xml")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.context_data,
+            {
+                "code": None,
+                "contact_email": "",
+                "description": "",
+                "host": "testserver",
+                "icon": "",
+                "title": "",
+                "url": "",
+            },
+        )
+        self.assertEqual(response["Content-Type"], "text/xml; charset=utf-8")
+        self.assertEqual(
+            xmltodict.parse(response.content),
+            xmltodict.parse(
+                """<?xml version="1.0" encoding="UTF-8"?>
+            <cartridge_basiclti_link xmlns="http://www.imsglobal.org/xsd/imslticc_v1p0"
+                xmlns:blti = "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"
+                xmlns:lticm ="http://www.imsglobal.org/xsd/imslticm_v1p0"
+                xmlns:lticp ="http://www.imsglobal.org/xsd/imslticp_v1p0"
+                xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
+                xsi:schemaLocation = "http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd
+    http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0.xsd
+    http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd
+    http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd">
+                <blti:launch_url>http://testserver</blti:launch_url>
+                <blti:secure_launch_url>https://testserver</blti:secure_launch_url>
+                <cartridge_bundle identifierref="BLTI001_Bundle"/>
+                <cartridge_icon identifierref="BLTI001_Icon"/>
+            </cartridge_basiclti_link>
+                        """  # noqa: E501 pylint: disable=line-too-long
+            ),
+        )
+
+    @override_settings(LTI_CONFIG_TITLE=None)
+    @override_settings(LTI_CONFIG_DESCRIPTION=None)
+    @override_settings(LTI_CONFIG_ICON=None)
+    @override_settings(LTI_CONFIG_CONTACT_EMAIL=None)
+    @override_settings(LTI_CONFIG_URL=None)
+    def test_views_lti_config_none(self):
+        """Validate that xml is correctly rendered without settings."""
+        response = self.client.get("/lti/config.xml")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.context_data,
+            {
+                "code": None,
+                "contact_email": None,
+                "description": None,
+                "host": "testserver",
+                "icon": None,
+                "title": None,
+                "url": None,
+            },
+        )
+        self.assertEqual(response["Content-Type"], "text/xml; charset=utf-8")
+        self.assertEqual(
+            xmltodict.parse(response.content),
+            xmltodict.parse(
+                """<?xml version="1.0" encoding="UTF-8"?>
+            <cartridge_basiclti_link xmlns="http://www.imsglobal.org/xsd/imslticc_v1p0"
+                xmlns:blti = "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"
+                xmlns:lticm ="http://www.imsglobal.org/xsd/imslticm_v1p0"
+                xmlns:lticp ="http://www.imsglobal.org/xsd/imslticp_v1p0"
+                xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
+                xsi:schemaLocation = "http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd
+    http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0.xsd
+    http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd
+    http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd">
+                <blti:launch_url>http://testserver</blti:launch_url>
+                <blti:secure_launch_url>https://testserver</blti:secure_launch_url>
+                <cartridge_bundle identifierref="BLTI001_Bundle"/>
+                <cartridge_icon identifierref="BLTI001_Icon"/>
+            </cartridge_basiclti_link>
+                        """  # noqa: E501 pylint: disable=line-too-long
+            ),
+        )
+
+    @override_settings(LTI_CONFIG_TITLE=None)
+    @override_settings(LTI_CONFIG_DESCRIPTION=None)
+    @override_settings(LTI_CONFIG_ICON=None)
+    @override_settings(LTI_CONFIG_CONTACT_EMAIL=None)
+    @override_settings(LTI_CONFIG_URL="https://example.com")
+    def test_views_lti_config_url_only(self):
+        """Validate that xml is correctly rendered without settings."""
+        response = self.client.get("/lti/config.xml")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.context_data,
+            {
+                "code": None,
+                "contact_email": None,
+                "description": None,
+                "host": "testserver",
+                "icon": None,
+                "title": None,
+                "url": "https://example.com",
+            },
+        )
+        self.assertEqual(response["Content-Type"], "text/xml; charset=utf-8")
+        self.assertEqual(
+            xmltodict.parse(response.content),
+            xmltodict.parse(
+                """<?xml version="1.0" encoding="UTF-8"?>
+            <cartridge_basiclti_link xmlns="http://www.imsglobal.org/xsd/imslticc_v1p0"
+                xmlns:blti = "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"
+                xmlns:lticm ="http://www.imsglobal.org/xsd/imslticm_v1p0"
+                xmlns:lticp ="http://www.imsglobal.org/xsd/imslticp_v1p0"
+                xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
+                xsi:schemaLocation = "http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd
+    http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0.xsd
+    http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd
+    http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd">
+                <blti:launch_url>http://testserver</blti:launch_url>
+                <blti:secure_launch_url>https://testserver</blti:secure_launch_url>
+                <blti:vendor>
+                    <lticp:url>https://example.com</lticp:url>
+                </blti:vendor>
+                <cartridge_bundle identifierref="BLTI001_Bundle"/>
+                <cartridge_icon identifierref="BLTI001_Icon"/>
+            </cartridge_basiclti_link>
+                        """  # noqa: E501 pylint: disable=line-too-long
+            ),
+        )
+
+    def test_views_lti_config_default(self):
+        """Validate that xml is correctly rendered with default settings."""
+        response = self.client.get("/lti/config.xml")
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(
+            response.context_data,
+            {
+                "code": "marsha",
+                "contact_email": None,
+                "description": "An LTI first, opensource and extensible "
+                "Learning Content Management System",
+                "host": "testserver",
+                "icon": "marsha_32x32_blue.png",
+                "title": "Marsha",
+                "url": None,
+            },
+        )
+        self.assertEqual(response["Content-Type"], "text/xml; charset=utf-8")
+        self.assertEqual(
+            xmltodict.parse(response.content),
+            xmltodict.parse(
+                """<?xml version="1.0" encoding="UTF-8"?>
+            <cartridge_basiclti_link xmlns="http://www.imsglobal.org/xsd/imslticc_v1p0"
+                xmlns:blti = "http://www.imsglobal.org/xsd/imsbasiclti_v1p0"
+                xmlns:lticm ="http://www.imsglobal.org/xsd/imslticm_v1p0"
+                xmlns:lticp ="http://www.imsglobal.org/xsd/imslticp_v1p0"
+                xmlns:xsi = "http://www.w3.org/2001/XMLSchema-instance"
+                xsi:schemaLocation = "http://www.imsglobal.org/xsd/imslticc_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticc_v1p0.xsd
+    http://www.imsglobal.org/xsd/imsbasiclti_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imsbasiclti_v1p0.xsd
+    http://www.imsglobal.org/xsd/imslticm_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticm_v1p0.xsd
+    http://www.imsglobal.org/xsd/imslticp_v1p0 http://www.imsglobal.org/xsd/lti/ltiv1p0/imslticp_v1p0.xsd">
+                <blti:title>Marsha</blti:title>
+                <blti:description>An LTI first, opensource and extensible Learning Content Management System</blti:description>
+                <blti:launch_url>http://testserver</blti:launch_url>
+                <blti:secure_launch_url>https://testserver</blti:secure_launch_url>
+                <blti:icon>http://testserver/static/marsha_32x32_blue.png</blti:icon>
+                <blti:secure_icon>https://testserver/static/marsha_32x32_blue.png</blti:secure_icon>
+                <blti:vendor>
+                    <lticp:code>marsha</lticp:code>
+                    <lticp:name>Marsha</lticp:name>
+                    <lticp:description>An LTI first, opensource and extensible Learning Content Management System</lticp:description>
+                </blti:vendor>
+                <cartridge_bundle identifierref="BLTI001_Bundle"/>
+                <cartridge_icon identifierref="BLTI001_Icon"/>
+            </cartridge_basiclti_link>
+                        """  # noqa: E501 pylint: disable=line-too-long
+            ),
+        )