From 68bd822e223483d3aa85b86ab746b2917f0baabc Mon Sep 17 00:00:00 2001 From: Cristhian Garcia Date: Thu, 29 Feb 2024 16:33:51 -0500 Subject: [PATCH] feat: allow to embed superset dashboard in instructor dashboard --- manage.py | 2 +- platform_plugin_aspects/__init__.py | 9 +- platform_plugin_aspects/apps.py | 21 +++- .../extensions/__init__.py | 0 platform_plugin_aspects/extensions/filters.py | 64 ++++++++++ platform_plugin_aspects/models.py | 3 - platform_plugin_aspects/settings/__init__.py | 0 platform_plugin_aspects/settings/common.py | 23 ++++ .../settings/production.py | 19 +++ .../static/css/superset.css | 5 + .../static/html/superset.html | 25 ++++ .../static/js/embed_dashboard.js | 30 +++++ platform_plugin_aspects/static/js/superset.js | 36 ++++++ .../instructor_dashboard/aspects.html | 8 ++ .../platform_plugin_aspects/base.html | 26 ---- platform_plugin_aspects/tests/__init__.py | 0 platform_plugin_aspects/tests/test_filters.py | 46 +++++++ .../tests/test_settings.py | 57 +++++++++ platform_plugin_aspects/tests/test_utils.py | 102 +++++++++++++++ platform_plugin_aspects/urls.py | 10 -- platform_plugin_aspects/utils.py | 117 ++++++++++++++++++ requirements/base.in | 7 +- requirements/base.txt | 28 +++++ requirements/dev.txt | 42 ++++++- requirements/doc.txt | 49 +++++++- requirements/pip-tools.txt | 2 +- requirements/quality.txt | 40 ++++++ requirements/test.txt | 44 ++++++- setup.py | 8 ++ test_settings.py | 13 +- tests/test_models.py | 13 -- tox.ini | 12 +- 32 files changed, 786 insertions(+), 75 deletions(-) create mode 100644 platform_plugin_aspects/extensions/__init__.py create mode 100644 platform_plugin_aspects/extensions/filters.py delete mode 100644 platform_plugin_aspects/models.py create mode 100644 platform_plugin_aspects/settings/__init__.py create mode 100644 platform_plugin_aspects/settings/common.py create mode 100644 platform_plugin_aspects/settings/production.py create mode 100644 platform_plugin_aspects/static/css/superset.css create mode 100644 platform_plugin_aspects/static/html/superset.html create mode 100644 platform_plugin_aspects/static/js/embed_dashboard.js create mode 100644 platform_plugin_aspects/static/js/superset.js create mode 100644 platform_plugin_aspects/templates/instructor_dashboard/aspects.html delete mode 100644 platform_plugin_aspects/templates/platform_plugin_aspects/base.html create mode 100644 platform_plugin_aspects/tests/__init__.py create mode 100644 platform_plugin_aspects/tests/test_filters.py create mode 100644 platform_plugin_aspects/tests/test_settings.py create mode 100644 platform_plugin_aspects/tests/test_utils.py delete mode 100644 platform_plugin_aspects/urls.py create mode 100644 platform_plugin_aspects/utils.py delete mode 100644 tests/test_models.py diff --git a/manage.py b/manage.py index 4afa5aa..c4c1f40 100644 --- a/manage.py +++ b/manage.py @@ -18,7 +18,7 @@ # issue is really that Django is missing to avoid masking other # exceptions on Python 2. try: - import django # pylint: disable=unused-import, wrong-import-position + import django # pylint: disable=unused-import except ImportError as import_error: raise ImportError( "Couldn't import Django. Are you sure it's installed and " diff --git a/platform_plugin_aspects/__init__.py b/platform_plugin_aspects/__init__.py index 8d0a793..9b46ff1 100644 --- a/platform_plugin_aspects/__init__.py +++ b/platform_plugin_aspects/__init__.py @@ -1,5 +1,10 @@ """ -Aspects plugins for edx-platform +Aspects plugins for edx-platform. """ -__version__ = '0.1.0' +import os +from pathlib import Path + +__version__ = "0.1.0" + +ROOT_DIRECTORY = Path(os.path.dirname(os.path.abspath(__file__))) diff --git a/platform_plugin_aspects/apps.py b/platform_plugin_aspects/apps.py index 608a632..08c092b 100644 --- a/platform_plugin_aspects/apps.py +++ b/platform_plugin_aspects/apps.py @@ -7,7 +7,24 @@ class PlatformPluginAspectsConfig(AppConfig): """ - Configuration for the platform_plugin_aspects Django application. + Configuration for the aspects Django application. """ - name = 'platform_plugin_aspects' + name = "platform_plugin_aspects" + + plugin_app = { + "settings_config": { + "lms.djangoapp": { + "common": {"relative_path": "settings.common"}, + "production": {"relative_path": "settings.production"}, + }, + "cms.djangoapp": { + "common": {"relative_path": "settings.common"}, + "production": {"relative_path": "settings.production"}, + }, + }, + } + + def ready(self): + """Load modules of Aspects.""" + from platform_plugin_aspects.extensions import filters # pylint: disable=unused-import, import-outside-toplevel diff --git a/platform_plugin_aspects/extensions/__init__.py b/platform_plugin_aspects/extensions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/platform_plugin_aspects/extensions/filters.py b/platform_plugin_aspects/extensions/filters.py new file mode 100644 index 0000000..1ba6d23 --- /dev/null +++ b/platform_plugin_aspects/extensions/filters.py @@ -0,0 +1,64 @@ +""" +Open edX Filters needed for Aspects integration. +""" + +import pkg_resources +from django.conf import settings +from django.template import Context, Template +from openedx_filters import PipelineStep +from web_fragments.fragment import Fragment + +from platform_plugin_aspects.utils import generate_superset_context + +TEMPLATE_ABSOLUTE_PATH = "/instructor_dashboard/" +BLOCK_CATEGORY = "aspects" + +ASPECTS_SECURITY_FILTERS_FORMAT = [ + "org = '{course.org}'", + "course_name = '{course.display_name}'", + "course_run = '{course.id.run}'", +] + + +class AddSupersetTab(PipelineStep): + """Add superset tab to instructor dashboard.""" + + def run_filter( + self, context, template_name + ): # pylint: disable=arguments-differ, unused-argument + """Execute filter that modifies the instructor dashboard context. + Args: + context (dict): the context for the instructor dashboard. + _ (str): instructor dashboard template name. + """ + course = context["course"] + dashboard_uuid = settings.ASPECTS_INSTRUCTOR_DASHBOARD_UUID + extra_filters_format = settings.SUPERSET_EXTRA_FILTERS_FORMAT + + filters = ASPECTS_SECURITY_FILTERS_FORMAT + extra_filters_format + + context = generate_superset_context( + context, dashboard_uuid, filters + ) + + template = Template(self.resource_string("static/html/superset.html")) + html = template.render(Context(context)) + frag = Fragment(html) + frag.add_css(self.resource_string("static/css/superset.css")) + frag.add_javascript(self.resource_string("static/js/embed_dashboard.js")) + section_data = { + "fragment": frag, + "section_key": BLOCK_CATEGORY, + "section_display_name": BLOCK_CATEGORY.title(), + "course_id": str(course.id), + "template_path_prefix": TEMPLATE_ABSOLUTE_PATH, + } + context["sections"].append(section_data) + return { + "context": context, + } + + def resource_string(self, path): + """Handy helper for getting resources from our kit.""" + data = pkg_resources.resource_string("platform_plugin_aspects", path) + return data.decode("utf8") diff --git a/platform_plugin_aspects/models.py b/platform_plugin_aspects/models.py deleted file mode 100644 index 0c230db..0000000 --- a/platform_plugin_aspects/models.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Database models for platform_plugin_aspects. -""" diff --git a/platform_plugin_aspects/settings/__init__.py b/platform_plugin_aspects/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/platform_plugin_aspects/settings/common.py b/platform_plugin_aspects/settings/common.py new file mode 100644 index 0000000..fe95bb2 --- /dev/null +++ b/platform_plugin_aspects/settings/common.py @@ -0,0 +1,23 @@ +""" +Common Django settings for eox_hooks project. +For more information on this file, see +https://docs.djangoproject.com/en/2.22/topics/settings/ +For the full list of settings and their values, see +https://docs.djangoproject.com/en/2.22/ref/settings/ +""" +from platform_plugin_aspects import ROOT_DIRECTORY + + +def plugin_settings(settings): + """ + Set of plugin settings used by the Open Edx platform. + More info: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst + """ + settings.MAKO_TEMPLATE_DIRS_BASE.append(ROOT_DIRECTORY / "templates") + settings.SUPERSET_CONFIG = { + "url": "http://superset.local.overhang.io:8088", + "username": "superset", + "password": "superset", + } + settings.ASPECTS_INSTRUCTOR_DASHBOARD_UUID = "1d6bf904-f53f-47fd-b1c9-6cd7e284d286" + settings.SUPERSET_EXTRA_FILTERS_FORMAT = [] diff --git a/platform_plugin_aspects/settings/production.py b/platform_plugin_aspects/settings/production.py new file mode 100644 index 0000000..25a72a9 --- /dev/null +++ b/platform_plugin_aspects/settings/production.py @@ -0,0 +1,19 @@ +""" +Production Django settings for Aspects project. +""" + + +def plugin_settings(settings): + """ + Set of plugin settings used by the Open Edx platform. + More info: https://github.com/edx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst + """ + settings.SUPERSET_CONFIG = getattr(settings, "ENV_TOKENS", {}).get( + "SUPERSET_CONFIG", settings.SUPERSET_CONFIG + ) + settings.ASPECTS_INSTRUCTOR_DASHBOARD_UUID = getattr(settings, "ENV_TOKENS", {}).get( + "ASPECTS_INSTRUCTOR_DASHBOARD_UUID", settings.ASPECTS_INSTRUCTOR_DASHBOARD_UUID + ) + settings.SUPERSET_EXTRA_FILTERS_FORMAT = getattr(settings, "ENV_TOKENS", {}).get( + "SUPERSET_EXTRA_FILTERS_FORMAT", settings.SUPERSET_EXTRA_FILTERS_FORMAT + ) diff --git a/platform_plugin_aspects/static/css/superset.css b/platform_plugin_aspects/static/css/superset.css new file mode 100644 index 0000000..e549ea4 --- /dev/null +++ b/platform_plugin_aspects/static/css/superset.css @@ -0,0 +1,5 @@ +.superset-embedded-container > iframe { + height: 720px; + width: 100%; + display: block; +} diff --git a/platform_plugin_aspects/static/html/superset.html b/platform_plugin_aspects/static/html/superset.html new file mode 100644 index 0000000..6b4837f --- /dev/null +++ b/platform_plugin_aspects/static/html/superset.html @@ -0,0 +1,25 @@ +{% load i18n %} + + + +
+

