From 8b62c093199613aea9b7100d219dcf2b555ae374 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Tue, 3 Mar 2026 20:35:12 -0800 Subject: [PATCH] feat: add DashboardContextEnricher pipeline step for student dashboard filter ENT-11569 --- enterprise/filters/__init__.py | 0 enterprise/filters/dashboard.py | 59 ++++++++++++ tests/filters/__init__.py | 0 tests/filters/test_dashboard.py | 162 ++++++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 enterprise/filters/__init__.py create mode 100644 enterprise/filters/dashboard.py create mode 100644 tests/filters/__init__.py create mode 100644 tests/filters/test_dashboard.py diff --git a/enterprise/filters/__init__.py b/enterprise/filters/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/enterprise/filters/dashboard.py b/enterprise/filters/dashboard.py new file mode 100644 index 0000000000..5745493257 --- /dev/null +++ b/enterprise/filters/dashboard.py @@ -0,0 +1,59 @@ +""" +Pipeline steps for the student dashboard filter. +""" +import logging + +from openedx_filters.filters import PipelineStep + +log = logging.getLogger(__name__) + +# These imports will be replaced with internal paths in epic 17 when enterprise_support is +# migrated into edx-enterprise. +try: + from openedx.features.enterprise_support.api import ( + get_dashboard_consent_notification, + get_enterprise_learner_portal_context, + ) + from openedx.features.enterprise_support.utils import is_enterprise_learner +except ImportError: + get_dashboard_consent_notification = None + get_enterprise_learner_portal_context = None + is_enterprise_learner = None + + +class DashboardContextEnricher(PipelineStep): + """ + Enrich the student dashboard context with enterprise-specific data. + + Injects: enterprise_message, consent_required_courses, is_enterprise_user, and + enterprise learner portal context keys. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """ + Inject enterprise data into the dashboard context. + """ + request = context.get('request') + user = context.get('user') + course_enrollments = context.get('course_enrollment_pairs', []) + + if user is None: + return {'context': context, 'template_name': template_name} + + try: + enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments) + except Exception: # pylint: disable=broad-except + log.warning('Failed to fetch enterprise dashboard consent notification.', exc_info=True) + enterprise_message = '' + + try: + enterprise_learner_portal_context = get_enterprise_learner_portal_context(request) + except Exception: # pylint: disable=broad-except + log.warning('Failed to fetch enterprise learner portal context.', exc_info=True) + enterprise_learner_portal_context = {} + + context['enterprise_message'] = enterprise_message + context['is_enterprise_user'] = is_enterprise_learner(user) + context.update(enterprise_learner_portal_context) + + return {'context': context, 'template_name': template_name} diff --git a/tests/filters/__init__.py b/tests/filters/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/filters/test_dashboard.py b/tests/filters/test_dashboard.py new file mode 100644 index 0000000000..877f021b9d --- /dev/null +++ b/tests/filters/test_dashboard.py @@ -0,0 +1,162 @@ +""" +Tests for enterprise.filters.dashboard pipeline step. +""" +from unittest.mock import MagicMock, patch + +from django.test import RequestFactory, TestCase + +from enterprise.filters.dashboard import DashboardContextEnricher + + +FILTER_TYPE = "org.openedx.learning.dashboard.render.started.v1" + +# Patch targets — names are bound in enterprise.filters.dashboard at import time. +_CONSENT_PATH = 'enterprise.filters.dashboard.get_dashboard_consent_notification' +_PORTAL_PATH = 'enterprise.filters.dashboard.get_enterprise_learner_portal_context' +_IS_ENTERPRISE_PATH = 'enterprise.filters.dashboard.is_enterprise_learner' + + +class TestDashboardContextEnricher(TestCase): + """ + Tests for DashboardContextEnricher pipeline step. + """ + + def _make_step(self): + return DashboardContextEnricher(FILTER_TYPE, []) + + def _make_request(self): + factory = RequestFactory() + return factory.get('/') + + def _make_user(self): + user = MagicMock() + user.id = 42 + return user + + @patch(_IS_ENTERPRISE_PATH, return_value=True) + @patch( + _PORTAL_PATH, + return_value={'enterprise_portal_url': 'https://portal.example.com'}, + ) + @patch(_CONSENT_PATH, return_value='You must consent.') + def test_enriches_context_for_enterprise_learner( + self, mock_consent, mock_portal, mock_is_enterprise + ): + """ + For an enterprise learner, the step injects enterprise_message, is_enterprise_user, + and enterprise portal keys into the dashboard context. + """ + request = self._make_request() + user = self._make_user() + enrollments = [MagicMock()] + context = { + 'request': request, + 'user': user, + 'course_enrollment_pairs': enrollments, + } + template_name = 'student/dashboard.html' + + step = self._make_step() + result = step.run_filter(context=context, template_name=template_name) + + assert result['template_name'] == template_name + result_context = result['context'] + assert result_context['enterprise_message'] == 'You must consent.' + assert result_context['is_enterprise_user'] is True + assert result_context['enterprise_portal_url'] == 'https://portal.example.com' + + mock_consent.assert_called_once_with(request, user, enrollments) + mock_portal.assert_called_once_with(request) + mock_is_enterprise.assert_called_once_with(user) + + @patch(_IS_ENTERPRISE_PATH, return_value=False) + @patch(_PORTAL_PATH, return_value={}) + @patch(_CONSENT_PATH, return_value='') + def test_enriches_context_for_non_enterprise_learner( + self, mock_consent, mock_portal, mock_is_enterprise + ): + """ + For a non-enterprise learner, is_enterprise_user is False and enterprise_message is empty. + """ + request = self._make_request() + user = self._make_user() + context = { + 'request': request, + 'user': user, + } + template_name = 'student/dashboard.html' + + step = self._make_step() + result = step.run_filter(context=context, template_name=template_name) + + result_context = result['context'] + assert result_context['enterprise_message'] == '' + assert result_context['is_enterprise_user'] is False + + def test_returns_unchanged_context_when_no_user(self): + """ + When no user is present in context, the step returns context unchanged without calling + any enterprise helper functions. + """ + context = {'request': MagicMock()} + template_name = 'student/dashboard.html' + + step = self._make_step() + result = step.run_filter(context=context, template_name=template_name) + + assert result == {'context': context, 'template_name': template_name} + assert 'enterprise_message' not in result['context'] + assert 'is_enterprise_user' not in result['context'] + + @patch(_IS_ENTERPRISE_PATH, return_value=True) + @patch(_PORTAL_PATH, side_effect=Exception('portal error')) + @patch(_CONSENT_PATH, side_effect=Exception('consent error')) + def test_handles_exceptions_gracefully( + self, mock_consent, mock_portal, mock_is_enterprise + ): + """ + When enterprise helper functions raise exceptions, the step logs warnings and continues + with fallback values, not propagating the exception. + """ + request = self._make_request() + user = self._make_user() + context = { + 'request': request, + 'user': user, + } + template_name = 'student/dashboard.html' + + step = self._make_step() + # Should not raise + result = step.run_filter(context=context, template_name=template_name) + + result_context = result['context'] + assert result_context['enterprise_message'] == '' + assert 'is_enterprise_user' in result_context + + @patch(_IS_ENTERPRISE_PATH, return_value=True) + @patch( + _PORTAL_PATH, + return_value={'enterprise_portal_url': 'https://portal.example.com'}, + ) + @patch(_CONSENT_PATH, return_value='') + def test_uses_empty_list_when_course_enrollment_pairs_missing( + self, mock_consent, mock_portal, mock_is_enterprise + ): + """ + When course_enrollment_pairs is not in context, an empty list is passed to + get_dashboard_consent_notification. + """ + request = self._make_request() + user = self._make_user() + context = { + 'request': request, + 'user': user, + # no 'course_enrollment_pairs' key + } + template_name = 'student/dashboard.html' + + step = self._make_step() + step.run_filter(context=context, template_name=template_name) + + mock_consent.assert_called_once_with(request, user, [])