diff --git a/enterprise/filters/__init__.py b/enterprise/filters/__init__.py new file mode 100644 index 000000000..1bb8bf6d7 --- /dev/null +++ b/enterprise/filters/__init__.py @@ -0,0 +1 @@ +# empty diff --git a/enterprise/filters/enrollment.py b/enterprise/filters/enrollment.py new file mode 100644 index 000000000..7686612c3 --- /dev/null +++ b/enterprise/filters/enrollment.py @@ -0,0 +1,67 @@ +""" +Pipeline steps for the course enrollment filter. +""" +import logging + +from openedx_filters.filters import PipelineStep + +from enterprise.models import EnterpriseCustomerUser + +log = logging.getLogger(__name__) + + +class EnterpriseEnrollmentPostProcessor(PipelineStep): + """ + Post-enrollment pipeline step: notify enterprise API and record consent. + + When a user who is 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. + """ + + def run_filter(self, user, course_key, mode): # pylint: disable=arguments-differ + """ + Post enterprise enrollment and consent if the user is an enterprise customer user. + """ + # Deferred imports — will be replaced with internal paths in epic 17. + from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel + EnterpriseApiServiceClient, + ConsentApiServiceClient, + ) + + enterprise_customer_users = EnterpriseCustomerUser.objects.filter(user=user) + if not enterprise_customer_users.exists(): + return {'user': user, 'course_key': course_key, 'mode': mode} + + enterprise_customer_user = enterprise_customer_users.first() + 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/tests/filters/__init__.py b/tests/filters/__init__.py new file mode 100644 index 000000000..5a5f3f9ac --- /dev/null +++ b/tests/filters/__init__.py @@ -0,0 +1 @@ +"""Tests for enterprise filter pipeline steps.""" diff --git a/tests/filters/test_enrollment.py b/tests/filters/test_enrollment.py new file mode 100644 index 000000000..525814a89 --- /dev/null +++ b/tests/filters/test_enrollment.py @@ -0,0 +1,206 @@ +""" +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.created.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__ = lambda self: "course-v1:org+course+run" + mode = "verified" + + mock_qs = MagicMock() + mock_qs.exists.return_value = False + + 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.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.exists.return_value = True + mock_qs.first.return_value = mock_ecu + + course_key = MagicMock() + course_key.__str__ = lambda self: "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.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_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.exists.return_value = True + mock_qs.first.return_value = mock_ecu + + course_key = MagicMock() + course_key.__str__ = lambda self: "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.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.exists.return_value = True + mock_qs.first.return_value = mock_ecu + + course_key = MagicMock() + course_key.__str__ = lambda self: "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.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", + )