{{display_name}}

+ + {% if exception %} +

{% trans 'Superset is not configured properly. Please contact your system administrator.'%}

+

+ {{exception}} +

+ {% elif not dashboard_uuid %} +

+ Dashboard UUID is not set. Please set the dashboard UUID in the Studio. {{dashboard_uuid}} +

+ {% elif superset_url and superset_token %} +
+ + {% endif %} +
diff --git a/platform_plugin_aspects/static/js/embed_dashboard.js b/platform_plugin_aspects/static/js/embed_dashboard.js new file mode 100644 index 0000000..08c42f7 --- /dev/null +++ b/platform_plugin_aspects/static/js/embed_dashboard.js @@ -0,0 +1,30 @@ +function embedDashboard(dashboard_uuid, superset_url, superset_token, xblock_id) { + xblock_id = xblock_id || ""; + window.supersetEmbeddedSdk + .embedDashboard({ + id: dashboard_uuid, // given by the Superset embedding UI + supersetDomain: superset_url, // your Superset instance + mountPoint: document.getElementById(`superset-embedded-container-${xblock_id}`), // any html element that can contain an iframe + fetchGuestToken: () => superset_token, // function that returns a Promise with the guest token + dashboardUiConfig: { + // dashboard UI config: hideTitle, hideTab, hideChartControls, filters.visible, filters.expanded (optional) + hideTitle: true, + filters: { + expanded: false, + }, + hideTab: true, + hideChartControls: false, + hideFilters: true, + }, + }) + .then((dashboard) => { + mountPoint = document.getElementById("superset-embedded-container"); + /* + Perform extra operations on the dashboard object or the container + when the dashboard is loaded + */ + }); +} +if (window.dashboard_uuid !== undefined) { + embedDashboard(window.dashboard_uuid, window.superset_url, window.superset_token, window.xblock_id); +} diff --git a/platform_plugin_aspects/static/js/superset.js b/platform_plugin_aspects/static/js/superset.js new file mode 100644 index 0000000..14ae644 --- /dev/null +++ b/platform_plugin_aspects/static/js/superset.js @@ -0,0 +1,36 @@ +/* Javascript for SupersetXBlock. */ +function SupersetXBlock(runtime, element, context) { + const dashboard_uuid = context.dashboard_uuid; + const superset_url = context.superset_url; + const superset_token = context.superset_token; + const xblock_id = context.xblock_id + + function initSuperset(supersetEmbeddedSdk) { + embedDashboard(dashboard_uuid, superset_url, superset_token, xblock_id); + } + + if (typeof require === "function") { + require(["supersetEmbeddedSdk"], function (supersetEmbeddedSdk) { + window.supersetEmbeddedSdk = supersetEmbeddedSdk; + initSuperset(); + }); + } else { + loadJS(function () { + initSuperset(); + }); + } +} + +function loadJS(callback) { + if (window.supersetEmbeddedSdk) { + callback(); + } else { + $.getScript("https://cdn.jsdelivr.net/npm/@superset-ui/embedded-sdk@0.1.0-alpha.10/bundle/index.min.js") + .done(function () { + callback(); + }) + .fail(function () { + console.error("Error loading supersetEmbeddedSdk."); + }); + } +} diff --git a/platform_plugin_aspects/templates/instructor_dashboard/aspects.html b/platform_plugin_aspects/templates/instructor_dashboard/aspects.html new file mode 100644 index 0000000..1e8020e --- /dev/null +++ b/platform_plugin_aspects/templates/instructor_dashboard/aspects.html @@ -0,0 +1,8 @@ +<%page args="section_data" expression_filter="h"/> +<%! from openedx.core.djangolib.markup import HTML %> + +<%include file="/courseware/xqa_interface.html/"/> + +
+ ${HTML(section_data['fragment'].body_html())} +
diff --git a/platform_plugin_aspects/templates/platform_plugin_aspects/base.html b/platform_plugin_aspects/templates/platform_plugin_aspects/base.html deleted file mode 100644 index daf311a..0000000 --- a/platform_plugin_aspects/templates/platform_plugin_aspects/base.html +++ /dev/null @@ -1,26 +0,0 @@ - - -{% load i18n %} -{% trans "Dummy text to generate a translation (.po) source file. It is safe to delete this line. It is also safe to delete (load i18n) above if there are no other (trans) tags in the file" %} - -{% comment %} -As the developer of this package, don't place anything here if you can help it -since this allows developers to have interoperability between your template -structure and their own. - -Example: Developer melding the 2SoD pattern to fit inside with another pattern:: - - {% extends "base.html" %} - {% load static %} - - - {% block extra_js %} - - - {% block javascript %} - - {% endblock javascript %} - - {% endblock extra_js %} -{% endcomment %} - diff --git a/platform_plugin_aspects/tests/__init__.py b/platform_plugin_aspects/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/platform_plugin_aspects/tests/test_filters.py b/platform_plugin_aspects/tests/test_filters.py new file mode 100644 index 0000000..1359e86 --- /dev/null +++ b/platform_plugin_aspects/tests/test_filters.py @@ -0,0 +1,46 @@ +""" +Tests for the filters module. +""" + +from unittest import TestCase +from unittest.mock import Mock, patch + +from platform_plugin_aspects.extensions.filters import BLOCK_CATEGORY, AddSupersetTab + + +class TestFilters(TestCase): + """ + Test suite for the LimeSurveyXBlock filters. + """ + + def setUp(self) -> None: + """ + Set up the test suite. + """ + self.filter = AddSupersetTab(filter_type=Mock(), running_pipeline=Mock()) + self.template_name = "test-template-name" + self.context = {"course": Mock()} + + @patch("platform_plugin_aspects.extensions.filters.generate_superset_context") + def test_run_filter(self, mock_generate_superset_context): + """ + Check the filter is not executed when there are no LimeSurvey blocks in the course. + + Expected result: + - The context is returned without modifications. + """ + mock_generate_superset_context.return_value = { + "sections": [], + } + + context = self.filter.run_filter(self.context, self.template_name) + + self.assertDictContainsSubset( + { + "course_id": str(self.context["course"].id), + "section_key": BLOCK_CATEGORY, + "section_display_name": BLOCK_CATEGORY.title(), + "template_path_prefix": "/instructor_dashboard/", + }, + context["context"]["sections"][0], + ) diff --git a/platform_plugin_aspects/tests/test_settings.py b/platform_plugin_aspects/tests/test_settings.py new file mode 100644 index 0000000..cbbbc44 --- /dev/null +++ b/platform_plugin_aspects/tests/test_settings.py @@ -0,0 +1,57 @@ +""" +Test plugin settings for commond, devstack and production environments +""" + +from django.conf import settings +from django.test import TestCase + +from platform_plugin_aspects.settings import common as common_settings +from platform_plugin_aspects.settings import production as production_setttings + + +class TestPluginSettings(TestCase): + """ + Tests plugin settings + """ + + def test_common_settings(self): + """ + Test common settings + """ + settings.MAKO_TEMPLATE_DIRS_BASE = [] + common_settings.plugin_settings(settings) + self.assertIn("MAKO_TEMPLATE_DIRS_BASE", settings.__dict__) + self.assertIn("url", settings.SUPERSET_CONFIG) + self.assertIn("username", settings.SUPERSET_CONFIG) + self.assertIn("password", settings.SUPERSET_CONFIG) + self.assertIsNotNone(settings.ASPECTS_INSTRUCTOR_DASHBOARD_UUID) + self.assertIsNotNone(settings.SUPERSET_EXTRA_FILTERS_FORMAT) + + def test_production_settings(self): + """ + Test production settings + """ + settings.ENV_TOKENS = { + "SUPERSET_CONFIG": { + "url": "http://superset.local.overhang.io:8088", + "username": "superset", + "password": "superset", + }, + "ASPECTS_INSTRUCTOR_DASHBOARD_UUID": { + "dashboard_slug": "instructor-dashboard", + "dashboard_uuid": "1d6bf904-f53f-47fd-b1c9-6cd7e284d286", + }, + "SUPERSET_EXTRA_FILTERS_FORMAT": [], + } + production_setttings.plugin_settings(settings) + self.assertEqual( + settings.SUPERSET_CONFIG, settings.ENV_TOKENS["SUPERSET_CONFIG"] + ) + self.assertEqual( + settings.ASPECTS_INSTRUCTOR_DASHBOARD_UUID, + settings.ENV_TOKENS["ASPECTS_INSTRUCTOR_DASHBOARD_UUID"], + ) + self.assertEqual( + settings.SUPERSET_EXTRA_FILTERS_FORMAT, + settings.ENV_TOKENS["SUPERSET_EXTRA_FILTERS_FORMAT"], + ) diff --git a/platform_plugin_aspects/tests/test_utils.py b/platform_plugin_aspects/tests/test_utils.py new file mode 100644 index 0000000..d11c9c7 --- /dev/null +++ b/platform_plugin_aspects/tests/test_utils.py @@ -0,0 +1,102 @@ +""" +Tests for the utils module. +""" + +from collections import namedtuple +from unittest import TestCase +from unittest.mock import Mock, patch + +from django.conf import settings + +from platform_plugin_aspects.utils import generate_superset_context + +User = namedtuple("User", ["username"]) + + +class TestUtils(TestCase): + """ + Test utils module + """ + + @patch("platform_plugin_aspects.utils.generate_guest_token") + def test_generate_superset_context(self, mock_generate_guest_token): + """ + Test generate_superset_context + """ + course_mock = Mock() + filter_mock = Mock() + context = {"course": course_mock} + mock_generate_guest_token.return_value = ("test-token", "test-dashboard-uuid") + + context = generate_superset_context( + context, + dashboard_uuid="test-dashboard-uuid", + filters=[filter_mock], + ) + + self.assertEqual(context["superset_token"], "test-token") + self.assertEqual(context["dashboard_uuid"], "test-dashboard-uuid") + self.assertEqual(context["superset_url"], settings.SUPERSET_CONFIG.get("host")) + self.assertNotIn("exception", context) + + @patch("platform_plugin_aspects.utils.SupersetClient") + def test_generate_superset_context_with_superset_client_exception(self, mock_superset_client): + """ + Test generate_superset_context + """ + course_mock = Mock() + filter_mock = Mock() + context = {"course": course_mock} + mock_superset_client.side_effect = Exception("test-exception") + + context = generate_superset_context( + context, + dashboard_uuid="test-dashboard-uuid", + filters=[filter_mock], + ) + + self.assertIn("exception", context) + + @patch("platform_plugin_aspects.utils.SupersetClient") + @patch("platform_plugin_aspects.utils.get_current_user") + def test_generate_superset_context_succesful(self, mock_get_current_user, mock_superset_client): + """ + Test generate_superset_context + """ + course_mock = Mock() + filter_mock = Mock() + context = {"course": course_mock} + response_mock = Mock(status_code=200) + mock_superset_client.return_value.session.post.return_value = response_mock + response_mock.json.return_value = { + "token": "test-token", + } + mock_get_current_user.return_value = User(username="test-user") + + context = generate_superset_context( + context, + dashboard_uuid="test-dashboard-uuid", + filters=[filter_mock], + ) + + self.assertEqual(context["superset_token"], "test-token") + self.assertEqual(context["dashboard_uuid"], "test-dashboard-uuid") + self.assertEqual(context["superset_url"], settings.SUPERSET_CONFIG.get("host")) + + @patch("platform_plugin_aspects.utils.get_current_user") + def test_generate_superset_context_with_exception(self, mock_get_current_user): + """ + Test generate_superset_context + """ + course_mock = Mock() + filter_mock = Mock() + mock_get_current_user.return_value = User(username="test-user") + context = {"course": course_mock} + + context = generate_superset_context( + context, + dashboard_uuid="test-dashboard-uuid", + filters=[filter_mock], + ) + + self.assertIn("exception", context) diff --git a/platform_plugin_aspects/urls.py b/platform_plugin_aspects/urls.py deleted file mode 100644 index cb04c80..0000000 --- a/platform_plugin_aspects/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -URLs for platform_plugin_aspects. -""" -from django.urls import re_path # pylint: disable=unused-import -from django.views.generic import TemplateView # pylint: disable=unused-import - -urlpatterns = [ - # TODO: Fill in URL patterns and views here. - # re_path(r'', TemplateView.as_view(template_name="platform_plugin_aspects/base.html")), -] diff --git a/platform_plugin_aspects/utils.py b/platform_plugin_aspects/utils.py new file mode 100644 index 0000000..ce09db1 --- /dev/null +++ b/platform_plugin_aspects/utils.py @@ -0,0 +1,117 @@ +""" +Utilities for the Aspects app. +""" + +import logging +import os + +from crum import get_current_user +from django.conf import settings +from supersetapiclient.client import SupersetClient + +logger = logging.getLogger(__name__) + +if settings.DEBUG: + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + + +def generate_superset_context( # pylint: disable=dangerous-default-value + context, + dashboard_uuid="", + filters=[] +): + """ + Update context with superset token and dashboard id. + + Args: + context (dict): the context for the instructor dashboard. It must include a course object + superset_config (dict): superset config. + dashboard_uuid (str): superset dashboard uuid. + filters (list): list of filters to apply to the dashboard. + """ + course = context["course"] + user = get_current_user() + + superset_token, dashboard_uuid = generate_guest_token( + user=user, + course=course, + dashboard_uuid=dashboard_uuid, + filters=filters, + ) + + if superset_token: + context.update( + { + "superset_token": superset_token, + "dashboard_uuid": dashboard_uuid, + "superset_url": settings.SUPERSET_CONFIG.get("host"), + } + ) + else: + context.update( + { + "exception": dashboard_uuid, + } + ) + + return context + + +def generate_guest_token(user, course, dashboard_uuid, filters): + """ + Generate a Superset guest token for the user. + + Args: + user: User object. + course: Course object. + + Returns: + tuple: Superset guest token and dashboard id. + or None, exception if Superset is missconfigured or cannot generate guest token. + """ + superset_config = settings.SUPERSET_CONFIG + + superset_internal_host = superset_config.get("service_url") + superset_username = superset_config.get("username") + superset_password = superset_config.get("password") + + try: + client = SupersetClient( + host=superset_internal_host, + username=superset_username, + password=superset_password, + ) + except Exception as exc: # pylint: disable=broad-except + logger.error(exc) + return None, exc + + formatted_filters = [filter.format(course=course, user=user) for filter in filters] + + data = { + "user": { + "username": user.username, + # We can send more info about the user to superset + # but Open edX only provides the full name. For now is not needed + # and doesn't add any value so we don't send it. + # { + # "first_name": "John", + # "last_name": "Doe", + # } + }, + "resources": [{"type": "dashboard", "id": dashboard_uuid}], + "rls": [{"clause": filter} for filter in formatted_filters], + } + + try: + response = client.session.post( + url=f"{superset_internal_host}api/v1/security/guest_token/", + json=data, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + token = response.json()["token"] + + return token, dashboard_uuid + except Exception as exc: # pylint: disable=broad-except + logger.error(exc) + return None, exc diff --git a/requirements/base.in b/requirements/base.in index 9f4002e..a23d8e4 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -2,6 +2,9 @@ -c constraints.txt Django # Web application framework - - openedx-atlas +openedx-filters +web_fragments +superset-api-client +web_fragments +django_crum diff --git a/requirements/base.txt b/requirements/base.txt index 451961d..54a3abb 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -8,13 +8,41 @@ asgiref==3.7.2 # via django backports-zoneinfo==0.2.1 # via django +certifi==2024.2.2 + # via requests +charset-normalizer==3.3.2 + # via requests django==4.2.10 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.in + # django-crum + # openedx-filters +django-crum==0.7.9 + # via -r requirements/base.in +idna==3.6 + # via requests +oauthlib==3.2.2 + # via requests-oauthlib openedx-atlas==0.6.0 # via -r requirements/base.in +openedx-filters==1.6.0 + # via -r requirements/base.in +pyyaml==6.0.1 + # via superset-api-client +requests==2.31.0 + # via + # requests-oauthlib + # superset-api-client +requests-oauthlib==1.3.1 + # via superset-api-client sqlparse==0.4.4 # via django +superset-api-client==0.6.0 + # via -r requirements/base.in typing-extensions==4.10.0 # via asgiref +urllib3==2.2.1 + # via requests +web-fragments==2.1.0 + # via -r requirements/base.in diff --git a/requirements/dev.txt b/requirements/dev.txt index ec59a68..6a00177 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -17,7 +17,7 @@ backports-zoneinfo==0.2.1 # via # -r requirements/quality.txt # django -build==1.0.3 +build==1.1.1 # via # -r requirements/pip-tools.txt # pip-tools @@ -25,11 +25,19 @@ cachetools==5.3.3 # via # -r requirements/ci.txt # tox +certifi==2024.2.2 + # via + # -r requirements/quality.txt + # requests chardet==5.2.0 # via # -r requirements/ci.txt # diff-cover # tox +charset-normalizer==3.3.2 + # via + # -r requirements/quality.txt + # requests click==8.1.7 # via # -r requirements/pip-tools.txt @@ -68,7 +76,11 @@ django==4.2.10 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/quality.txt + # django-crum # edx-i18n-tools + # openedx-filters +django-crum==0.7.9 + # via -r requirements/quality.txt edx-i18n-tools==1.3.0 # via -r requirements/dev.in edx-lint==5.3.6 @@ -82,6 +94,10 @@ filelock==3.13.1 # -r requirements/ci.txt # tox # virtualenv +idna==3.6 + # via + # -r requirements/quality.txt + # requests importlib-metadata==7.0.1 # via # -r requirements/pip-tools.txt @@ -109,8 +125,14 @@ mccabe==0.7.0 # via # -r requirements/quality.txt # pylint +oauthlib==3.2.2 + # via + # -r requirements/quality.txt + # requests-oauthlib openedx-atlas==0.6.0 # via -r requirements/quality.txt +openedx-filters==1.6.0 + # via -r requirements/quality.txt packaging==23.2 # via # -r requirements/ci.txt @@ -197,6 +219,16 @@ pyyaml==6.0.1 # -r requirements/quality.txt # code-annotations # edx-i18n-tools + # superset-api-client +requests==2.31.0 + # via + # -r requirements/quality.txt + # requests-oauthlib + # superset-api-client +requests-oauthlib==1.3.1 + # via + # -r requirements/quality.txt + # superset-api-client six==1.16.0 # via # -r requirements/quality.txt @@ -213,6 +245,8 @@ stevedore==5.2.0 # via # -r requirements/quality.txt # code-annotations +superset-api-client==0.6.0 + # via -r requirements/quality.txt text-unidecode==1.3 # via # -r requirements/quality.txt @@ -242,10 +276,16 @@ typing-extensions==4.10.0 # asgiref # astroid # pylint +urllib3==2.2.1 + # via + # -r requirements/quality.txt + # requests virtualenv==20.25.1 # via # -r requirements/ci.txt # tox +web-fragments==2.1.0 + # via -r requirements/quality.txt wheel==0.42.0 # via # -r requirements/pip-tools.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 925948a..e82ec9e 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -22,12 +22,18 @@ backports-zoneinfo==0.2.1 # django beautifulsoup4==4.12.3 # via pydata-sphinx-theme -build==1.0.3 +build==1.1.1 # via -r requirements/doc.in certifi==2024.2.2 - # via requests + # via + # -r requirements/test.txt + # requests +cffi==1.16.0 + # via cryptography charset-normalizer==3.3.2 - # via requests + # via + # -r requirements/test.txt + # requests click==8.1.7 # via # -r requirements/test.txt @@ -38,10 +44,16 @@ coverage[toml]==7.4.3 # via # -r requirements/test.txt # pytest-cov +cryptography==42.0.5 + # via secretstorage django==4.2.10 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt + # django-crum + # openedx-filters +django-crum==0.7.9 + # via -r requirements/test.txt doc8==1.1.1 # via -r requirements/doc.in docutils==0.19 @@ -56,7 +68,9 @@ exceptiongroup==1.2.0 # -r requirements/test.txt # pytest idna==3.6 - # via requests + # via + # -r requirements/test.txt + # requests imagesize==1.4.1 # via sphinx importlib-metadata==7.0.1 @@ -73,6 +87,10 @@ iniconfig==2.0.0 # pytest jaraco-classes==3.3.1 # via keyring +jeepney==0.8.0 + # via + # keyring + # secretstorage jinja2==3.1.3 # via # -r requirements/test.txt @@ -92,8 +110,14 @@ more-itertools==10.2.0 # via jaraco-classes nh3==0.2.15 # via readme-renderer +oauthlib==3.2.2 + # via + # -r requirements/test.txt + # requests-oauthlib openedx-atlas==0.6.0 # via -r requirements/test.txt +openedx-filters==1.6.0 + # via -r requirements/test.txt packaging==23.2 # via # -r requirements/test.txt @@ -111,6 +135,8 @@ pluggy==1.4.0 # via # -r requirements/test.txt # pytest +pycparser==2.21 + # via cffi pydata-sphinx-theme==0.14.4 # via sphinx-book-theme pygments==2.17.2 @@ -142,13 +168,21 @@ pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations + # superset-api-client readme-renderer==43.0 # via twine requests==2.31.0 # via + # -r requirements/test.txt + # requests-oauthlib # requests-toolbelt # sphinx + # superset-api-client # twine +requests-oauthlib==1.3.1 + # via + # -r requirements/test.txt + # superset-api-client requests-toolbelt==1.0.0 # via twine restructuredtext-lint==1.4.0 @@ -157,6 +191,8 @@ rfc3986==2.0.0 # via twine rich==13.7.1 # via twine +secretstorage==3.3.3 + # via keyring snowballstemmer==2.2.0 # via sphinx soupsieve==2.5 @@ -189,6 +225,8 @@ stevedore==5.2.0 # -r requirements/test.txt # code-annotations # doc8 +superset-api-client==0.6.0 + # via -r requirements/test.txt text-unidecode==1.3 # via # -r requirements/test.txt @@ -211,8 +249,11 @@ typing-extensions==4.10.0 # rich urllib3==2.2.1 # via + # -r requirements/test.txt # requests # twine +web-fragments==2.1.0 + # via -r requirements/test.txt zipp==3.17.0 # via # importlib-metadata diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 44c48d9..8528adb 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -4,7 +4,7 @@ # # make upgrade # -build==1.0.3 +build==1.1.1 # via pip-tools click==8.1.7 # via pip-tools diff --git a/requirements/quality.txt b/requirements/quality.txt index 59f6e0c..0b79b75 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -16,6 +16,14 @@ backports-zoneinfo==0.2.1 # via # -r requirements/test.txt # django +certifi==2024.2.2 + # via + # -r requirements/test.txt + # requests +charset-normalizer==3.3.2 + # via + # -r requirements/test.txt + # requests click==8.1.7 # via # -r requirements/test.txt @@ -38,12 +46,20 @@ django==4.2.10 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/test.txt + # django-crum + # openedx-filters +django-crum==0.7.9 + # via -r requirements/test.txt edx-lint==5.3.6 # via -r requirements/quality.in exceptiongroup==1.2.0 # via # -r requirements/test.txt # pytest +idna==3.6 + # via + # -r requirements/test.txt + # requests iniconfig==2.0.0 # via # -r requirements/test.txt @@ -62,8 +78,14 @@ markupsafe==2.1.5 # jinja2 mccabe==0.7.0 # via pylint +oauthlib==3.2.2 + # via + # -r requirements/test.txt + # requests-oauthlib openedx-atlas==0.6.0 # via -r requirements/test.txt +openedx-filters==1.6.0 + # via -r requirements/test.txt packaging==23.2 # via # -r requirements/test.txt @@ -113,6 +135,16 @@ pyyaml==6.0.1 # via # -r requirements/test.txt # code-annotations + # superset-api-client +requests==2.31.0 + # via + # -r requirements/test.txt + # requests-oauthlib + # superset-api-client +requests-oauthlib==1.3.1 + # via + # -r requirements/test.txt + # superset-api-client six==1.16.0 # via edx-lint snowballstemmer==2.2.0 @@ -125,6 +157,8 @@ stevedore==5.2.0 # via # -r requirements/test.txt # code-annotations +superset-api-client==0.6.0 + # via -r requirements/test.txt text-unidecode==1.3 # via # -r requirements/test.txt @@ -143,3 +177,9 @@ typing-extensions==4.10.0 # asgiref # astroid # pylint +urllib3==2.2.1 + # via + # -r requirements/test.txt + # requests +web-fragments==2.1.0 + # via -r requirements/test.txt diff --git a/requirements/test.txt b/requirements/test.txt index d3406f9..9f24878 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -12,6 +12,14 @@ backports-zoneinfo==0.2.1 # via # -r requirements/base.txt # django +certifi==2024.2.2 + # via + # -r requirements/base.txt + # requests +charset-normalizer==3.3.2 + # via + # -r requirements/base.txt + # requests click==8.1.7 # via code-annotations code-annotations==1.6.0 @@ -21,16 +29,30 @@ coverage[toml]==7.4.3 # via # -c https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt # -r requirements/base.txt + # django-crum + # openedx-filters +django-crum==0.7.9 + # via -r requirements/base.txt exceptiongroup==1.2.0 # via pytest +idna==3.6 + # via + # -r requirements/base.txt + # requests iniconfig==2.0.0 # via pytest jinja2==3.1.3 # via code-annotations markupsafe==2.1.5 # via jinja2 +oauthlib==3.2.2 + # via + # -r requirements/base.txt + # requests-oauthlib openedx-atlas==0.6.0 # via -r requirements/base.txt +openedx-filters==1.6.0 + # via -r requirements/base.txt packaging==23.2 # via pytest pbr==6.0.0 @@ -48,13 +70,27 @@ pytest-django==4.8.0 python-slugify==8.0.4 # via code-annotations pyyaml==6.0.1 - # via code-annotations + # via + # -r requirements/base.txt + # code-annotations + # superset-api-client +requests==2.31.0 + # via + # -r requirements/base.txt + # requests-oauthlib + # superset-api-client +requests-oauthlib==1.3.1 + # via + # -r requirements/base.txt + # superset-api-client sqlparse==0.4.4 # via # -r requirements/base.txt # django stevedore==5.2.0 # via code-annotations +superset-api-client==0.6.0 + # via -r requirements/base.txt text-unidecode==1.3 # via python-slugify tomli==2.0.1 @@ -65,3 +101,9 @@ typing-extensions==4.10.0 # via # -r requirements/base.txt # asgiref +urllib3==2.2.1 + # via + # -r requirements/base.txt + # requests +web-fragments==2.1.0 + # via -r requirements/base.txt diff --git a/setup.py b/setup.py index 34448a6..f173ec7 100755 --- a/setup.py +++ b/setup.py @@ -157,4 +157,12 @@ def is_requirement(line): 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.8', ], + entry_points={ + 'lms.djangoapp': [ + 'platform_plugin_aspects = platform_plugin_aspects.apps:PlatformPluginAspectsConfig', + ], + 'cms.djangoapp': [ + 'platform_plugin_aspects = platform_plugin_aspects.apps:PlatformPluginAspectsConfig', + ], + }, ) diff --git a/test_settings.py b/test_settings.py index 43b7b78..d2e72bd 100644 --- a/test_settings.py +++ b/test_settings.py @@ -14,6 +14,7 @@ def root(*args): """ return join(abspath(dirname(__file__)), *args) +DEBUG = True DATABASES = { 'default': { @@ -39,8 +40,6 @@ def root(*args): root('platform_plugin_aspects', 'conf', 'locale'), ] -ROOT_URLCONF = 'platform_plugin_aspects.urls' - SECRET_KEY = 'insecure-secret-key' MIDDLEWARE = ( @@ -59,3 +58,13 @@ def root(*args): ], }, }] + +ASPECTS_INSTRUCTOR_DASHBOARD_UUID = "test-dashboard-uuid" + +SUPERSET_CONFIG = { + "url": "http://dummy-superset-url:8088", + "username": "superset", + "password": "superset", +} + +SUPERSET_EXTRA_FILTERS_FORMAT = [] diff --git a/tests/test_models.py b/tests/test_models.py deleted file mode 100644 index 446a740..0000000 --- a/tests/test_models.py +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env python -""" -Tests for the `platform-plugin-aspects` models module. -""" - -import pytest - - -@pytest.mark.skip(reason="Placeholder to allow pytest to succeed before real tests are in place.") -def test_placeholder(): - """ - TODO: Delete this test once there are real tests. - """ diff --git a/tox.ini b/tox.ini index c182f60..acf45d4 100644 --- a/tox.ini +++ b/tox.ini @@ -31,7 +31,7 @@ match-dir = (?!migrations) [pytest] DJANGO_SETTINGS_MODULE = test_settings -addopts = --cov platform_plugin_aspects --cov tests --cov-report term-missing --cov-report xml +addopts = --cov platform_plugin_aspects --cov-report term-missing --cov-report xml norecursedirs = .* docs requirements site-packages [testenv] @@ -71,12 +71,10 @@ allowlist_externals = deps = -r{toxinidir}/requirements/quality.txt commands = - touch tests/__init__.py - pylint platform_plugin_aspects tests test_utils manage.py setup.py - rm tests/__init__.py - pycodestyle platform_plugin_aspects tests manage.py setup.py - pydocstyle platform_plugin_aspects tests manage.py setup.py - isort --check-only --diff tests test_utils platform_plugin_aspects manage.py setup.py test_settings.py + pylint platform_plugin_aspects test_utils manage.py setup.py + pycodestyle platform_plugin_aspects manage.py setup.py + pydocstyle platform_plugin_aspects manage.py setup.py + isort --check-only --diff test_utils platform_plugin_aspects manage.py setup.py test_settings.py make selfcheck [testenv:pii_check]