Skip to content

Commit c115c11

Browse files
Merge pull request #215 from edx/rgopalrao/ENT-12003
fix: Fixing the issue of deactivating user while seat allocation
2 parents 22ade54 + 6a6e5ad commit c115c11

4 files changed

Lines changed: 264 additions & 0 deletions

File tree

enterprise_access/apps/api_client/lms_client.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,49 @@ def get_enterprise_user(self, enterprise_customer_uuid, learner_id):
607607

608608
return None
609609

610+
def get_enterprise_learner_by_email(self, enterprise_customer_uuid, learner_email):
611+
"""
612+
Return the enterprise customer user record for ``learner_email`` if the user is
613+
linked to ``enterprise_customer_uuid``, otherwise return ``None``.
614+
615+
Two LMS API calls are made:
616+
1. ``/api/user/v1/accounts`` to resolve the learner's LMS user ID from their email.
617+
2. ``enterprise-learner/`` to fetch the enterprise link for that user.
618+
619+
Returns ``None`` when the learner has no LMS account, is not linked to the given
620+
enterprise, or the account lookup returns no usable user ID.
621+
622+
Raises ``requests.exceptions.HTTPError`` on LMS API failures (e.g. 5xx) so that
623+
calling Celery tasks retry rather than silently proceeding with a stale state.
624+
625+
Arguments:
626+
enterprise_customer_uuid (UUID): UUID of the enterprise customer.
627+
learner_email (str): Email address of the learner to check.
628+
"""
629+
# get_lms_user_account returns None on 404 and raises HTTPError on 5xx.
630+
user_accounts = self.get_lms_user_account(email=learner_email)
631+
if not user_accounts:
632+
return None
633+
634+
# get_lms_user_account(email=...) always returns a list from the accounts endpoint.
635+
lms_user_id = user_accounts[0].get('id') if isinstance(user_accounts, list) else user_accounts.get('id')
636+
if lms_user_id is None:
637+
return None
638+
639+
ec_uuid = str(enterprise_customer_uuid)
640+
response = self.client.get(
641+
self.enterprise_learner_endpoint,
642+
params={'enterprise_customer_uuid': ec_uuid, 'user_ids': lms_user_id},
643+
timeout=settings.LMS_CLIENT_TIMEOUT,
644+
)
645+
response.raise_for_status() # raises HTTPError on 4xx/5xx — callers (tasks) should retry
646+
for result in response.json().get('results', []):
647+
returned_customer = result.get('enterprise_customer', {})
648+
returned_user = result.get('user', {})
649+
if returned_customer.get('uuid') == ec_uuid and returned_user.get('id') == lms_user_id:
650+
return result
651+
return None
652+
610653
def create_pending_enterprise_users(self, enterprise_customer_uuid, user_emails):
611654
"""
612655
Creates a pending enterprise user in the given ``enterprise_customer_uuid`` for each of the

enterprise_access/apps/api_client/tests/test_lms_client.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1282,6 +1282,107 @@ def test_get_lms_user_activation_link(
12821282
if expected_link is None:
12831283
self.assertTrue(mock_logger.error.called or mock_logger.exception.called)
12841284

1285+
@ddt.data(
1286+
# list response, user linked → returns enterprise user record
1287+
{
1288+
'mock_user_accounts': [{'id': TEST_USER_ID}],
1289+
'enterprise_results': [TEST_USER_RECORD],
1290+
'expected_result': TEST_USER_RECORD,
1291+
'expect_enterprise_called': True,
1292+
},
1293+
# empty list → None, no enterprise call
1294+
{
1295+
'mock_user_accounts': [],
1296+
'enterprise_results': None,
1297+
'expected_result': None,
1298+
'expect_enterprise_called': False,
1299+
},
1300+
# None → None, no enterprise call
1301+
{
1302+
'mock_user_accounts': None,
1303+
'enterprise_results': None,
1304+
'expected_result': None,
1305+
'expect_enterprise_called': False,
1306+
},
1307+
# user has account but is not linked to this enterprise → None
1308+
{
1309+
'mock_user_accounts': [{'id': TEST_USER_ID}],
1310+
'enterprise_results': [],
1311+
'expected_result': None,
1312+
'expect_enterprise_called': True,
1313+
},
1314+
# list entry missing 'id' → None, no enterprise call
1315+
{
1316+
'mock_user_accounts': [{'email': 'someone@example.com'}],
1317+
'enterprise_results': None,
1318+
'expected_result': None,
1319+
'expect_enterprise_called': False,
1320+
},
1321+
)
1322+
@ddt.unpack
1323+
@mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient')
1324+
def test_get_enterprise_learner_by_email(
1325+
self,
1326+
mock_oauth_client,
1327+
mock_user_accounts,
1328+
enterprise_results,
1329+
expected_result,
1330+
expect_enterprise_called,
1331+
):
1332+
"""
1333+
Verify get_enterprise_learner_by_email resolves an email to an enterprise user record,
1334+
returning None when the learner has no account, no matching ID, or is not linked.
1335+
HTTP errors from either LMS call propagate to the caller.
1336+
"""
1337+
learner_email = 'test@example.com'
1338+
1339+
if enterprise_results is not None:
1340+
mock_oauth_client.return_value.get.return_value = MockResponse(
1341+
{'results': enterprise_results}, 200
1342+
)
1343+
1344+
client = LmsApiClient()
1345+
with mock.patch.object(client, 'get_lms_user_account', return_value=mock_user_accounts) as mock_get_account:
1346+
result = client.get_enterprise_learner_by_email(str(TEST_ENTERPRISE_UUID), learner_email)
1347+
1348+
self.assertEqual(result, expected_result)
1349+
mock_get_account.assert_called_once_with(email=learner_email)
1350+
if expect_enterprise_called:
1351+
mock_oauth_client.return_value.get.assert_called_once_with(
1352+
client.enterprise_learner_endpoint,
1353+
params={'enterprise_customer_uuid': str(TEST_ENTERPRISE_UUID), 'user_ids': TEST_USER_ID},
1354+
timeout=settings.LMS_CLIENT_TIMEOUT,
1355+
)
1356+
else:
1357+
mock_oauth_client.return_value.get.assert_not_called()
1358+
1359+
@mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient')
1360+
def test_get_enterprise_learner_by_email_account_http_error(self, mock_oauth_client):
1361+
"""
1362+
HTTPError from get_lms_user_account propagates to the caller instead of being swallowed.
1363+
This ensures calling Celery tasks retry rather than silently proceeding.
1364+
"""
1365+
client = LmsApiClient()
1366+
with mock.patch.object(
1367+
client, 'get_lms_user_account', side_effect=requests.exceptions.HTTPError('500')
1368+
):
1369+
with self.assertRaises(requests.exceptions.HTTPError):
1370+
client.get_enterprise_learner_by_email(str(TEST_ENTERPRISE_UUID), 'test@example.com')
1371+
mock_oauth_client.return_value.get.assert_not_called()
1372+
1373+
@mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient')
1374+
def test_get_enterprise_learner_by_email_enterprise_http_error(self, mock_oauth_client):
1375+
"""
1376+
HTTPError from the enterprise-learner endpoint propagates to the caller.
1377+
This ensures calling Celery tasks retry on LMS 5xx rather than treating
1378+
the failure as 'not linked' and proceeding to create_pending_enterprise_users.
1379+
"""
1380+
mock_oauth_client.return_value.get.return_value = MockResponse(None, 503)
1381+
client = LmsApiClient()
1382+
with mock.patch.object(client, 'get_lms_user_account', return_value=[{'id': TEST_USER_ID}]):
1383+
with self.assertRaises(requests.exceptions.HTTPError):
1384+
client.get_enterprise_learner_by_email(str(TEST_ENTERPRISE_UUID), 'test@example.com')
1385+
12851386

12861387
class TestLmsUserApiClient(TestCase):
12871388
"""

