diff --git a/enterprise/filters/enrollment.py b/enterprise/filters/enrollment.py new file mode 100644 index 000000000..d4042a126 --- /dev/null +++ b/enterprise/filters/enrollment.py @@ -0,0 +1,77 @@ +""" +Pipeline steps for the course enrollment filter. +""" +import logging +from typing import Any + +from openedx_filters.filters import PipelineStep + +from django.contrib.auth.base_user import AbstractBaseUser + +from enterprise.models import EnterpriseCustomerUser + +log = logging.getLogger(__name__) + + +class EnterpriseEnrollmentPostProcessor(PipelineStep): + """ + Post-enrollment pipeline step: notify enterprise API and record consent. + + When an enterprise customer user enrolls in a course, this step calls the enterprise and consent + API clients to post the enrollment and provide consent on behalf of the enterprise customer. + + This step is intended to be registered as a pipeline step for the + ``org.openedx.learning.course.enrollment.started.v1`` filter. + """ + + def run_filter(self, user: AbstractBaseUser, course_key: Any, mode: str) -> dict[str, Any]: # pylint: disable=arguments-differ + """ + Post enterprise enrollment and consent if the user is an enterprise customer user. + """ + try: + from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel + ConsentApiServiceClient, + EnterpriseApiServiceClient, + ) + except ImportError: + return {'user': user, 'course_key': course_key, 'mode': mode} + + enterprise_customer_user = ( + EnterpriseCustomerUser.objects.select_related('enterprise_customer') + .filter(user=user) + .first() + ) + if enterprise_customer_user is None: + return {'user': user, 'course_key': course_key, 'mode': mode} + + enterprise_customer_uuid = str(enterprise_customer_user.enterprise_customer.uuid) + username = user.username + course_id = str(course_key) + + try: + EnterpriseApiServiceClient().post_enterprise_course_enrollment( + username, + course_id, + consent_granted=True, + ) + except Exception: # pylint: disable=broad-except + log.exception( + "Failed to post enterprise course enrollment for user %s in course %s.", + username, + course_id, + ) + + try: + ConsentApiServiceClient().provide_consent( + username=username, + course_id=course_id, + enterprise_customer_uuid=enterprise_customer_uuid, + ) + except Exception: # pylint: disable=broad-except + log.exception( + "Failed to provide enterprise consent for user %s in course %s.", + username, + course_id, + ) + + return {'user': user, 'course_key': course_key, 'mode': mode} diff --git a/enterprise/settings/common.py b/enterprise/settings/common.py index 94bf0a14a..12ebc3eae 100644 --- a/enterprise/settings/common.py +++ b/enterprise/settings/common.py @@ -20,6 +20,10 @@ "fail_silently": False, "pipeline": ["enterprise.filters.grades.GradeEventContextEnricher"], }, + "org.openedx.learning.course.enrollment.started.v1": { + "fail_silently": False, + "pipeline": ["enterprise.filters.enrollment.EnterpriseEnrollmentPostProcessor"], + }, } diff --git a/tests/filters/test_enrollment.py b/tests/filters/test_enrollment.py new file mode 100644 index 000000000..11084564f --- /dev/null +++ b/tests/filters/test_enrollment.py @@ -0,0 +1,219 @@ +""" +Tests for enterprise.filters.enrollment pipeline step. +""" +import sys +import uuid +from types import ModuleType +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +from enterprise.filters.enrollment import EnterpriseEnrollmentPostProcessor +from test_utils.factories import UserFactory + + +def _make_mock_api_module(): + """ + Return a fake ``openedx.features.enterprise_support.api`` module with mock clients. + """ + mock_enterprise_client = MagicMock() + mock_consent_client = MagicMock() + + mock_module = ModuleType("openedx.features.enterprise_support.api") + mock_module.EnterpriseApiServiceClient = MagicMock(return_value=mock_enterprise_client) + mock_module.ConsentApiServiceClient = MagicMock(return_value=mock_consent_client) + return mock_module, mock_enterprise_client, mock_consent_client + + +def _make_openedx_modules(): + """ + Build a minimal set of sys.modules entries for the openedx namespace. + """ + entries = {} + for name in ( + "openedx", + "openedx.features", + "openedx.features.enterprise_support", + ): + entries[name] = ModuleType(name) + return entries + + +class TestEnterpriseEnrollmentPostProcessor(TestCase): + """ + Tests for EnterpriseEnrollmentPostProcessor pipeline step. + """ + + def _make_step(self): + return EnterpriseEnrollmentPostProcessor( + "org.openedx.learning.course.enrollment.started.v1", + [], + ) + + def test_returns_unchanged_args_for_non_enterprise_user(self): + """ + When the user is not linked to an enterprise customer, return the + arguments unchanged without calling any API clients. + """ + user = UserFactory.build(username="regular-user") + course_key = MagicMock() + course_key.__str__.return_value = "course-v1:org+course+run" + mode = "verified" + + mock_qs = MagicMock() + mock_qs.first.return_value = None + + mock_api_module, mock_enterprise_client, mock_consent_client = _make_mock_api_module() + extra_modules = _make_openedx_modules() + extra_modules["openedx.features.enterprise_support.api"] = mock_api_module + + with patch.dict(sys.modules, extra_modules), \ + patch("enterprise.filters.enrollment.EnterpriseCustomerUser.objects") as mock_objects: + mock_objects.select_related.return_value.filter.return_value = mock_qs + step = self._make_step() + result = step.run_filter(user=user, course_key=course_key, mode=mode) + + assert result == {"user": user, "course_key": course_key, "mode": mode} + mock_enterprise_client.post_enterprise_course_enrollment.assert_not_called() + mock_consent_client.provide_consent.assert_not_called() + + def test_calls_api_clients_for_enterprise_user(self): + """ + When the user is linked to an enterprise customer, call both + EnterpriseApiServiceClient and ConsentApiServiceClient. + """ + user = UserFactory.build(username="enterprise-learner") + enterprise_uuid = uuid.uuid4() + + mock_enterprise_customer = MagicMock() + mock_enterprise_customer.uuid = enterprise_uuid + + mock_ecu = MagicMock() + mock_ecu.enterprise_customer = mock_enterprise_customer + + mock_qs = MagicMock() + mock_qs.first.return_value = mock_ecu + + course_key = MagicMock() + course_key.__str__.return_value = "course-v1:TestOrg+course+run" + mode = "audit" + + mock_api_module, mock_enterprise_client, mock_consent_client = _make_mock_api_module() + extra_modules = _make_openedx_modules() + extra_modules["openedx.features.enterprise_support.api"] = mock_api_module + + with patch.dict(sys.modules, extra_modules), \ + patch("enterprise.filters.enrollment.EnterpriseCustomerUser.objects") as mock_objects: + mock_objects.select_related.return_value.filter.return_value = mock_qs + step = self._make_step() + result = step.run_filter(user=user, course_key=course_key, mode=mode) + + assert result == {"user": user, "course_key": course_key, "mode": mode} + mock_enterprise_client.post_enterprise_course_enrollment.assert_called_once_with( + "enterprise-learner", + "course-v1:TestOrg+course+run", + consent_granted=True, + ) + mock_consent_client.provide_consent.assert_called_once_with( + username="enterprise-learner", + course_id="course-v1:TestOrg+course+run", + enterprise_customer_uuid=str(enterprise_uuid), + ) + + def test_returns_unchanged_args_when_openedx_not_installed(self): + """ + When openedx.features.enterprise_support.api is not importable (e.g. outside + the LMS), run_filter returns the arguments unchanged without raising. + """ + user = UserFactory.build(username="any-user") + course_key = MagicMock() + mode = "audit" + + # Ensure the module is absent so the ImportError branch is exercised. + with patch.dict(sys.modules, {"openedx.features.enterprise_support.api": None}): + step = self._make_step() + result = step.run_filter(user=user, course_key=course_key, mode=mode) + + assert result == {"user": user, "course_key": course_key, "mode": mode} + + def test_logs_exception_when_enterprise_api_call_fails(self): + """ + When the enterprise API client raises an exception, it is logged and + execution continues to the consent API call. + """ + user = UserFactory.build(username="learner") + enterprise_uuid = uuid.uuid4() + + mock_enterprise_customer = MagicMock() + mock_enterprise_customer.uuid = enterprise_uuid + + mock_ecu = MagicMock() + mock_ecu.enterprise_customer = mock_enterprise_customer + + mock_qs = MagicMock() + mock_qs.first.return_value = mock_ecu + + course_key = MagicMock() + course_key.__str__.return_value = "course-v1:org+course+run" + mode = "audit" + + mock_api_module, mock_enterprise_client, mock_consent_client = _make_mock_api_module() + mock_enterprise_client.post_enterprise_course_enrollment.side_effect = Exception("boom") + extra_modules = _make_openedx_modules() + extra_modules["openedx.features.enterprise_support.api"] = mock_api_module + + with patch.dict(sys.modules, extra_modules), \ + patch("enterprise.filters.enrollment.EnterpriseCustomerUser.objects") as mock_objects, \ + patch("enterprise.filters.enrollment.log") as mock_log: + mock_objects.select_related.return_value.filter.return_value = mock_qs + step = self._make_step() + result = step.run_filter(user=user, course_key=course_key, mode=mode) + + assert result == {"user": user, "course_key": course_key, "mode": mode} + mock_log.exception.assert_any_call( + "Failed to post enterprise course enrollment for user %s in course %s.", + "learner", + "course-v1:org+course+run", + ) + # Consent API should still be called despite enrollment API failure + mock_consent_client.provide_consent.assert_called_once() + + def test_logs_exception_when_consent_api_call_fails(self): + """ + When the consent API client raises an exception, it is logged and + the filter still returns the original arguments. + """ + user = UserFactory.build(username="learner2") + enterprise_uuid = uuid.uuid4() + + mock_enterprise_customer = MagicMock() + mock_enterprise_customer.uuid = enterprise_uuid + + mock_ecu = MagicMock() + mock_ecu.enterprise_customer = mock_enterprise_customer + + mock_qs = MagicMock() + mock_qs.first.return_value = mock_ecu + + course_key = MagicMock() + course_key.__str__.return_value = "course-v1:org+course+run" + mode = "verified" + + mock_api_module, _mock_enterprise_client, mock_consent_client = _make_mock_api_module() + mock_consent_client.provide_consent.side_effect = Exception("consent-boom") + extra_modules = _make_openedx_modules() + extra_modules["openedx.features.enterprise_support.api"] = mock_api_module + + with patch.dict(sys.modules, extra_modules), \ + patch("enterprise.filters.enrollment.EnterpriseCustomerUser.objects") as mock_objects, \ + patch("enterprise.filters.enrollment.log") as mock_log: + mock_objects.select_related.return_value.filter.return_value = mock_qs + step = self._make_step() + result = step.run_filter(user=user, course_key=course_key, mode=mode) + + assert result == {"user": user, "course_key": course_key, "mode": mode} + mock_log.exception.assert_any_call( + "Failed to provide enterprise consent for user %s in course %s.", + "learner2", + "course-v1:org+course+run", + ) diff --git a/tests/test_enterprise/test_settings.py b/tests/test_enterprise/test_settings.py index fa70a6067..989630c86 100644 --- a/tests/test_enterprise/test_settings.py +++ b/tests/test_enterprise/test_settings.py @@ -7,7 +7,7 @@ import ddt import pytest -from enterprise.settings.common import _merge_filters_config, plugin_settings +from enterprise.settings.common import ENTERPRISE_FILTERS_CONFIG, _merge_filters_config, plugin_settings class TestPluginSettingsPipelineInjection(unittest.TestCase): @@ -212,3 +212,48 @@ def test_additions_dict_isolated_from_subsequent_mutation(self): existing[FILTER_A]['pipeline'].append(STEP_Y) assert additions[FILTER_A]['pipeline'] == [STEP_X] + + +class TestEnterpriseFiltersConfigRegistration(unittest.TestCase): + """ + Tests that ENTERPRISE_FILTERS_CONFIG declares the expected filter types and + that plugin_settings() injects them into OPEN_EDX_FILTERS_CONFIG. + """ + + ENROLLMENT_STARTED_FILTER = "org.openedx.learning.course.enrollment.started.v1" + ENROLLMENT_STEP = "enterprise.filters.enrollment.EnterpriseEnrollmentPostProcessor" + + def _make_settings(self): + return SimpleNamespace(ENABLE_ENTERPRISE_INTEGRATION=True) + + def test_enrollment_started_filter_is_declared_in_enterprise_filters_config(self): + """ + ENTERPRISE_FILTERS_CONFIG must contain an entry for the CourseEnrollmentStarted + filter pointing at EnterpriseEnrollmentPostProcessor. + """ + assert self.ENROLLMENT_STARTED_FILTER in ENTERPRISE_FILTERS_CONFIG + pipeline = ENTERPRISE_FILTERS_CONFIG[self.ENROLLMENT_STARTED_FILTER].get("pipeline", []) + assert self.ENROLLMENT_STEP in pipeline + + def test_enrollment_started_filter_injected_by_plugin_settings(self): + """ + plugin_settings() must merge the enrollment filter into OPEN_EDX_FILTERS_CONFIG. + """ + settings = self._make_settings() + plugin_settings(settings) + + filters_config = getattr(settings, "OPEN_EDX_FILTERS_CONFIG", {}) + assert self.ENROLLMENT_STARTED_FILTER in filters_config + pipeline = filters_config[self.ENROLLMENT_STARTED_FILTER].get("pipeline", []) + assert self.ENROLLMENT_STEP in pipeline + + def test_enrollment_filter_not_injected_when_enterprise_integration_disabled(self): + """ + When ENABLE_ENTERPRISE_INTEGRATION is False, plugin_settings() must not + add the enrollment filter to OPEN_EDX_FILTERS_CONFIG. + """ + settings = SimpleNamespace(ENABLE_ENTERPRISE_INTEGRATION=False) + plugin_settings(settings) + + filters_config = getattr(settings, "OPEN_EDX_FILTERS_CONFIG", {}) + assert self.ENROLLMENT_STARTED_FILTER not in filters_config