From 374dedfc9807bf241cb0cd0d569a0af558a59fb5 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Thu, 30 Apr 2026 19:51:15 -0600 Subject: [PATCH 1/2] feat: added use of new filter for instructor dash tabs chore: fix comments chore: update filters to right version chore: lint fix and test impriove chore: fix bad patch chore: fix comments chore: add extra tests chore: remove extra trailing line --- common/djangoapps/util/tests/test_filters.py | 51 ++++++++++ .../instructor/tests/views/test_api_v2.py | 92 +++++++++++++++++++ .../instructor/views/serializers_v2.py | 17 +++- 3 files changed, 158 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/util/tests/test_filters.py b/common/djangoapps/util/tests/test_filters.py index 4fadce839bf7..e541b5e19502 100644 --- a/common/djangoapps/util/tests/test_filters.py +++ b/common/djangoapps/util/tests/test_filters.py @@ -3,6 +3,7 @@ """ from django.test import override_settings from openedx_filters import PipelineStep +from openedx_filters.learning.filters import InstructorDashboardTabsRequested from common.djangoapps.util import course from openedx.core.djangolib.testing.utils import skip_unless_lms @@ -76,3 +77,53 @@ def test_course_about_page_url_requested_without_filter_configuration(self): ) self.assertEqual(expected_course_about, course_about_url) # noqa: PT009 + + +class TestInstructorDashCustomTab(PipelineStep): + """ + Utility class used when getting steps for pipeline. + """ + + def run_filter(self, tabs, user, course_key): # pylint: disable=arguments-differ,unused-argument + """Pipeline step that appends a custom instructor dashboard tab.""" + result = { + "tabs": tabs + [{ + "tab_id": "custom", + "title": "Custom Tab", + "url": f"/courses/{course_key}/instructor/custom", + "sort_order": 999, + }], + } + return result + + +class TestPreventTabsGenerationWithTabs(PipelineStep): + """ + Pipeline step that raises PreventTabsGeneration with a custom tabs list. + Used to test that the exception handler in get_tabs uses exc.tabs when present. + """ + + def run_filter(self, tabs, user, course_key): # pylint: disable=arguments-differ,unused-argument + """Pipeline step that raises PreventTabsGeneration with custom tabs.""" + raise InstructorDashboardTabsRequested.PreventTabsGeneration( + "Preventing default tabs in favor of custom ones.", + tabs=[{ + "tab_id": "plugin_tab", + "title": "Plugin Tab", + "url": f"/courses/{course_key}/instructor/plugin", + "sort_order": 5, + }], + ) + + +class TestPreventTabsGenerationWithoutTabs(PipelineStep): + """ + Pipeline step that raises PreventTabsGeneration without a tabs list. + Used to test that the exception handler in get_tabs falls back to an empty list. + """ + + def run_filter(self, tabs, user, course_key): # pylint: disable=arguments-differ,unused-argument + """Pipeline step that raises PreventTabsGeneration without providing tabs.""" + raise InstructorDashboardTabsRequested.PreventTabsGeneration( + "Preventing all tabs from being generated." + ) diff --git a/lms/djangoapps/instructor/tests/views/test_api_v2.py b/lms/djangoapps/instructor/tests/views/test_api_v2.py index e438b3583c12..77da1a175a33 100644 --- a/lms/djangoapps/instructor/tests/views/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/views/test_api_v2.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 +from django.test import override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient @@ -692,3 +693,94 @@ def test_override_rejects_negative_score(self): format='json', ) assert response.status_code == status.HTTP_400_BAD_REQUEST + + +class CourseMetadataViewTestCase(ModuleStoreTestCase): + """ + Tests for GET /api/instructor/v2/courses/{course_id} with InstructorDashboardTabsRequested filter. + """ + def setUp(self): + super().setUp() + self.client = APIClient() + self.course = CourseFactory.create() + self.instructor = InstructorFactory.create(course_key=self.course.id) + self.client.force_authenticate(user=self.instructor) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.instructor.dashboard.tabs.requested.v1": { + "pipeline": [ + "common.djangoapps.util.tests.test_filters.TestInstructorDashCustomTab", + ], + "fail_silently": False, + }, + }, + ) + def test_tabs_filter_adds_custom_tab(self): + """Test that override settings drive a custom instructor dashboard tab.""" + url = reverse("instructor_api_v2:course_metadata", kwargs={"course_id": str(self.course.id)}) + response = self.client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + tabs_by_id = {tab["tab_id"]: tab for tab in data["tabs"]} + + assert "course_info" in tabs_by_id + assert "custom" in tabs_by_id + assert tabs_by_id["custom"] == { + "tab_id": "custom", + "title": "Custom Tab", + "url": f"/courses/{self.course.id}/instructor/custom", + "sort_order": 999, + } + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.instructor.dashboard.tabs.requested.v1": { + "pipeline": [ + "common.djangoapps.util.tests.test_filters.TestPreventTabsGenerationWithTabs", + ], + "fail_silently": False, + }, + }, + ) + def test_tabs_filter_prevent_tabs_generation_with_custom_tabs(self): + """ + Test that when PreventTabsGeneration is raised with a tabs attribute, + the serializer uses those custom tabs instead of the default ones. + """ + url = reverse("instructor_api_v2:course_metadata", kwargs={"course_id": str(self.course.id)}) + with self.assertLogs('openedx_filters.tooling', level='ERROR'): + response = self.client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["tabs"] == [{ + "tab_id": "plugin_tab", + "title": "Plugin Tab", + "url": f"/courses/{self.course.id}/instructor/plugin", + "sort_order": 5, + }] + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.instructor.dashboard.tabs.requested.v1": { + "pipeline": [ + "common.djangoapps.util.tests.test_filters.TestPreventTabsGenerationWithoutTabs", + ], + "fail_silently": False, + }, + }, + ) + def test_tabs_filter_prevent_tabs_generation_without_tabs_attr(self): + """ + Test that when PreventTabsGeneration is raised without a tabs attribute, + the serializer falls back to an empty list. + """ + url = reverse("instructor_api_v2:course_metadata", kwargs={"course_id": str(self.course.id)}) + with self.assertLogs('openedx_filters.tooling', level='ERROR'): + response = self.client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["tabs"] == [] diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index 1f8d400a6c59..504be052b831 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -13,6 +13,7 @@ from django.utils.html import escape from django.utils.translation import gettext as _ from edx_when.api import is_enabled_for_course +from openedx_filters.learning.filters import InstructorDashboardTabsRequested from rest_framework import serializers from common.djangoapps.course_modes.models import CourseMode @@ -305,6 +306,19 @@ def get_tabs(self, data): 'sort_order': 110, }) + try: + # .. filter_implemented_name: InstructorDashboardTabsRequested + # .. filter_type: org.openedx.learning.instructor.dashboard.tabs.requested.v1 + filtered_tabs = InstructorDashboardTabsRequested.run_filter( + tabs=tabs, + user=request.user, + course_key=course_key + ) + custom_tabs = filtered_tabs if filtered_tabs is not None else tabs + except InstructorDashboardTabsRequested.PreventTabsGeneration as exc: + # Plugin provided custom tabs or prevented tab generation + custom_tabs = getattr(exc, 'tabs', None) or [] + # We provide the tabs in a specific order based on how it was # historically presented in the frontend. The frontend can use # this info or choose to ignore the ordering. @@ -322,8 +336,7 @@ def get_tabs(self, data): 'special_exams', ] order_index = {tab: i for i, tab in enumerate(tabs_order)} - tabs = sorted(tabs, key=lambda x: order_index.get(x['tab_id'], float("inf"))) - return tabs + return sorted(custom_tabs, key=lambda x: order_index.get(x['tab_id'], float("inf"))) def get_course_id(self, data): """Get course ID as string.""" From 2da52162ef2b6148160b779631aaa78d64fb1674 Mon Sep 17 00:00:00 2001 From: javier ontiveros Date: Fri, 22 May 2026 14:56:08 -0600 Subject: [PATCH 2/2] chore: add filters right version --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- requirements/edx/doc.txt | 2 +- requirements/edx/testing.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index d27657db9642..7c81fe68dd11 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -861,7 +861,7 @@ openedx-events==11.2.0 # openedx-authz # openedx-core # ora2 -openedx-filters==3.1.0 +openedx-filters==3.4.0 # via # -r requirements/edx/kernel.in # edx-enterprise diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 4cf16f0daae7..d02a3215e6bd 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1413,7 +1413,7 @@ openedx-events==11.2.0 # openedx-authz # openedx-core # ora2 -openedx-filters==3.1.0 +openedx-filters==3.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 6df56043d4dd..265215516ff2 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -1041,7 +1041,7 @@ openedx-events==11.2.0 # openedx-authz # openedx-core # ora2 -openedx-filters==3.1.0 +openedx-filters==3.4.0 # via # -r requirements/edx/base.txt # edx-enterprise diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 43cd581e3a86..c2e3c97292e7 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1081,7 +1081,7 @@ openedx-events==11.2.0 # openedx-authz # openedx-core # ora2 -openedx-filters==3.1.0 +openedx-filters==3.4.0 # via # -r requirements/edx/base.txt # edx-enterprise