diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 67e51dd..09aae76 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -31,17 +31,19 @@ Change Log Unreleased ---------- +.. scriv-insert-here +[3.4.0] - 2026-05-07 Added ~~~~~ * Added django52 support. * Also releasing pending items. +* Added new ``InstructorDashboardTabsRequested`` filter to allow for dynamic generation of instructor dashboard tabs. (by @holaontiveros) See the fragment files in the `changelog.d directory`_. .. _changelog.d directory: https://github.com/openedx/openedx-filters/tree/master/changelog.d -.. scriv-insert-here [3.3.0] - 2025-04-17 -------------------- @@ -58,7 +60,7 @@ Changed ~~~~~~~ * Added GradeEventContextRequested filter - + [3.1.0] - 2025-04-06 -------------------- diff --git a/openedx_filters/__init__.py b/openedx_filters/__init__.py index 2bdb72d..bd86af1 100644 --- a/openedx_filters/__init__.py +++ b/openedx_filters/__init__.py @@ -6,7 +6,7 @@ from openedx_filters.filters import * -__version__ = "3.3.0" +__version__ = "3.4.0" if sys.version_info < (3, 12): # pragma: no cover warnings.warn( diff --git a/openedx_filters/learning/filters.py b/openedx_filters/learning/filters.py index f75c27a..8ca1f2b 100644 --- a/openedx_filters/learning/filters.py +++ b/openedx_filters/learning/filters.py @@ -1166,6 +1166,7 @@ def run_filter(cls, serialized_courserun: dict[str, Any]) -> dict[str, Any] | No return data.get("serialized_courserun") +# DEPR-38432: This filter should be handled as part of mentioned deprecation ticket class InstructorDashboardRenderStarted(OpenEdxPublicFilter): """ Filter used to modify the instructor dashboard rendering process. @@ -1174,6 +1175,10 @@ class InstructorDashboardRenderStarted(OpenEdxPublicFilter): This filter is triggered when an instructor requests to view the dashboard, just before the page is rendered allowing the filter to act on the context and the template used to render the page. + There's a new version of this filter (org.openedx.learning.instructor.dashboard.tabs.requested.v1) + that applies to the instructor dashboard app, + but this filter will still be triggered for the legacy instructor dashboard. + Filter Type: org.openedx.learning.instructor.dashboard.render.started.v1 @@ -1283,6 +1288,75 @@ def run_filter(cls, context: dict[str, Any], template_name: str) -> tuple[dict[s return data.get("context"), data.get("template_name") +class InstructorDashboardTabsRequested(OpenEdxPublicFilter): + """ + Filter used to modify the instructor dashboard tabs generation process. + + Purpose: + This filter is triggered when instructor dashboard tabs are generated, allowing plugins + to add, modify, or remove tabs from the instructor dashboard in the MFE. + + There's an old version of this filter (org.openedx.learning.instructor.dashboard.render.started.v1) + that applies to the legacy instructor dashboard, but this new filter is specifically designed + to work with the instructor dashboard app and its tabs generation process. + + Filter Type: + org.openedx.learning.instructor.dashboard.tabs.requested.v1 + + Trigger: + - Repository: openedx/edx-platform + - Path: lms/djangoapps/instructor/views/serializers_v2.py + - Function or Method: CourseInformationSerializerV2.get_tabs + """ + + filter_type = "org.openedx.learning.instructor.dashboard.tabs.requested.v1" + + class PreventTabsGeneration(OpenEdxFilterException): + """ + Raise to prevent the normal tabs generation process and optionally provide custom tabs. + + This exception is propagated to the instructor dashboard serializer and handled to stop + the normal tab generation process. Plugins can provide their own tabs list. + + Attributes: + message (str): error message for the exception. + tabs (list): optional custom tabs list to use instead. + """ + + def __init__(self, message: str, tabs: Optional[list] = None) -> None: + """ + Initialize the exception with the message and optional custom tabs. + + Arguments: + message (str): error message for the exception. + tabs (list): optional custom tabs list to use instead. + """ + super().__init__(message, tabs=tabs) + + @classmethod + def run_filter( + cls, + tabs: list, + user: Any, + course_key: CourseKey + ) -> list | None: + """ + Process the tabs list using the configured pipeline steps to modify instructor dashboard tabs. + Arguments: + tabs (list): List of tab dictionaries containing tab_id, title, url, sort_order, etc. + user (User): Django User object (usually an instructor or staff member). + course_key (CourseKey): Course key for the instructor dashboard. + Returns: + list | None: Tab dictionaries, possibly modified by pipeline steps, or None if not provided. + """ + data = super().run_pipeline( + tabs=tabs, + user=user, + course_key=course_key + ) + return data.get("tabs") + + class ORASubmissionViewRenderStarted(OpenEdxPublicFilter): """ Filter used to modify the submission view rendering process. diff --git a/openedx_filters/learning/tests/test_filters.py b/openedx_filters/learning/tests/test_filters.py index 8ca9bd0..b537e87 100644 --- a/openedx_filters/learning/tests/test_filters.py +++ b/openedx_filters/learning/tests/test_filters.py @@ -26,6 +26,7 @@ GradeEventContextRequested, IDVPageURLRequested, InstructorDashboardRenderStarted, + InstructorDashboardTabsRequested, ORASubmissionViewRenderStarted, RenderXBlockStarted, ScheduleQuerySetRequested, @@ -867,3 +868,97 @@ def test_filter_type(self): AccountSettingsReadOnlyFieldsRequested.filter_type, "org.openedx.learning.account.settings.read_only_fields.requested.v1", ) + + +@ddt +class TestInstructorDashboardTabsRequested(TestCase): + """ + Test class to verify standard behavior of the InstructorDashboardTabsRequested filter. + + You'll find test suites for: + - InstructorDashboardTabsRequested + """ + + def test_run_filter_returns_unchanged_tabs_when_no_pipeline(self): + """ + Test InstructorDashboardTabsRequested filter behavior under normal conditions. + + When no pipeline steps are configured, run_filter returns the original tabs unchanged. + + Expected behavior: + - The filter should return the tabs list unchanged. + """ + tabs = [ + {"tab_id": "courseware", "title": "Course", "url": "/course/123", "sort_order": 0}, + {"tab_id": "instructor", "title": "Instructor", "url": "/instructor/123", "sort_order": 1}, + ] + user = Mock() + course_key = Mock() + + with patch("openedx_filters.tooling.OpenEdxPublicFilter.run_pipeline") as mock_run_pipeline: + mock_run_pipeline.return_value = {"tabs": tabs, "user": user, "course_key": course_key} + result_tabs = InstructorDashboardTabsRequested.run_filter( + tabs=tabs, user=user, course_key=course_key + ) + + self.assertEqual(result_tabs, tabs) + + def test_filter_type(self): + """Test that the filter type is properly set.""" + self.assertEqual( + InstructorDashboardTabsRequested.filter_type, + "org.openedx.learning.instructor.dashboard.tabs.requested.v1", + ) + + def test_run_filter_with_pipeline_returning_dict_with_tabs(self): + """ + Test InstructorDashboardTabsRequested filter when pipeline returns dict with tabs. + + Expected behavior: + - The filter should return the filtered tabs from the pipeline result. + """ + tabs = [ + {"tab_id": "courseware", "title": "Course", "url": "/course/123", "sort_order": 0}, + ] + modified_tabs = [ + {"tab_id": "custom", "title": "Custom Tab", "url": "/custom/123", "sort_order": 0}, + ] + user = Mock() + course_key = Mock() + + with patch("openedx_filters.tooling.OpenEdxPublicFilter.run_pipeline") as mock_run_pipeline: + mock_run_pipeline.return_value = { + "tabs": modified_tabs, "user": user, "course_key": course_key + } + result_tabs = InstructorDashboardTabsRequested.run_filter( + tabs=tabs, user=user, course_key=course_key + ) + + self.assertEqual(result_tabs, modified_tabs) + + @data( + ( + InstructorDashboardTabsRequested.PreventTabsGeneration, + { + "message": "Custom tabs provided by plugin", + "tabs": [{"tab_id": "custom", "title": "Custom", "url": "/custom", "sort_order": 0}], + } + ), + ( + InstructorDashboardTabsRequested.PreventTabsGeneration, + { + "message": "Disable tab generation", + } + ), + ) + @unpack + def test_prevent_tabs_generation_exception(self, exception_class, attributes): + """ + Test that the PreventTabsGeneration exception can be initialized with required attributes. + + Expected behavior: + - The exception must have the attributes specified. + """ + exception = exception_class(**attributes) + + self.assertLessEqual(attributes.items(), exception.__dict__.items())