Skip to content

Commit 5f4f237

Browse files
fix: sync enterprise customer default language changes to braze
1 parent 86b3096 commit 5f4f237

File tree

6 files changed

+373
-3
lines changed

6 files changed

+373
-3
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ Unreleased
1717
----------
1818
* nothing unreleased
1919

20+
[6.6.3] - 2026-01-26
21+
---------------------
22+
* fix: sync enterprise customer default language changes to braze
23+
2024
[6.6.2] - 2026-01-15
2125
---------------------
2226
* feat: add a waffle flag for invite admins

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__ = "6.6.2"
5+
__version__ = "6.6.3"

enterprise/signals.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from enterprise.api import activate_admin_permissions
1414
from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient
1515
from enterprise.decorators import disable_for_loaddata
16-
from enterprise.tasks import create_enterprise_enrollment
16+
from enterprise.tasks import create_enterprise_enrollment, track_enterprise_language_update_for_all_learners
1717
from enterprise.utils import (
1818
NotConnectedToOpenEdX,
1919
get_default_catalog_content_filter,
@@ -117,6 +117,47 @@ def update_lang_pref_of_all_learners(sender, instance, **kwargs): # pylint: dis
117117
unset_language_of_all_enterprise_learners(instance)
118118

119119

120+
@receiver(pre_save, sender=models.EnterpriseCustomer)
121+
def track_default_language_change_in_braze(sender, instance, **kwargs): # pylint: disable=unused-argument
122+
"""
123+
Track default_language changes in Braze when EnterpriseCustomer is saved.
124+
125+
Triggers a Celery task to sync the default_language to Braze for all active learners when:
126+
- A new customer is created with default_language set
127+
- An existing customer's default_language is changed (including to/from None)
128+
129+
Args:
130+
sender: The model class (EnterpriseCustomer)
131+
instance: The actual instance being saved
132+
"""
133+
# Get the previous state from database
134+
prev_state = models.EnterpriseCustomer.objects.filter(uuid=instance.uuid).first()
135+
old_language = prev_state.default_language if prev_state else None
136+
new_language = instance.default_language
137+
138+
# Check if default_language actually changed
139+
if old_language == new_language:
140+
return
141+
142+
# Skip if this is a new customer being created with no language set
143+
if prev_state is None and new_language is None:
144+
return
145+
146+
# Language changed - trigger Braze sync
147+
logger.info(
148+
"Default language changed for EnterpriseCustomer %s (%s) from '%s' to '%s'. "
149+
"Triggering Braze update task.",
150+
instance.name,
151+
instance.uuid,
152+
old_language,
153+
new_language
154+
)
155+
track_enterprise_language_update_for_all_learners.delay(
156+
str(instance.uuid),
157+
new_language
158+
)
159+
160+
120161
@receiver(pre_save, sender=models.EnterpriseCustomerBrandingConfiguration)
121162
def skip_saving_logo_file(sender, instance, **kwargs): # pylint: disable=unused-argument
122163
"""

enterprise/tasks.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,13 @@
1616
from enterprise.api_client.braze import ENTERPRISE_BRAZE_ALIAS_LABEL, MAX_NUM_IDENTIFY_USERS_ALIASES, BrazeAPIClient
1717
from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient
1818
from enterprise.constants import SSO_BRAZE_CAMPAIGN_ID
19-
from enterprise.utils import batch_dict, get_enterprise_customer, localized_utcnow, send_email_notification_message
19+
from enterprise.utils import (
20+
batch,
21+
batch_dict,
22+
get_enterprise_customer,
23+
localized_utcnow,
24+
send_email_notification_message,
25+
)
2026

2127
LOGGER = getLogger(__name__)
2228

@@ -436,3 +442,82 @@ def send_group_membership_removal_notification(enterprise_customer_uuid, members
436442
errored_at=localized_utcnow())
437443
LOGGER.exception(message)
438444
raise exc
445+
446+
447+
@shared_task
448+
@set_code_owner_attribute
449+
def track_enterprise_language_update_for_all_learners(enterprise_customer_uuid, new_language):
450+
"""
451+
Update language preference in Braze for all active learners of an enterprise customer.
452+
453+
Uses Braze's batch track_users endpoint which accepts up to 75 attribute objects per request.
454+
Rate limit: 3,000 requests per 3 seconds.
455+
456+
Arguments:
457+
enterprise_customer_uuid (str): UUID of the enterprise customer
458+
new_language (str|None): The new default language code (e.g., 'en', 'es', 'fr') or None to clear
459+
"""
460+
461+
try:
462+
enterprise_customer = get_enterprise_customer(enterprise_customer_uuid)
463+
braze_client = BrazeAPIClient()
464+
465+
# Get all active, linked enterprise customer users
466+
active_users = enterprise_customer.enterprise_customer_users.filter(
467+
active=True,
468+
linked=True
469+
).values_list('user_id', flat=True)
470+
471+
user_count = active_users.count()
472+
LOGGER.info(
473+
f"Tracking language update to '{new_language}' for {user_count} users "
474+
f"in enterprise {enterprise_customer.name} ({enterprise_customer_uuid})"
475+
)
476+
477+
if user_count == 0:
478+
LOGGER.info(
479+
f"No active users found for enterprise {enterprise_customer.name}. Skipping Braze sync."
480+
)
481+
return
482+
483+
# Braze allows 75 attribute objects per request
484+
BATCH_SIZE = 75
485+
success_count = 0
486+
error_count = 0
487+
488+
# Process users in batches using the batch utility
489+
for user_id_batch in batch(active_users, BATCH_SIZE):
490+
# Build attributes array for this batch
491+
# Each item is an attribute object with external_id and pref-lang
492+
attributes = [
493+
{
494+
'external_id': str(user_id),
495+
'pref-lang': new_language
496+
}
497+
for user_id in user_id_batch
498+
]
499+
500+
try:
501+
# Call Braze track_users with batch of attributes
502+
braze_client.track_user(attributes=attributes)
503+
success_count += len(user_id_batch)
504+
LOGGER.info(
505+
f"Successfully tracked language for batch of {len(user_id_batch)} users "
506+
f"(processed {success_count}/{user_count})"
507+
)
508+
except Exception as exc:
509+
error_count += len(user_id_batch)
510+
LOGGER.warning(
511+
f"Failed to track language for batch of {len(user_id_batch)} users: {str(exc)}"
512+
)
513+
514+
LOGGER.info(
515+
f"Language update tracking complete for enterprise {enterprise_customer.name}. "
516+
f"Success: {success_count}, Errors: {error_count}"
517+
)
518+
519+
except Exception as exc:
520+
LOGGER.exception(
521+
f"Failed to track language update for enterprise {enterprise_customer_uuid}: {str(exc)}"
522+
)
523+
raise

tests/test_enterprise/test_signals.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,3 +1174,113 @@ def test_update_enterprise_catalog_query(self, api_client_mock):
11741174
include_exec_ed_2u_courses=test_query.include_exec_ed_2u_courses,
11751175
)
11761176
api_client_mock.return_value.refresh_catalogs.assert_called_with([enterprise_catalog_2])
1177+
1178+
1179+
@mark.django_db
1180+
@ddt.ddt
1181+
class TestTrackDefaultLanguageChangeInBraze(unittest.TestCase):
1182+
"""
1183+
Tests for track_default_language_change_in_braze signal handler.
1184+
"""
1185+
1186+
def setUp(self):
1187+
"""
1188+
Setup for test.
1189+
"""
1190+
self.enterprise_customer = EnterpriseCustomerFactory(
1191+
name='Test Enterprise',
1192+
default_language=None,
1193+
)
1194+
super().setUp()
1195+
1196+
@ddt.data(
1197+
(None, 'en'), # Setting language from None
1198+
('en', 'es'), # Changing from one language to another
1199+
('en', None), # Clearing language back to None
1200+
)
1201+
@ddt.unpack
1202+
@mock.patch('enterprise.signals.track_enterprise_language_update_for_all_learners')
1203+
def test_signal_triggers_on_language_change(self, initial_language, new_language, mock_task):
1204+
"""
1205+
Verify signal triggers Celery task when default_language changes.
1206+
Tests: None→language, language→language, language→None transitions.
1207+
"""
1208+
# Set initial language if needed
1209+
if initial_language is not None:
1210+
self.enterprise_customer.default_language = initial_language
1211+
self.enterprise_customer.save()
1212+
mock_task.delay.reset_mock()
1213+
1214+
# Change to new language
1215+
self.enterprise_customer.default_language = new_language
1216+
self.enterprise_customer.save()
1217+
1218+
# Verify task was called with new language
1219+
mock_task.delay.assert_called_once_with(
1220+
str(self.enterprise_customer.uuid),
1221+
new_language
1222+
)
1223+
1224+
@mock.patch('enterprise.signals.track_enterprise_language_update_for_all_learners')
1225+
def test_signal_does_not_trigger_when_language_unchanged(self, mock_task):
1226+
"""
1227+
Verify signal does not trigger when default_language is unchanged.
1228+
"""
1229+
# Set initial language
1230+
self.enterprise_customer.default_language = 'en'
1231+
self.enterprise_customer.save()
1232+
mock_task.delay.reset_mock()
1233+
1234+
# Save without changing language
1235+
self.enterprise_customer.name = 'Updated Name'
1236+
self.enterprise_customer.save()
1237+
1238+
# Verify task was not called
1239+
mock_task.delay.assert_not_called()
1240+
1241+
@mock.patch('enterprise.signals.track_enterprise_language_update_for_all_learners')
1242+
def test_signal_does_not_trigger_on_new_customer_with_no_language(self, mock_task):
1243+
"""
1244+
Verify signal does not trigger when creating new customer with no language.
1245+
"""
1246+
# Create new customer with no default_language
1247+
new_customer = EnterpriseCustomerFactory(
1248+
name='New Customer',
1249+
default_language=None,
1250+
)
1251+
1252+
# Verify task was not called
1253+
mock_task.delay.assert_not_called()
1254+
1255+
@mock.patch('enterprise.signals.track_enterprise_language_update_for_all_learners')
1256+
def test_signal_triggers_on_new_customer_with_language(self, mock_task):
1257+
"""
1258+
Verify signal triggers when creating new customer with default_language set.
1259+
"""
1260+
# Create new customer with default_language
1261+
new_customer = EnterpriseCustomerFactory(
1262+
name='New Customer',
1263+
default_language='fr',
1264+
)
1265+
1266+
# Verify task was called
1267+
mock_task.delay.assert_called_once_with(
1268+
str(new_customer.uuid),
1269+
'fr'
1270+
)
1271+
1272+
@mock.patch('enterprise.signals.track_enterprise_language_update_for_all_learners')
1273+
def test_signal_logs_language_change(self, mock_task):
1274+
"""
1275+
Verify signal logs the language change appropriately.
1276+
"""
1277+
with self.assertLogs('enterprise.signals', level='INFO') as logs:
1278+
self.enterprise_customer.default_language = 'de'
1279+
self.enterprise_customer.save()
1280+
1281+
# Verify log message contains expected information
1282+
log_output = ' '.join(logs.output)
1283+
assert 'Default language changed' in log_output
1284+
assert str(self.enterprise_customer.uuid) in log_output
1285+
assert 'None' in log_output # old value
1286+
assert 'de' in log_output # new value

0 commit comments

Comments
 (0)