Skip to content

Commit 44cccd5

Browse files
authored
feat: add EnterpriseEnrollmentPostProcessor pipeline step for CourseEnrollmentStarted filter
1 parent 2216a75 commit 44cccd5

4 files changed

Lines changed: 279 additions & 0 deletions

File tree

enterprise/filters/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# empty

enterprise/filters/enrollment.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""
2+
Pipeline steps for the course enrollment filter.
3+
"""
4+
import logging
5+
from typing import Any
6+
7+
from django.contrib.auth.base_user import AbstractBaseUser
8+
from openedx_filters.filters import PipelineStep
9+
10+
from enterprise.models import EnterpriseCustomerUser
11+
12+
log = logging.getLogger(__name__)
13+
14+
15+
class EnterpriseEnrollmentPostProcessor(PipelineStep):
16+
"""
17+
Post-enrollment pipeline step: notify enterprise API and record consent.
18+
19+
When a user who is angit enterprise customer user enrolls in a course, this step calls
20+
the enterprise and consent API clients to post the enrollment and provide consent on
21+
behalf of the enterprise customer.
22+
"""
23+
24+
def run_filter(self, user: AbstractBaseUser, course_key: Any, mode: str) -> dict[str, Any]: # pylint: disable=arguments-differ
25+
"""
26+
Post enterprise enrollment and consent if the user is an enterprise customer user.
27+
"""
28+
try:
29+
from openedx.features.enterprise_support.api import ( # pylint: disable=import-outside-toplevel
30+
ConsentApiServiceClient,
31+
EnterpriseApiServiceClient,
32+
)
33+
except ImportError:
34+
return {'user': user, 'course_key': course_key, 'mode': mode}
35+
36+
enterprise_customer_user = (
37+
EnterpriseCustomerUser.objects.select_related('enterprise_customer')
38+
.filter(user=user)
39+
.first()
40+
)
41+
if enterprise_customer_user is None:
42+
return {'user': user, 'course_key': course_key, 'mode': mode}
43+
44+
enterprise_customer_uuid = str(enterprise_customer_user.enterprise_customer.uuid)
45+
username = user.username
46+
course_id = str(course_key)
47+
48+
try:
49+
EnterpriseApiServiceClient().post_enterprise_course_enrollment(
50+
username,
51+
course_id,
52+
consent_granted=True,
53+
)
54+
except Exception: # pylint: disable=broad-except
55+
log.exception(
56+
"Failed to post enterprise course enrollment for user %s in course %s.",
57+
username,
58+
course_id,
59+
)
60+
61+
try:
62+
ConsentApiServiceClient().provide_consent(
63+
username=username,
64+
course_id=course_id,
65+
enterprise_customer_uuid=enterprise_customer_uuid,
66+
)
67+
except Exception: # pylint: disable=broad-except
68+
log.exception(
69+
"Failed to provide enterprise consent for user %s in course %s.",
70+
username,
71+
course_id,
72+
)
73+
74+
return {'user': user, 'course_key': course_key, 'mode': mode}

tests/filters/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for enterprise filter pipeline steps."""

