Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ Unreleased
----------
* nothing unreleased

[6.6.3] - 2026-01-26
---------------------
* fix: sync enterprise customer default language changes to braze

[6.6.2] - 2026-01-15
---------------------
* feat: add a waffle flag for invite admins
Expand Down
2 changes: 1 addition & 1 deletion enterprise/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
Your project description goes here.
"""

__version__ = "6.6.2"
__version__ = "6.6.3"
43 changes: 42 additions & 1 deletion enterprise/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from enterprise.api import activate_admin_permissions
from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient
from enterprise.decorators import disable_for_loaddata
from enterprise.tasks import create_enterprise_enrollment
from enterprise.tasks import create_enterprise_enrollment, track_enterprise_language_update_for_all_learners
from enterprise.utils import (
NotConnectedToOpenEdX,
get_default_catalog_content_filter,
Expand Down Expand Up @@ -117,6 +117,47 @@ def update_lang_pref_of_all_learners(sender, instance, **kwargs): # pylint: dis
unset_language_of_all_enterprise_learners(instance)


@receiver(pre_save, sender=models.EnterpriseCustomer)
def track_default_language_change_in_braze(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Track default_language changes in Braze when EnterpriseCustomer is saved.

Triggers a Celery task to sync the default_language to Braze for all active learners when:
- A new customer is created with default_language set
- An existing customer's default_language is changed (including to/from None)

Args:
sender: The model class (EnterpriseCustomer)
instance: The actual instance being saved
"""
# Get the previous state from database
prev_state = models.EnterpriseCustomer.objects.filter(uuid=instance.uuid).first()
old_language = prev_state.default_language if prev_state else None
new_language = instance.default_language

# Check if default_language actually changed
if old_language == new_language:
return

# Skip if this is a new customer being created with no language set
if prev_state is None and new_language is None:
return

# Language changed - trigger Braze sync
logger.info(
"Default language changed for EnterpriseCustomer %s (%s) from '%s' to '%s'. "
"Triggering Braze update task.",
instance.name,
instance.uuid,
old_language,
new_language
)
track_enterprise_language_update_for_all_learners.delay(
str(instance.uuid),
new_language
)


@receiver(pre_save, sender=models.EnterpriseCustomerBrandingConfiguration)
def skip_saving_logo_file(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Expand Down
87 changes: 86 additions & 1 deletion enterprise/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@
from enterprise.api_client.braze import ENTERPRISE_BRAZE_ALIAS_LABEL, MAX_NUM_IDENTIFY_USERS_ALIASES, BrazeAPIClient
from enterprise.api_client.enterprise_catalog import EnterpriseCatalogApiClient
from enterprise.constants import SSO_BRAZE_CAMPAIGN_ID
from enterprise.utils import batch_dict, get_enterprise_customer, localized_utcnow, send_email_notification_message
from enterprise.utils import (
batch,
batch_dict,
get_enterprise_customer,
localized_utcnow,
send_email_notification_message,
)

LOGGER = getLogger(__name__)

Expand Down Expand Up @@ -436,3 +442,82 @@ def send_group_membership_removal_notification(enterprise_customer_uuid, members
errored_at=localized_utcnow())
LOGGER.exception(message)
raise exc


@shared_task
@set_code_owner_attribute
def track_enterprise_language_update_for_all_learners(enterprise_customer_uuid, new_language):
"""
Update language preference in Braze for all active learners of an enterprise customer.

Uses Braze's batch track_users endpoint which accepts up to 75 attribute objects per request.
Rate limit: 3,000 requests per 3 seconds.

Arguments:
enterprise_customer_uuid (str): UUID of the enterprise customer
new_language (str|None): The new default language code (e.g., 'en', 'es', 'fr') or None to clear
"""

try:
enterprise_customer = get_enterprise_customer(enterprise_customer_uuid)
braze_client = BrazeAPIClient()

# Get all active, linked enterprise customer users
active_users = enterprise_customer.enterprise_customer_users.filter(
active=True,
linked=True
).values_list('user_id', flat=True)

user_count = active_users.count()
LOGGER.info(
f"Tracking language update to '{new_language}' for {user_count} users "
f"in enterprise {enterprise_customer.name} ({enterprise_customer_uuid})"
)

if user_count == 0:
LOGGER.info(
f"No active users found for enterprise {enterprise_customer.name}. Skipping Braze sync."
)
return

# Braze allows 75 attribute objects per request
BATCH_SIZE = 75
success_count = 0
error_count = 0

# Process users in batches using the batch utility
for user_id_batch in batch(active_users, BATCH_SIZE):
# Build attributes array for this batch
# Each item is an attribute object with external_id and pref-lang
attributes = [
{
'external_id': str(user_id),
'pref-lang': new_language
}
for user_id in user_id_batch
]

try:
# Call Braze track_users with batch of attributes
braze_client.track_user(attributes=attributes)
success_count += len(user_id_batch)
LOGGER.info(
f"Successfully tracked language for batch of {len(user_id_batch)} users "
f"(processed {success_count}/{user_count})"
)
except Exception as exc:
error_count += len(user_id_batch)
LOGGER.warning(
f"Failed to track language for batch of {len(user_id_batch)} users: {str(exc)}"
)

LOGGER.info(
f"Language update tracking complete for enterprise {enterprise_customer.name}. "
f"Success: {success_count}, Errors: {error_count}"
)

except Exception as exc:
LOGGER.exception(
f"Failed to track language update for enterprise {enterprise_customer_uuid}: {str(exc)}"
)
raise
110 changes: 110 additions & 0 deletions tests/test_enterprise/test_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -1174,3 +1174,113 @@ def test_update_enterprise_catalog_query(self, api_client_mock):
include_exec_ed_2u_courses=test_query.include_exec_ed_2u_courses,
)
api_client_mock.return_value.refresh_catalogs.assert_called_with([enterprise_catalog_2])


