From 5fcce3ed0141d1ddf99769044d49701c6016586d Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 21 May 2026 16:40:14 -0600 Subject: [PATCH 1/6] feat: add frontendbase instructor dashboard compat --- platform_plugin_aspects/extensions/filters.py | 27 ++++++ platform_plugin_aspects/tests/test_views.py | 95 +++++++++++++++++++ platform_plugin_aspects/urls.py | 5 + platform_plugin_aspects/views.py | 61 ++++++++++++ 4 files changed, 188 insertions(+) diff --git a/platform_plugin_aspects/extensions/filters.py b/platform_plugin_aspects/extensions/filters.py index 4b432a9b..cc6d7741 100644 --- a/platform_plugin_aspects/extensions/filters.py +++ b/platform_plugin_aspects/extensions/filters.py @@ -77,3 +77,30 @@ def resource_string(self, path): ) return data + + +class AddSupersetTabToInstructorDashboard(PipelineStep): + """ + Add superset tab to instructor dashboard app. + """ + + def run_filter(self, tabs, user, course_key): + """Execute filter that modifies the instructor dashboard context. + Args: + tabs (list): the list of tabs for the instructor dashboard. + user (User): the current user. + course_key (str): the course key. + """ + modified_tabs = tabs.copy() + tab_id = "aspects" + course_id = str(course_key) + + custom_tab = { + 'tab_id': tab_id, + 'title': _("Reports"), + 'url': f"/instructor-dashboard/{course_id}/{tab_id}/", + 'sort_order': 120, + } + + modified_tabs.append(custom_tab) + return {"tabs": modified_tabs} diff --git a/platform_plugin_aspects/tests/test_views.py b/platform_plugin_aspects/tests/test_views.py index 35b2f59d..68b4bb22 100644 --- a/platform_plugin_aspects/tests/test_views.py +++ b/platform_plugin_aspects/tests/test_views.py @@ -232,3 +232,98 @@ def test_in_context_dashboard_block( data = response.json() self.assertEqual(data["dashboardId"], "00000000-0000-0000-0000-000000000000") self.assertEqual(data["defaultCourseRun"], "run") + + +class SupersetInstructorDashboardViewTestCase(TestCase): + """ + Test cases for SupersetInstructorDashboardView. + """ + + def setUp(self): + """ + Set up data used by multiple tests. + """ + super().setUp() + self.client = APIClient() + self.url = f"/superset_instructor_dashboard/{COURSE_ID}" + self.user = User.objects.create( + username="instructor", + email="instructor@example.com", + ) + self.user.set_password("password") + self.user.save() + + def test_requires_authorization(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_requires_course_access(self): + self.client.login(username="instructor", password="password") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_invalid_course_id(self): + self.client.login(username="instructor", password="password") + response = self.client.get("/superset_instructor_dashboard/block-v1:org+course+run") + self.assertEqual(response.status_code, 404) + + @patch("platform_plugin_aspects.views.get_model") + def test_course_not_found(self, mock_get_model): + mock_model_get = Mock(side_effect=ObjectDoesNotExist) + mock_model_only = Mock(return_value=Mock(get=mock_model_get)) + mock_get_model.return_value = Mock( + objects=Mock(only=mock_model_only), + DoesNotExist=ObjectDoesNotExist, + ) + + self.client.login(username="instructor", password="password") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + mock_model_get.assert_called_once() + + @patch.object(IsCourseStaffInstructor, "has_object_permission") + @patch("platform_plugin_aspects.views.generate_superset_context") + def test_success(self, mock_generate_superset_context, mock_has_object_permission): + mock_has_object_permission.return_value = True + mock_generate_superset_context.return_value = { + "course_id": COURSE_ID, + "superset_dashboards": [{"name": "Course Dashboard", "uuid": "test-uuid", "slug": "course-dashboard"}], + "superset_url": "https://superset.example.com", + "superset_guest_token_url": f"https://lms.example.com/aspects/superset_guest_token/{COURSE_ID}", + } + + self.client.login(username="instructor", password="password") + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("superset_dashboards", data) + self.assertIn("superset_url", data) + self.assertIn("superset_guest_token_url", data) + self.assertIn("show_dashboard_link", data) + self.assertEqual(data["superset_url"], "https://superset.example.com") + mock_has_object_permission.assert_called_once() + mock_generate_superset_context.assert_called_once() + + @patch.object(IsCourseStaffInstructor, "has_object_permission") + @patch("platform_plugin_aspects.views.generate_superset_context") + def test_show_dashboard_link_from_settings( + self, mock_generate_superset_context, mock_has_object_permission + ): + mock_has_object_permission.return_value = True + mock_generate_superset_context.return_value = { + "course_id": COURSE_ID, + "superset_dashboards": [], + "superset_url": "https://superset.example.com", + "superset_guest_token_url": f"https://lms.example.com/aspects/superset_guest_token/{COURSE_ID}", + } + + self.client.login(username="instructor", password="password") + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual( + data["show_dashboard_link"], + settings.SUPERSET_SHOW_INSTRUCTOR_DASHBOARD_LINK, + ) diff --git a/platform_plugin_aspects/urls.py b/platform_plugin_aspects/urls.py index 5b4c9193..5b11d83b 100644 --- a/platform_plugin_aspects/urls.py +++ b/platform_plugin_aspects/urls.py @@ -25,6 +25,11 @@ views.SupersetInContextDashboardView.as_view(), name="superset_in_context_dashboard", ), + re_path( + rf"superset_instructor_dashboard/{COURSE_ID_PATTERN}/?$", + views.SupersetInstructorDashboardView.as_view(), + name="superset_instructor_dashboard", + ), ], "platform_plugin_aspects", ) diff --git a/platform_plugin_aspects/views.py b/platform_plugin_aspects/views.py index 9afdd2e7..14c07cd9 100644 --- a/platform_plugin_aspects/views.py +++ b/platform_plugin_aspects/views.py @@ -20,6 +20,7 @@ _, build_filter, generate_guest_token, + generate_superset_context, get_localized_uuid, get_model, get_user_dashboard_locale, @@ -248,3 +249,63 @@ def get(self, request, *args, **kwargs): "defaultCourseRun": course_key.run, } ) + + +class SupersetInstructorDashboardView(GenericAPIView): + """ + Endpoint for instructor dashboard Superset configuration. + + Returns the Superset context needed by the frontend app to embed + the instructor dashboards, including dashboard list, URLs, and + display settings. + """ + + authentication_classes = (SessionAuthentication,) + permission_classes = ( + permissions.IsAuthenticated, + IsStaffOrReadOnly | IsCourseStaffInstructor, + ) + + lookup_field = "course_id" + + def get_object(self): + """ + Return a CourseKey for the requested course_id. + """ + course_id = self.kwargs.get(self.lookup_field, "") + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError as exc: + raise NotFound( + _("Invalid course id: '{course_id}'").format(course_id=course_id) + ) from exc + + course = _get_course(course_key) + + # May raise a permission denied + self.check_object_permissions(self.request, course) + + return course_key + + def get(self, request, *args, **kwargs): + """ + Return Superset context for embedding the instructor dashboards. + """ + course_key = self.get_object() + + language = get_user_dashboard_locale(request.user) + context = {"course_id": str(course_key)} + context = generate_superset_context( + context, + dashboards=settings.ASPECTS_INSTRUCTOR_DASHBOARDS, + language=language, + ) + + return Response( + { + "superset_dashboards": context["superset_dashboards"], + "superset_url": context["superset_url"], + "superset_guest_token_url": context["superset_guest_token_url"], + "show_dashboard_link": settings.SUPERSET_SHOW_INSTRUCTOR_DASHBOARD_LINK, + } + ) From 75c2c97ef85453603a97dc06daf5783b242d7070 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 21 May 2026 16:51:01 -0600 Subject: [PATCH 2/6] chore: fixe quality linting --- platform_plugin_aspects/extensions/filters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/platform_plugin_aspects/extensions/filters.py b/platform_plugin_aspects/extensions/filters.py index cc6d7741..c62c5150 100644 --- a/platform_plugin_aspects/extensions/filters.py +++ b/platform_plugin_aspects/extensions/filters.py @@ -84,6 +84,7 @@ class AddSupersetTabToInstructorDashboard(PipelineStep): Add superset tab to instructor dashboard app. """ + # pylint: disable=arguments-differ, unused-argument def run_filter(self, tabs, user, course_key): """Execute filter that modifies the instructor dashboard context. Args: From 6d2e937fd8272434d3e3e7ee75b4c098c7b98e1e Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 22 May 2026 09:22:17 -0600 Subject: [PATCH 3/6] chore: fix lint issues --- platform_plugin_aspects/extensions/filters.py | 13 +++++++------ platform_plugin_aspects/tests/test_views.py | 12 ++++++++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/platform_plugin_aspects/extensions/filters.py b/platform_plugin_aspects/extensions/filters.py index c62c5150..5d055d56 100644 --- a/platform_plugin_aspects/extensions/filters.py +++ b/platform_plugin_aspects/extensions/filters.py @@ -84,8 +84,9 @@ class AddSupersetTabToInstructorDashboard(PipelineStep): Add superset tab to instructor dashboard app. """ - # pylint: disable=arguments-differ, unused-argument - def run_filter(self, tabs, user, course_key): + def run_filter( + self, tabs, user, course_key + ): # pylint: disable=arguments-differ, unused-argument """Execute filter that modifies the instructor dashboard context. Args: tabs (list): the list of tabs for the instructor dashboard. @@ -97,10 +98,10 @@ def run_filter(self, tabs, user, course_key): course_id = str(course_key) custom_tab = { - 'tab_id': tab_id, - 'title': _("Reports"), - 'url': f"/instructor-dashboard/{course_id}/{tab_id}/", - 'sort_order': 120, + "tab_id": tab_id, + "title": _("Reports"), + "url": f"/instructor-dashboard/{course_id}/{tab_id}/", + "sort_order": 120, } modified_tabs.append(custom_tab) diff --git a/platform_plugin_aspects/tests/test_views.py b/platform_plugin_aspects/tests/test_views.py index 68b4bb22..c4c32333 100644 --- a/platform_plugin_aspects/tests/test_views.py +++ b/platform_plugin_aspects/tests/test_views.py @@ -264,7 +264,9 @@ def test_requires_course_access(self): def test_invalid_course_id(self): self.client.login(username="instructor", password="password") - response = self.client.get("/superset_instructor_dashboard/block-v1:org+course+run") + response = self.client.get( + "/superset_instructor_dashboard/block-v1:org+course+run" + ) self.assertEqual(response.status_code, 404) @patch("platform_plugin_aspects.views.get_model") @@ -287,7 +289,13 @@ def test_success(self, mock_generate_superset_context, mock_has_object_permissio mock_has_object_permission.return_value = True mock_generate_superset_context.return_value = { "course_id": COURSE_ID, - "superset_dashboards": [{"name": "Course Dashboard", "uuid": "test-uuid", "slug": "course-dashboard"}], + "superset_dashboards": [ + { + "name": "Course Dashboard", + "uuid": "test-uuid", + "slug": "course-dashboard", + } + ], "superset_url": "https://superset.example.com", "superset_guest_token_url": f"https://lms.example.com/aspects/superset_guest_token/{COURSE_ID}", } From 85d3ff4c84ef6a0eebcd0b553626ac9ad5356fa2 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 22 May 2026 09:35:41 -0600 Subject: [PATCH 4/6] chore: add missing coverage --- .../extensions/tests/test_filters.py | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/platform_plugin_aspects/extensions/tests/test_filters.py b/platform_plugin_aspects/extensions/tests/test_filters.py index 55acaf17..c24cb01b 100644 --- a/platform_plugin_aspects/extensions/tests/test_filters.py +++ b/platform_plugin_aspects/extensions/tests/test_filters.py @@ -6,7 +6,11 @@ from django.test import TestCase -from platform_plugin_aspects.extensions.filters import BLOCK_CATEGORY, AddSupersetTab +from platform_plugin_aspects.extensions.filters import ( + BLOCK_CATEGORY, + AddSupersetTab, + AddSupersetTabToInstructorDashboard, +) class TestFilters(TestCase): @@ -48,3 +52,73 @@ def test_run_filter( }.items() <= context["context"]["sections"][0].items() mock_get_user_dashboard_locale.assert_called_once() + + +class TestAddSupersetTabToInstructorDashboard(TestCase): + """ + Test suite for the AddSupersetTabToInstructorDashboard filter. + """ + + def setUp(self) -> None: + """ + Set up the test suite. + """ + self.filter = AddSupersetTabToInstructorDashboard( + filter_type=Mock(), running_pipeline=Mock() + ) + self.course_key = Mock(__str__=Mock(return_value="course-v1:org+course+run")) + self.user = Mock() + + def test_run_filter_appends_aspects_tab(self): + """ + Check that the filter appends the Aspects tab to the existing tabs list. + + Expected result: + - The returned tabs list contains the original tabs plus the new aspects tab. + """ + existing_tab = {"tab_id": "existing", "title": "Existing", "sort_order": 10} + tabs = [existing_tab] + + result = self.filter.run_filter( + tabs=tabs, user=self.user, course_key=self.course_key + ) + + assert len(result["tabs"]) == 2 + aspects_tab = result["tabs"][1] + assert aspects_tab["tab_id"] == "aspects" + assert aspects_tab["title"] == "Reports" + assert ( + aspects_tab["url"] + == "/instructor-dashboard/course-v1:org+course+run/aspects/" + ) + assert aspects_tab["sort_order"] == 120 + + def test_run_filter_does_not_mutate_original_tabs(self): + """ + Check that the filter does not modify the original tabs list. + + Expected result: + - The original tabs list is unchanged after the filter runs. + """ + original_tabs = [{"tab_id": "existing", "title": "Existing", "sort_order": 10}] + tabs_copy = original_tabs.copy() + + self.filter.run_filter( + tabs=original_tabs, user=self.user, course_key=self.course_key + ) + + assert original_tabs == tabs_copy + + def test_run_filter_with_empty_tabs(self): + """ + Check that the filter works when the initial tabs list is empty. + + Expected result: + - The returned tabs list contains only the aspects tab. + """ + result = self.filter.run_filter( + tabs=[], user=self.user, course_key=self.course_key + ) + + assert len(result["tabs"]) == 1 + assert result["tabs"][0]["tab_id"] == "aspects" From e1c0902f1b7561a53e4f3fc46a366cf2eb593bce Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 22 May 2026 09:51:34 -0600 Subject: [PATCH 5/6] chore: updated documentation --- README.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.rst b/README.rst index d61cad76..26e8deb7 100644 --- a/README.rst +++ b/README.rst @@ -129,6 +129,8 @@ The Instructor Dashboard integration uses the `Open edX Filters`_. To learn more the filters, see the `Open edX Filters`_ documentation. Make sure to configure the superset pipeline into the filter as follows: +for legacy instructor dashboard: + .. code-block:: python OPEN_EDX_FILTERS_CONFIG = { @@ -140,6 +142,19 @@ superset pipeline into the filter as follows: }, } +for new instructor dashboard: + +.. code-block:: python + + OPEN_EDX_FILTERS_CONFIG = { + "org.openedx.learning.instructor.dashboard.tabs.requested.v1": { + "fail_silently": False, + "pipeline": [ + "platform_plugin_superset.extensions.filters.AddSupersetTabToInstructorDashboard", + ] + }, + } + - ``SUPERSET_CONFIG`` - This setting is used to configure the Superset Embedded SDK. The configuration is a dictionary that contains the following keys: From dee912587bb437f1be603be47934eb1db91e0b1b Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 22 May 2026 09:58:02 -0600 Subject: [PATCH 6/6] chore: version bump to 1.3.0 --- platform_plugin_aspects/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform_plugin_aspects/__init__.py b/platform_plugin_aspects/__init__.py index 9a17ef12..0e5c07f7 100644 --- a/platform_plugin_aspects/__init__.py +++ b/platform_plugin_aspects/__init__.py @@ -5,6 +5,6 @@ import os from pathlib import Path -__version__ = "1.2.0" +__version__ = "1.3.0" ROOT_DIRECTORY = Path(os.path.dirname(os.path.abspath(__file__)))