Skip to content

Commit e92d54e

Browse files
authored
feat: add EnterpriseEnrollmentPostProcessor pipeline step for CourseEnrollmentStarted filter
1 parent 67cbfdf commit e92d54e

7 files changed

Lines changed: 348 additions & 4 deletions

File tree

CHANGELOG.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@ Unreleased
1717
----------
1818
* nothing unreleased
1919

20+
[8.0.17] - 2026-06-07
21+
---------------------
22+
* feat: add EnterpriseEnrollmentPostProcessor pipeline step for CourseEnrollmentViewStarted filter (ENT-11570)
23+
2024
[8.0.16] - 2026-06-02
2125
---------------------
2226
* feat: remove enterprise_invite_admins_enabled feature flag and related conditional behavior (ENT-11269)
2327

24-
2528
[8.0.15] - 2026-05-15
2629
---------------------
2730
* feat: add GradeEventContextEnricher pipeline step for grade analytics (ENT-11563)

enterprise/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
Your project description goes here.
33
"""
44

5-
__version__ = "8.0.16"
5+
__version__ = "8.0.17"

enterprise/api_client/xpert_ai.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def chat_completion(prompt, role):
2828
}
2929

3030
body = {
31-
'messages': [{'role': role, 'content': prompt},],
31+
'messages': [{'role': role, 'content': prompt}, ],
3232
'client_id': settings.ENTERPRISE_ANALYSIS_CLIENT_ID,
3333
'system_message': settings.ENTERPRISE_ANALYSIS_SYSTEM_PROMPT
3434
}

enterprise/filters/enrollment.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Pipeline steps for the course enrollment filter.
3+
"""
4+
import logging
5+
from typing import Any
6+
7+
from openedx_filters.filters import PipelineStep
8+
9+
from django.contrib.auth.base_user import AbstractBaseUser
10+
11+
log = logging.getLogger(__name__)
12+
13+
try:
14+
from openedx.features.enterprise_support.api import ConsentApiServiceClient, EnterpriseApiServiceClient
15+
except ImportError:
16+
ConsentApiServiceClient = None
17+
EnterpriseApiServiceClient = None
18+
19+
20+
class EnterpriseEnrollmentPostProcessor(PipelineStep):
21+
"""
22+
Post-enrollment pipeline step: notify enterprise API and record consent.
23+
24+
When an enterprise customer user enrolls in a course, this step calls the enterprise and consent
25+
API clients to post the enrollment and provide consent on behalf of the enterprise customer.
26+
27+
This step is intended to be registered as a pipeline step for the
28+
``org.openedx.learning.course.enrollment.view.started.v1`` filter.
29+
"""
30+
31+
def run_filter( # pylint: disable=arguments-differ
32+
self,
33+
user: AbstractBaseUser,
34+
course_key: Any,
35+
linked_enterprise: str | None,
36+
has_api_key_permissions: bool
37+
) -> dict[str, Any]:
38+
"""
39+
Post enterprise enrollment and consent if the user is an enterprise customer user.
40+
"""
41+
log.info(
42+
"EnterpriseEnrollmentPostProcessor running: user_id=%s, course_key=%s, "
43+
+ "linked_enterprise=%s, api_permissions=%s",
44+
user.id,
45+
str(course_key),
46+
linked_enterprise,
47+
has_api_key_permissions,
48+
)
49+
if linked_enterprise is None or not has_api_key_permissions:
50+
return {
51+
'user': user,
52+
'course_key': course_key,
53+
'linked_enterprise': linked_enterprise,
54+
'has_api_key_permissions': has_api_key_permissions
55+
}
56+
57+
username = user.username
58+
course_id = str(course_key)
59+
try:
60+
EnterpriseApiServiceClient().post_enterprise_course_enrollment(
61+
username,
62+
course_id,
63+
)
64+
except Exception: # pylint: disable=broad-except
65+
log.exception(
66+
"Failed to post enterprise course enrollment for user %s in course %s.",
67+
username,
68+
course_id,
69+
)
70+
71+
try:
72+
ConsentApiServiceClient().provide_consent(
73+
username=username,
74+
course_id=course_id,
75+
enterprise_customer_uuid=str(linked_enterprise),
76+
)
77+
except Exception: # pylint: disable=broad-except
78+
log.exception(
79+
"Failed to provide enterprise consent for user %s in course %s.",
80+
username,
81+
course_id,
82+
)
83+
84+
return {
85+
'user': user,
86+
'course_key': course_key,
87+
'linked_enterprise': linked_enterprise,
88+
'has_api_key_permissions': has_api_key_permissions
89+
}

enterprise/settings/common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
"fail_silently": False,
2121
"pipeline": ["enterprise.filters.grades.GradeEventContextEnricher"],
2222
},
23+
"org.openedx.learning.course.enrollment.started.v1": {
24+
"fail_silently": False,
25+
"pipeline": ["enterprise.filters.enrollment.EnterpriseEnrollmentPostProcessor"],
26+
},
2327
}
2428

2529

