Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions enterprise/filters/enrollment.py
Original file line number Diff line number Diff line change
@@ -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}
4 changes: 4 additions & 0 deletions enterprise/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
}


Expand Down
219 changes: 219 additions & 0 deletions tests/filters/test_enrollment.py
Original file line number Diff line number Diff line change
@@ -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",
)
47 changes: 46 additions & 1 deletion tests/test_enterprise/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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