enterprise_access/apps/content_assignments/tasks.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,24 @@ def create_pending_enterprise_learner_for_assignment_task(learner_content_assign
367367
enterprise_customer_uuid = assignment.assignment_configuration.enterprise_customer_uuid
368368

369369
lms_client = LmsApiClient()
370+
371+
# Skip pending-user creation if the learner is already actively linked to this enterprise.
372+
# Calling create_pending_enterprise_users when the user is already linked can inadvertently
373+
# trigger the LMS serializer's "inactivate other customers" logic and deactivate the user's
374+
# links to other enterprise customers.
375+
existing_link = lms_client.get_enterprise_learner_by_email(
376+
enterprise_customer_uuid, assignment.learner_email
377+
)
378+
if existing_link and existing_link.get('active', False):
379+
assignment.add_successful_linked_action()
380+
logger.info(
381+
'Learner is already actively linked to enterprise %s; '
382+
'skipping pending enterprise user creation for assignment %s',
383+
enterprise_customer_uuid,
384+
assignment.uuid,
385+
)
386+
return
387+
370388
# Could raise HTTPError and trigger task retry. Intentionally ignoring response since success should just not throw
371389
# an exception. Two possible success statuses are 201 (created) and 200 (found), but there's no reason to
372390
# distinguish them for the purpose of this task.

enterprise_access/apps/content_assignments/tests/test_tasks.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ def setUp(self):
8787
assignment_configuration=self.assignment_configuration,
8888
)
8989

