Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion platform_plugin_aspects/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)))
29 changes: 29 additions & 0 deletions platform_plugin_aspects/extensions/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,32 @@ 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
): # 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.
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}
76 changes: 75 additions & 1 deletion platform_plugin_aspects/extensions/tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
103 changes: 103 additions & 0 deletions platform_plugin_aspects/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,106 @@ 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,
)
5 changes: 5 additions & 0 deletions platform_plugin_aspects/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down
61 changes: 61 additions & 0 deletions platform_plugin_aspects/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
_,
build_filter,
generate_guest_token,
generate_superset_context,
get_localized_uuid,
get_model,
get_user_dashboard_locale,
Expand Down Expand Up @@ -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,
}
)
Loading