@mark.django_db
@ddt.ddt
class TestTrackDefaultLanguageChangeInBraze(unittest.TestCase):
"""
Tests for track_default_language_change_in_braze signal handler.
"""

def setUp(self):
"""
Setup for test.
"""
self.enterprise_customer = EnterpriseCustomerFactory(
name='Test Enterprise',
default_language=None,
)
super().setUp()

@ddt.data(
(None, 'en'), # Setting language from None
('en', 'es'), # Changing from one language to another
('en', None), # Clearing language back to None
)
@ddt.unpack
@mock.patch('enterprise.signals.track_enterprise_language_update_for_all_learners')
def test_signal_triggers_on_language_change(self, initial_language, new_language, mock_task):
"""
Verify signal triggers Celery task when default_language changes.
Tests: None→language, language→language, language→None transitions.
"""
# Set initial language if needed
if initial_language is not None:
self.enterprise_customer.default_language = initial_language
self.enterprise_customer.save()
mock_task.delay.reset_mock()

# Change to new language
self.enterprise_customer.default_language = new_language
self.enterprise_customer.save()

# Verify task was called with new language
mock_task.delay.assert_called_once_with(
str(self.enterprise_customer.uuid),
new_language
)

@mock.patch('enterprise.signals.track_enterprise_language_update_for_all_learners')
def test_signal_does_not_trigger_when_language_unchanged(self, mock_task):
"""
Verify signal does not trigger when default_language is unchanged.
"""
# Set initial language
self.enterprise_customer.default_language = 'en'
self.enterprise_customer.save()
mock_task.delay.reset_mock()

# Save without changing language
self.enterprise_customer.name = 'Updated Name'
self.enterprise_customer.save()

# Verify task was not called
mock_task.delay.assert_not_called()

@mock.patch('enterprise.signals.track_enterprise_language_update_for_all_learners')
def test_signal_does_not_trigger_on_new_customer_with_no_language(self, mock_task):
"""
Verify signal does not trigger when creating new customer with no language.
"""
# Create new customer with no default_language
new_customer = EnterpriseCustomerFactory(
name='New Customer',
default_language=None,
)

# Verify task was not called
mock_task.delay.assert_not_called()

@mock.patch('enterprise.signals.track_enterprise_language_update_for_all_learners')
def test_signal_triggers_on_new_customer_with_language(self, mock_task):
"""
Verify signal triggers when creating new customer with default_language set.
"""
# Create new customer with default_language
new_customer = EnterpriseCustomerFactory(
name='New Customer',
default_language='fr',
)

# Verify task was called
mock_task.delay.assert_called_once_with(
str(new_customer.uuid),
'fr'
)

@mock.patch('enterprise.signals.track_enterprise_language_update_for_all_learners')
def test_signal_logs_language_change(self, mock_task):
"""
Verify signal logs the language change appropriately.
"""
with self.assertLogs('enterprise.signals', level='INFO') as logs:
self.enterprise_customer.default_language = 'de'
self.enterprise_customer.save()

# Verify log message contains expected information
log_output = ' '.join(logs.output)
assert 'Default language changed' in log_output
assert str(self.enterprise_customer.uuid) in log_output
assert 'None' in log_output # old value
assert 'de' in log_output # new value
Loading
Loading