90+
# By default, the learner is NOT already actively linked. Individual tests override this.
91+
patcher = mock.patch(
92+
'enterprise_access.apps.api_client.lms_client.LmsApiClient.get_enterprise_learner_by_email',
93+
return_value=None,
94+
)
95+
self.mock_get_enterprise_learner_by_email = patcher.start()
96+
self.addCleanup(patcher.stop)
97+
9098
@ddt.data(
9199
# The LMS API did not find an existing PendingEnterpriseLearner, so it created one.
92100
{
@@ -210,6 +218,100 @@ def test_last_retry_success(self, mock_oauth_client):
210218
self.assignment.refresh_from_db()
211219
assert self.assignment.state == LearnerContentAssignmentStateChoices.ALLOCATED
212220

221+
@mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient')
222+
def test_skip_if_already_active_link(self, mock_oauth_client):
223+
"""
224+
If the learner is already actively linked to the enterprise, the task should
225+
record a successful linked action and return early without calling the
226+
pending-enterprise-learner LMS endpoint.
227+
"""
228+
self.mock_get_enterprise_learner_by_email.return_value = {
229+
'enterprise_customer': {'uuid': str(TEST_ENTERPRISE_UUID)},
230+
'user': {'email': TEST_EMAIL},
231+
'active': True,
232+
}
233+
234+
task_result = create_pending_enterprise_learner_for_assignment_task.delay(self.assignment.uuid)
235+
236+
assert task_result.state == celery_states.SUCCESS
237+
238+
# The pending-enterprise-learner POST endpoint must NOT have been called.
239+
mock_oauth_client.return_value.post.assert_not_called()
240+
241+
# The active-link check must have been called with the correct arguments.
242+
self.mock_get_enterprise_learner_by_email.assert_called_once_with(
243+
TEST_ENTERPRISE_UUID,
244+
TEST_EMAIL,
245+
)
246+
247+
# Assignment state stays allocated and a successful linked action is recorded.
248+
self.assignment.refresh_from_db()
249+
assert self.assignment.state == LearnerContentAssignmentStateChoices.ALLOCATED
250+
assert self.assignment.actions.filter(action_type=AssignmentActions.LEARNER_LINKED).exists()
251+
252+
@ddt.data(
253+
# Learner has a link but it is explicitly inactive.
254+
{'active': False},
255+
# Learner has a link record with no 'active' field at all.
256+
{},
257+
)
258+
@mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient')
259+
def test_proceeds_if_link_not_active(self, active_value, mock_oauth_client):
260+
"""
261+
If the learner's enterprise link exists but is not active, the task should
262+
proceed normally and call create_pending_enterprise_users.
263+
"""
264+
self.mock_get_enterprise_learner_by_email.return_value = {
265+
'enterprise_customer': {'uuid': str(TEST_ENTERPRISE_UUID)},
266+
'user': {'email': TEST_EMAIL},
267+
**active_value,
268+
}
269+
mock_oauth_client.return_value.post.return_value = MockResponse(
270+
{'enterprise_customer': str(TEST_ENTERPRISE_UUID), 'user_email': TEST_EMAIL},
271+
status.HTTP_201_CREATED,
272+
)
273+
274+
task_result = create_pending_enterprise_learner_for_assignment_task.delay(self.assignment.uuid)
275+
276+
assert task_result.state == celery_states.SUCCESS
277+
278+
# The pending-enterprise-learner POST endpoint must still have been called.
279+
assert len(mock_oauth_client.return_value.post.call_args_list) == 1
280+
assert mock_oauth_client.return_value.post.call_args.kwargs['json'] == [{
281+
'enterprise_customer': str(self.assignment.assignment_configuration.enterprise_customer_uuid),
282+
'user_email': self.assignment.learner_email,
283+
}]
284+
285+
self.assignment.refresh_from_db()
286+
assert self.assignment.state == LearnerContentAssignmentStateChoices.ALLOCATED
287+
288+
@mock.patch('enterprise_access.apps.api_client.base_oauth.OAuthAPIClient')
289+
def test_max_retries_on_link_check_error(self, mock_oauth_client):
290+
"""
291+
When get_enterprise_learner_by_email raises HTTPError (e.g. LMS 5xx), the task
292+
retries until max retries, then sets the assignment to ERRORED. This verifies
293+
that the deactivation guard does not silently swallow LMS errors and proceed to
294+
call create_pending_enterprise_users with a potentially wrong state.
295+
"""
296+
error_response = MockResponse({'detail': 'Service Unavailable'}, status.HTTP_503_SERVICE_UNAVAILABLE)
297+
self.mock_get_enterprise_learner_by_email.side_effect = HTTPError(response=error_response)
298+
299+
task_result = create_pending_enterprise_learner_for_assignment_task.delay(self.assignment.uuid)
300+
301+
assert task_result.state == celery_states.FAILURE
302+
assert isinstance(task_result.result, HTTPError)
303+
304+
# Called once per attempt: 1 initial + max retries
305+
assert self.mock_get_enterprise_learner_by_email.call_count == 1 + settings.TASK_MAX_RETRIES
306+
# create_pending_enterprise_users must never be called when the link check errors
307+
mock_oauth_client.return_value.post.assert_not_called()
308+
309+
self.assignment.refresh_from_db()
310+
assert self.assignment.state == LearnerContentAssignmentStateChoices.ERRORED
311+
action = self.assignment.actions.filter(action_type=AssignmentActions.LEARNER_LINKED).first()
312+
self.assertIsNotNone(action)
313+
self.assertEqual(action.error_reason, AssignmentActionErrors.INTERNAL_API_ERROR)
314+
213315

214316
@ddt.ddt
215317
class TestBrazeEmailTasks(APITestWithMocks):

0 commit comments

Comments
 (0)