tests/filters/test_enrollment.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""
2+
Tests for enterprise.filters.enrollment pipeline step.
3+
"""
4+
import sys
5+
import uuid
6+
from types import ModuleType
7+
from unittest.mock import MagicMock, patch
8+
9+
from django.test import TestCase
10+
11+
from enterprise.filters.enrollment import EnterpriseEnrollmentPostProcessor
12+
from test_utils.factories import UserFactory
13+
14+
15+
def _make_mock_api_module():
16+
"""
17+
Return a fake ``openedx.features.enterprise_support.api`` module with mock clients.
18+
"""
19+
mock_enterprise_client = MagicMock()
20+
mock_consent_client = MagicMock()
21+
22+
mock_module = ModuleType("openedx.features.enterprise_support.api")
23+
mock_module.EnterpriseApiServiceClient = MagicMock(return_value=mock_enterprise_client)
24+
mock_module.ConsentApiServiceClient = MagicMock(return_value=mock_consent_client)
25+
return mock_module, mock_enterprise_client, mock_consent_client
26+
27+
28+
def _make_openedx_modules():
29+
"""
30+
Build a minimal set of sys.modules entries for the openedx namespace.
31+
"""
32+
entries = {}
33+
for name in (
34+
"openedx",
35+
"openedx.features",
36+
"openedx.features.enterprise_support",
37+
):
38+
entries[name] = ModuleType(name)
39+
return entries
40+
41+
42+
class TestEnterpriseEnrollmentPostProcessor(TestCase):
43+
"""
44+
Tests for EnterpriseEnrollmentPostProcessor pipeline step.
45+
"""
46+
47+
def _make_step(self):
48+
return EnterpriseEnrollmentPostProcessor(
49+
"org.openedx.learning.course.enrollment.created.v1",
50+
[],
51+
)
52+
53+
def test_returns_unchanged_args_for_non_enterprise_user(self):
54+
"""
55+
When the user is not linked to an enterprise customer, return the
56+
arguments unchanged without calling any API clients.
57+
"""
58+
user = UserFactory.build(username="regular-user")
59+
course_key = MagicMock()
60+
course_key.__str__.return_value = "course-v1:org+course+run"
61+
mode = "verified"
62+
63+
mock_qs = MagicMock()
64+
mock_qs.first.return_value = None
65+
66+
mock_api_module, mock_enterprise_client, mock_consent_client = _make_mock_api_module()
67+
extra_modules = _make_openedx_modules()
68+
extra_modules["openedx.features.enterprise_support.api"] = mock_api_module
69+
70+
with patch.dict(sys.modules, extra_modules), \
71+
patch("enterprise.filters.enrollment.EnterpriseCustomerUser.objects") as mock_objects:
72+
mock_objects.select_related.return_value.filter.return_value = mock_qs
73+
step = self._make_step()
74+
result = step.run_filter(user=user, course_key=course_key, mode=mode)
75+
76+
assert result == {"user": user, "course_key": course_key, "mode": mode}
77+
mock_enterprise_client.post_enterprise_course_enrollment.assert_not_called()
78+
mock_consent_client.provide_consent.assert_not_called()
79+
80+
def test_calls_api_clients_for_enterprise_user(self):
81+
"""
82+
When the user is linked to an enterprise customer, call both
83+
EnterpriseApiServiceClient and ConsentApiServiceClient.
84+
"""
85+
user = UserFactory.build(username="enterprise-learner")
86+
enterprise_uuid = uuid.uuid4()
87+
88+
mock_enterprise_customer = MagicMock()
89+
mock_enterprise_customer.uuid = enterprise_uuid
90+
91+
mock_ecu = MagicMock()
92+
mock_ecu.enterprise_customer = mock_enterprise_customer
93+
94+
mock_qs = MagicMock()
95+
mock_qs.first.return_value = mock_ecu
96+
97+
course_key = MagicMock()
98+
course_key.__str__.return_value = "course-v1:TestOrg+course+run"
99+
mode = "audit"
100+
101+
mock_api_module, mock_enterprise_client, mock_consent_client = _make_mock_api_module()
102+
extra_modules = _make_openedx_modules()
103+
extra_modules["openedx.features.enterprise_support.api"] = mock_api_module
104+
105+
with patch.dict(sys.modules, extra_modules), \
106+
patch("enterprise.filters.enrollment.EnterpriseCustomerUser.objects") as mock_objects:
107+
mock_objects.select_related.return_value.filter.return_value = mock_qs
108+
step = self._make_step()
109+
result = step.run_filter(user=user, course_key=course_key, mode=mode)
110+
111+
assert result == {"user": user, "course_key": course_key, "mode": mode}
112+
mock_enterprise_client.post_enterprise_course_enrollment.assert_called_once_with(
113+
"enterprise-learner",
114+
"course-v1:TestOrg+course+run",
115+
consent_granted=True,
116+
)
117+
mock_consent_client.provide_consent.assert_called_once_with(
118+
username="enterprise-learner",
119+
course_id="course-v1:TestOrg+course+run",
120+
enterprise_customer_uuid=str(enterprise_uuid),
121+
)
122+
123+
def test_logs_exception_when_enterprise_api_call_fails(self):
124+
"""
125+
When the enterprise API client raises an exception, it is logged and
126+
execution continues to the consent API call.
127+
"""
128+
user = UserFactory.build(username="learner")
129+
enterprise_uuid = uuid.uuid4()
130+
131+
mock_enterprise_customer = MagicMock()
132+
mock_enterprise_customer.uuid = enterprise_uuid
133+
134+
mock_ecu = MagicMock()
135+
mock_ecu.enterprise_customer = mock_enterprise_customer
136+
137+
mock_qs = MagicMock()
138+
mock_qs.first.return_value = mock_ecu
139+
140+
course_key = MagicMock()
141+
course_key.__str__.return_value = "course-v1:org+course+run"
142+
mode = "audit"
143+
144+
mock_api_module, mock_enterprise_client, mock_consent_client = _make_mock_api_module()
145+
mock_enterprise_client.post_enterprise_course_enrollment.side_effect = Exception("boom")
146+
extra_modules = _make_openedx_modules()
147+
extra_modules["openedx.features.enterprise_support.api"] = mock_api_module
148+
149+
with patch.dict(sys.modules, extra_modules), \
150+
patch("enterprise.filters.enrollment.EnterpriseCustomerUser.objects") as mock_objects, \
151+
patch("enterprise.filters.enrollment.log") as mock_log:
152+
mock_objects.select_related.return_value.filter.return_value = mock_qs
153+
step = self._make_step()
154+
result = step.run_filter(user=user, course_key=course_key, mode=mode)
155+
156+
assert result == {"user": user, "course_key": course_key, "mode": mode}
157+
mock_log.exception.assert_any_call(
158+
"Failed to post enterprise course enrollment for user %s in course %s.",
159+
"learner",
160+
"course-v1:org+course+run",
161+
)
162+
# Consent API should still be called despite enrollment API failure
163+
mock_consent_client.provide_consent.assert_called_once()
164+
165+
def test_logs_exception_when_consent_api_call_fails(self):
166+
"""
167+
When the consent API client raises an exception, it is logged and
168+
the filter still returns the original arguments.
169+
"""
170+
user = UserFactory.build(username="learner2")
171+
enterprise_uuid = uuid.uuid4()
172+
173+
mock_enterprise_customer = MagicMock()
174+
mock_enterprise_customer.uuid = enterprise_uuid
175+
176+
mock_ecu = MagicMock()
177+
mock_ecu.enterprise_customer = mock_enterprise_customer
178+
179+
mock_qs = MagicMock()
180+
mock_qs.first.return_value = mock_ecu
181+
182+
course_key = MagicMock()
183+
course_key.__str__.return_value = "course-v1:org+course+run"
184+
mode = "verified"
185+
186+
mock_api_module, _mock_enterprise_client, mock_consent_client = _make_mock_api_module()
187+
mock_consent_client.provide_consent.side_effect = Exception("consent-boom")
188+
extra_modules = _make_openedx_modules()
189+
extra_modules["openedx.features.enterprise_support.api"] = mock_api_module
190+
191+
with patch.dict(sys.modules, extra_modules), \
192+
patch("enterprise.filters.enrollment.EnterpriseCustomerUser.objects") as mock_objects, \
193+
patch("enterprise.filters.enrollment.log") as mock_log:
194+
mock_objects.select_related.return_value.filter.return_value = mock_qs
195+
step = self._make_step()
196+
result = step.run_filter(user=user, course_key=course_key, mode=mode)
197+
198+
assert result == {"user": user, "course_key": course_key, "mode": mode}
199+
mock_log.exception.assert_any_call(
200+
"Failed to provide enterprise consent for user %s in course %s.",
201+
"learner2",
202+
"course-v1:org+course+run",
203+
)

0 commit comments

Comments
 (0)