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
1 change: 1 addition & 0 deletions enterprise/filters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# empty
67 changes: 67 additions & 0 deletions enterprise/filters/enrollment.py
Original file line number Diff line number Diff line change
@@ -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}
1 change: 1 addition & 0 deletions tests/filters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for enterprise filter pipeline steps."""
206 changes: 206 additions & 0 deletions tests/filters/test_enrollment.py
Original file line number Diff line number Diff line change
@@ -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",
)
Loading