tests/filters/test_enrollment.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""
2+
Tests for enterprise.filters.enrollment pipeline step.
3+
"""
4+
import uuid
5+
from unittest.mock import MagicMock, patch
6+
7+
from django.test import TestCase
8+
9+
from enterprise.filters.enrollment import EnterpriseEnrollmentPostProcessor
10+
from test_utils.factories import UserFactory
11+
12+
13+
class TestEnterpriseEnrollmentPostProcessor(TestCase):
14+
"""
15+
Tests for EnterpriseEnrollmentPostProcessor pipeline step.
16+
"""
17+
18+
def _make_step(self):
19+
return EnterpriseEnrollmentPostProcessor(
20+
"org.openedx.learning.course.enrollment.started.v1",
21+
[],
22+
)
23+
24+
@patch("enterprise.filters.enrollment.ConsentApiServiceClient")
25+
@patch("enterprise.filters.enrollment.EnterpriseApiServiceClient")
26+
def test_returns_unchanged_args_for_non_enterprise_user(
27+
self,
28+
mock_enterprise_client,
29+
mock_consent_client,
30+
):
31+
"""
32+
When the user is not linked to an enterprise customer, return the
33+
arguments unchanged without calling any API clients.
34+
"""
35+
user = UserFactory.create(username="regular-user")
36+
course_key = MagicMock()
37+
course_key.__str__.return_value = "course-v1:org+course+run"
38+
39+
step = self._make_step()
40+
result = step.run_filter(
41+
user=user,
42+
course_key=course_key,
43+
linked_enterprise=None,
44+
has_api_key_permissions=True,
45+
)
46+
self.assertEqual(
47+
result,
48+
{
49+
"user": user,
50+
"course_key": course_key,
51+
"linked_enterprise": None,
52+
"has_api_key_permissions": True,
53+
},
54+
)
55+
56+
mock_enterprise_client.return_value.post_enterprise_course_enrollment.assert_not_called()
57+
mock_consent_client.return_value.provide_consent.assert_not_called()
58+
59+
@patch("enterprise.filters.enrollment.ConsentApiServiceClient")
60+
@patch("enterprise.filters.enrollment.EnterpriseApiServiceClient")
61+
def test_returns_unchanged_args_for_no_api_permissions(
62+
self,
63+
mock_enterprise_client,
64+
mock_consent_client,
65+
):
66+
"""
67+
When the user request is sent without proper api key permissions, return the
68+
arguments unchanged without calling any API clients.
69+
"""
70+
user = UserFactory.create(username="regular-user")
71+
enterprise_uuid = uuid.uuid4()
72+
73+
course_key = MagicMock()
74+
course_key.__str__.return_value = "course-v1:org+course+run"
75+
76+
step = self._make_step()
77+
result = step.run_filter(
78+
user=user,
79+
course_key=course_key,
80+
linked_enterprise=enterprise_uuid,
81+
has_api_key_permissions=False,
82+
)
83+
self.assertEqual(
84+
result,
85+
{
86+
"user": user,
87+
"course_key": course_key,
88+
"linked_enterprise": enterprise_uuid,
89+
"has_api_key_permissions": False,
90+
},
91+
)
92+
93+
mock_enterprise_client.return_value.post_enterprise_course_enrollment.assert_not_called()
94+
mock_consent_client.return_value.provide_consent.assert_not_called()
95+
96+
@patch("enterprise.filters.enrollment.ConsentApiServiceClient")
97+
@patch("enterprise.filters.enrollment.EnterpriseApiServiceClient")
98+
def test_calls_api_clients_for_enterprise_user(
99+
self,
100+
mock_enterprise_client,
101+
mock_consent_client,
102+
):
103+
"""
104+
When the user is linked to an enterprise customer, call both
105+
EnterpriseApiServiceClient and ConsentApiServiceClient.
106+
"""
107+
user = UserFactory.create(username="enterprise-learner")
108+
enterprise_uuid = uuid.uuid4()
109+
110+
course_key = MagicMock()
111+
course_key.__str__.return_value = "course-v1:org+course+run"
112+
113+
step = self._make_step()
114+
result = step.run_filter(
115+
user=user,
116+
course_key=course_key,
117+
linked_enterprise=enterprise_uuid,
118+
has_api_key_permissions=True,
119+
)
120+
self.assertEqual(
121+
result,
122+
{
123+
"user": user,
124+
"course_key": course_key,
125+
"linked_enterprise": enterprise_uuid,
126+
"has_api_key_permissions": True,
127+
},
128+
)
129+
130+
mock_enterprise_client.return_value.post_enterprise_course_enrollment.assert_called_once_with(
131+
"enterprise-learner",
132+
"course-v1:org+course+run",
133+
)
134+
mock_consent_client.return_value.provide_consent.assert_called_once_with(
135+
username="enterprise-learner",
136+
course_id="course-v1:org+course+run",
137+
enterprise_customer_uuid=str(enterprise_uuid),
138+
)
139+
140+
@patch("enterprise.filters.enrollment.ConsentApiServiceClient")
141+
@patch("enterprise.filters.enrollment.EnterpriseApiServiceClient")
142+
def test_logs_exception_when_enterprise_api_call_fails(
143+
self,
144+
mock_enterprise_client,
145+
mock_consent_client,
146+
):
147+
"""
148+
When the enterprise API client raises an exception, it is logged and
149+
execution continues to the consent API call.
150+
"""
151+
user = UserFactory.create(username="enterprise-learner")
152+
enterprise_uuid = uuid.uuid4()
153+
154+
course_key = MagicMock()
155+
course_key.__str__.return_value = "course-v1:org+course+run"
156+
157+
# Something goes wrong in the enterprise client
158+
mock_enterprise_client.return_value.post_enterprise_course_enrollment.side_effect = Exception(
159+
"boom"
160+
)
161+
162+
step = self._make_step()
163+
result = step.run_filter(
164+
user=user,
165+
course_key=course_key,
166+
linked_enterprise=enterprise_uuid,
167+
has_api_key_permissions=True,
168+
)
169+
self.assertEqual(
170+
result,
171+
{
172+
"user": user,
173+
"course_key": course_key,
174+
"linked_enterprise": enterprise_uuid,
175+
"has_api_key_permissions": True,
176+
},
177+
)
178+
179+
# Consent API should still be called despite enrollment API failure
180+
mock_consent_client.return_value.provide_consent.assert_called_once()
181+
182+
@patch("enterprise.filters.enrollment.ConsentApiServiceClient")
183+
@patch("enterprise.filters.enrollment.EnterpriseApiServiceClient")
184+
def test_logs_exception_when_consent_api_call_fails(
185+
self,
186+
mock_enterprise_client,
187+
mock_consent_client,
188+
):
189+
"""
190+
When the consent API client raises an exception, it is logged and
191+
the filter still returns the original arguments.
192+
"""
193+
user = UserFactory.create(username="enterprise-learner")
194+
enterprise_uuid = uuid.uuid4()
195+
196+
course_key = MagicMock()
197+
course_key.__str__.return_value = "course-v1:org+course+run"
198+
199+
# Something goes wrong in the consent client
200+
mock_consent_client.return_value.provide_consent.side_effect = Exception(
201+
"consent-boom"
202+
)
203+
204+
step = self._make_step()
205+
result = step.run_filter(
206+
user=user,
207+
course_key=course_key,
208+
linked_enterprise=enterprise_uuid,
209+
has_api_key_permissions=True,
210+
)
211+
self.assertEqual(
212+
result,
213+
{
214+
"user": user,
215+
"course_key": course_key,
216+
"linked_enterprise": enterprise_uuid,
217+
"has_api_key_permissions": True,
218+
},
219+
)
220+
221+
# Enterprise API should still be called despite consent API failure
222+
mock_enterprise_client.return_value.post_enterprise_course_enrollment.assert_called_once()

tests/test_enterprise/test_settings.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import ddt
88
import pytest
99

10-
from enterprise.settings.common import _merge_filters_config, plugin_settings
10+
from enterprise.settings.common import ENTERPRISE_FILTERS_CONFIG, _merge_filters_config, plugin_settings
1111

1212

1313
class TestPluginSettingsPipelineInjection(unittest.TestCase):
@@ -212,3 +212,29 @@ def test_additions_dict_isolated_from_subsequent_mutation(self):
212212
existing[FILTER_A]['pipeline'].append(STEP_Y)
213213

214214
assert additions[FILTER_A]['pipeline'] == [STEP_X]
215+
216+
217+
class TestEnterpriseFiltersConfig(unittest.TestCase):
218+
"""
219+
Smoke tests asserting that ``ENTERPRISE_FILTERS_CONFIG`` contains the expected
220+
filter registrations. These tests catch omissions when a new pipeline step is
221+
added to ``enterprise/filters/`` but its filter-type key is never registered.
222+
"""
223+
224+
def test_plugin_settings_injects_all_enterprise_filters(self):
225+
"""
226+
plugin_settings() should inject every filter key and pipeline step from
227+
ENTERPRISE_FILTERS_CONFIG into OPEN_EDX_FILTERS_CONFIG.
228+
"""
229+
settings = SimpleNamespace(
230+
ENABLE_ENTERPRISE_INTEGRATION=True,
231+
OPEN_EDX_FILTERS_CONFIG={},
232+
)
233+
plugin_settings(settings)
234+
235+
for filter_key, expected_filter_config in ENTERPRISE_FILTERS_CONFIG.items():
236+
assert filter_key in settings.OPEN_EDX_FILTERS_CONFIG
237+
actual_filter_config = settings.OPEN_EDX_FILTERS_CONFIG[filter_key]
238+
assert actual_filter_config.get("fail_silently") == expected_filter_config.get("fail_silently")
239+
for expected_step in expected_filter_config.get("pipeline", []):
240+
assert expected_step in actual_filter_config.get("pipeline", [])

0 commit comments

Comments
 (0)