feat: added account level user preferences model and migration command (#36811)

This commit is contained in:
Ahtisham Shahid
2025-06-19 18:54:24 +05:00
committed by GitHub
parent 6053f5d058
commit e4e8565084
4 changed files with 720 additions and 0 deletions

View File

@@ -0,0 +1,263 @@
"""
Command to migrate course-level notification preferences to account-level preferences.
"""
import logging
from typing import Dict, List, Any, Iterator
from collections import defaultdict
from django.core.management.base import BaseCommand, CommandParser
from django.db import transaction
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, NotificationPreference
from openedx.core.djangoapps.notifications.utils import aggregate_notification_configs
logger = logging.getLogger(__name__)
DEFAULT_BATCH_SIZE = 1000
class Command(BaseCommand):
"""
Migrates course-level notification preferences to account-level notification preferences.
This command processes users in batches, aggregates their course-level preferences,
and creates new account-level preferences. It includes a dry-run mode.
Existing account-level preferences for a processed user will be deleted before
new ones are created to ensure idempotency.
"""
help = "Migrates course-level notification preferences to account-level preferences for all relevant users."
def add_arguments(self, parser: CommandParser):
parser.add_argument(
'--batch-size',
type=int,
default=DEFAULT_BATCH_SIZE,
help=f"The number of users to process in each batch. Default: {DEFAULT_BATCH_SIZE}"
)
parser.add_argument(
'--dry-run',
action='store_true',
help="Simulate the migration without making any database changes."
)
@staticmethod
def _get_user_ids_to_process() -> Iterator[int]:
"""
Yields all distinct user IDs with course notification preferences.
"""
logger.info("Fetching all distinct user IDs with course notification preferences...")
user_id_queryset = (CourseNotificationPreference
.objects
.values_list('user_id', flat=True)
.distinct())
# The iterator with chunk_size is memory efficient for fetching the IDs themselves.
yield from user_id_queryset.iterator()
@staticmethod
def _create_preference_object(
user_id: int,
app_name: str,
notification_type: str,
values: Dict[str, Any]
) -> NotificationPreference:
"""
Helper function to create a NotificationPreference instance.
"""
return NotificationPreference(
user_id=user_id,
app=app_name,
type=notification_type,
web=values.get('web'),
email=values.get('email'),
push=values.get('push'),
email_cadence=values.get('email_cadence', EmailCadence.DAILY)
)
def _create_preferences_from_configs(
self,
user_id: int,
course_preferences_configs: List[Dict]
) -> List[NotificationPreference]:
"""
Processes a list of preference configs for a single user.
Returns a list of NotificationPreference objects to be created.
"""
new_account_preferences: List[NotificationPreference] = []
if not course_preferences_configs:
logger.debug(f"No course preferences found for user {user_id}. Skipping.")
return new_account_preferences
aggregated_data = aggregate_notification_configs(course_preferences_configs)
for app_name, app_config in aggregated_data.items():
if not isinstance(app_config, dict):
logger.warning(
f"Malformed app_config for app '{app_name}' for user {user_id}. "
f"Expected dict, got {type(app_config)}. Skipping app."
)
continue
notif_types = app_config.get('notification_types', {})
if not isinstance(notif_types, dict):
logger.warning(
f"Malformed 'notification_types' for app '{app_name}' for user {user_id}. Expected dict, "
f"got {type(notif_types)}. Skipping notification_types."
)
continue
# Handle regular notification types
for notification_type, values in notif_types.items():
if notification_type == 'core':
continue
if values is None or not isinstance(values, dict):
logger.warning(
f"Skipping malformed notification type data for '{notification_type}' "
f"in app '{app_name}' for user {user_id}."
)
continue
new_account_preferences.append(
self._create_preference_object(user_id, app_name, notification_type, values)
)
# Handle core notification types
core_types_list = app_config.get('core_notification_types', [])
if not isinstance(core_types_list, list):
logger.warning(
f"Malformed 'core_notification_types' for app '{app_name}' for user {user_id}. "
f"Expected list, got {type(core_types_list)}. Skipping core_notification_types."
)
continue
core_values = notif_types.get('core', {})
if not isinstance(core_values, dict):
logger.warning(
f"Malformed values for 'core' notification types in app '{app_name}' for user {user_id}. "
f"Expected dict, got {type(core_values)}. Using empty defaults."
)
core_values = {}
for core_type_name in core_types_list:
if core_type_name is None or not isinstance(core_type_name, str):
logger.warning(
f"Skipping malformed core_type_name: '{core_type_name}' in app '{app_name}' for user {user_id}."
)
continue
new_account_preferences.append(
self._create_preference_object(user_id, app_name, core_type_name, core_values)
)
return new_account_preferences
def _process_batch(self, user_ids: List[int]) -> List[NotificationPreference]:
"""
Fetches all preferences for a batch of users and processes them.
"""
all_new_preferences: List[NotificationPreference] = []
# 1. Fetch all preference data for the batch in a single query.
course_prefs = CourseNotificationPreference.objects.filter(
user_id__in=user_ids
).values('user_id', 'notification_preference_config')
# 2. Group the fetched data by user_id in memory.
prefs_by_user = defaultdict(list)
for pref in course_prefs:
prefs_by_user[pref['user_id']].append(pref['notification_preference_config'])
# 3. Process each user's grouped data.
for user_id, configs in prefs_by_user.items():
user_new_preferences = self._create_preferences_from_configs(user_id, configs)
if user_new_preferences:
all_new_preferences.extend(user_new_preferences)
logger.debug(f"User {user_id}: Aggregated {len(configs)} course preferences "
f"into {len(user_new_preferences)} account preferences.")
else:
logger.debug(f"User {user_id}: No account preferences generated from {len(configs)} "
f"course preferences.")
return all_new_preferences
def handle(self, *args: Any, **options: Any):
dry_run = options['dry_run']
batch_size = options['batch_size']
if dry_run:
logger.info(self.style.WARNING("Performing a DRY RUN. No changes will be made to the database."))
else:
# Clear all existing preferences once at the beginning.
# This is more efficient and safer than deleting per-user.
NotificationPreference.objects.all().delete()
logger.info('Cleared all existing account-level notification preferences.')
user_id_iterator = self._get_user_ids_to_process()
user_id_batch: List[int] = []
total_users_processed = 0
total_preferences_created = 0
for user_id in user_id_iterator:
user_id_batch.append(user_id)
if len(user_id_batch) >= batch_size:
try:
with transaction.atomic():
# Process the entire batch of users
preferences_to_create = self._process_batch(user_id_batch)
if preferences_to_create:
if not dry_run:
NotificationPreference.objects.bulk_create(preferences_to_create)
total_preferences_created += len(preferences_to_create)
logger.info(
self.style.SUCCESS(
f"Batch complete. {'Would create' if dry_run else 'Created'} "
f"{len(preferences_to_create)} preferences for {len(user_id_batch)} users."
)
)
else:
logger.info(f"Batch complete. No preferences to create for {len(user_id_batch)} users.")
total_users_processed += len(user_id_batch)
user_id_batch = [] # Reset the batch
except Exception as e: # pylint: disable=broad-except
logger.error(f"Failed to process batch containing users {user_id_batch}: {e}", exc_info=True)
# The transaction for the whole batch will be rolled back.
# Clear the batch to continue with the next set of users.
user_id_batch = []
if total_users_processed > 0 and total_users_processed % (batch_size * 5) == 0:
logger.info(f"PROGRESS: Total users processed so far: {total_users_processed}. "
f"Total preferences {'would be' if dry_run else ''} "
f"created: {total_preferences_created}")
# Process any remaining users in the last, smaller batch
if user_id_batch:
try:
with transaction.atomic():
preferences_to_create = self._process_batch(user_id_batch)
if preferences_to_create:
if not dry_run:
NotificationPreference.objects.bulk_create(preferences_to_create)
total_preferences_created += len(preferences_to_create)
logger.info(
self.style.SUCCESS(
f"Final batch complete. {'Would create' if dry_run else 'Created'} "
f"{len(preferences_to_create)} preferences for {len(user_id_batch)} users."
)
)
total_users_processed += len(user_id_batch)
except Exception as e: # pylint: disable=broad-except
logger.error(f"Failed to process final batch of users {user_id_batch}: {e}", exc_info=True)
logger.info(
self.style.SUCCESS(
f"Migration complete. Processed {total_users_processed} users. "
f"{'Would have created' if dry_run else 'Created'} a total of {total_preferences_created} "
f"account-level preferences."
)
)
if dry_run:
logger.info(self.style.WARNING("DRY RUN finished. No actual changes were made."))

View File

@@ -0,0 +1,397 @@
# pylint: disable = W0212
"""
Test for account level migration command
"""
from unittest.mock import Mock, patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.core.management import call_command
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
from openedx.core.djangoapps.notifications.models import (
CourseNotificationPreference,
NotificationPreference
)
from openedx.core.djangoapps.notifications.management.commands.migrate_preferences_to_account_level_model import Command
User = get_user_model()
COMMAND_MODULE = 'openedx.core.djangoapps.notifications.management.commands.migrate_preferences_to_account_level_model'
class MigrateNotificationPreferencesTestCase(TestCase):
"""Test cases for the migrate_preferences_to_account_level_model management command."""
def setUp(self):
"""Set up test data."""
self.user1 = User.objects.create_user(username='user1', email='user1@example.com')
self.user2 = User.objects.create_user(username='user2', email='user2@example.com')
self.user3 = User.objects.create_user(username='user3', email='user3@example.com')
# Sample notification preference config
self.sample_config = {
"grading": {
"enabled": True,
"notification_types": {
"core": {
"web": True,
"push": True,
"email": True,
"email_cadence": "Daily"
},
"ora_grade_assigned": {
"web": True,
"push": False,
"email": True,
"email_cadence": "Daily"
}
},
"core_notification_types": ["grade_assigned", "grade_updated"]
},
"discussion": {
"enabled": True,
"notification_types": {
"core": {
"web": False,
"push": True,
"email": False,
"email_cadence": "Weekly"
},
"new_post": {
"web": True,
"push": True,
"email": True,
"email_cadence": "Immediately"
}
},
"core_notification_types": ["new_response", "new_comment"]
}
}
def tearDown(self):
"""Clean up test data."""
CourseNotificationPreference.objects.all().delete()
NotificationPreference.objects.all().delete()
User.objects.all().delete()
def test_get_user_ids_to_process(self):
"""Test that _get_user_ids_to_process returns correct user IDs."""
# Create course preferences for users
CourseNotificationPreference.objects.create(
user=self.user1,
course_id='course-v1:Test+Course+1',
notification_preference_config=self.sample_config
)
CourseNotificationPreference.objects.create(
user=self.user2,
course_id='course-v1:Test+Course+2',
notification_preference_config=self.sample_config
)
command = Command()
user_ids = list(command._get_user_ids_to_process())
self.assertEqual(len(user_ids), 2)
self.assertIn(self.user1.id, user_ids)
self.assertIn(self.user2.id, user_ids)
def test_create_preference_object(self):
"""Test that _create_preference_object creates correct NotificationPreference instance."""
command = Command()
values = {
'web': True,
'push': False,
'email': True,
'email_cadence': 'Weekly'
}
preference = command._create_preference_object(
user_id=self.user1.id,
app_name='grading',
notification_type='ora_grade_assigned',
values=values
)
self.assertEqual(preference.user_id, self.user1.id)
self.assertEqual(preference.app, 'grading')
self.assertEqual(preference.type, 'ora_grade_assigned')
self.assertTrue(preference.web)
self.assertFalse(preference.push)
self.assertTrue(preference.email)
self.assertEqual(preference.email_cadence, 'Weekly')
def test_create_preference_object_with_defaults(self):
"""Test _create_preference_object with missing values uses defaults."""
command = Command()
values = {'web': True} # Missing other values
preference = command._create_preference_object(
user_id=self.user1.id,
app_name='grading',
notification_type='test_type',
values=values
)
self.assertTrue(preference.web)
self.assertIsNone(preference.push)
self.assertIsNone(preference.email)
self.assertEqual(preference.email_cadence, EmailCadence.DAILY)
@patch(f'{COMMAND_MODULE}.aggregate_notification_configs')
def test_process_user_preferences_success(self, mock_aggregate):
"""Test successful processing of user preferences."""
# Setup
CourseNotificationPreference.objects.create(
user=self.user1,
course_id='course-v1:Test+Course+1',
notification_preference_config=self.sample_config
)
mock_aggregate.return_value = {
'grading': {
'notification_types': {
'core': {'web': True, 'push': True, 'email': True, 'email_cadence': 'Daily'},
'grade_assigned': {'web': True, 'push': False, 'email': True, 'email_cadence': 'Daily'}
},
'core_notification_types': ['grade_updated']
}
}
command = Command()
preferences = command._process_batch([self.user1.id])
self.assertEqual(len(preferences), 2) # grade_assigned + grade_updated
# Check grade_assigned preference
grade_assigned_pref = next(p for p in preferences if p.type == 'grade_assigned')
self.assertEqual(grade_assigned_pref.app, 'grading')
self.assertTrue(grade_assigned_pref.web)
self.assertFalse(grade_assigned_pref.push)
self.assertTrue(grade_assigned_pref.email)
# Check core notification type
grade_updated_pref = next(p for p in preferences if p.type == 'grade_updated')
self.assertEqual(grade_updated_pref.app, 'grading')
self.assertTrue(grade_updated_pref.web)
self.assertTrue(grade_updated_pref.push)
self.assertTrue(grade_updated_pref.email)
@patch(f'{COMMAND_MODULE}.aggregate_notification_configs')
def test_process_user_preferences_no_course_preferences(self, mock_aggregate):
"""Test processing user with no course preferences."""
command = Command()
preferences = command._process_batch([self.user1.id])
self.assertEqual(len(preferences), 0)
mock_aggregate.assert_not_called()
@patch(f'{COMMAND_MODULE}.aggregate_notification_configs')
def test_process_user_preferences_malformed_data(self, mock_aggregate):
"""Test handling of malformed notification config data."""
CourseNotificationPreference.objects.create(
user=self.user1,
course_id='course-v1:Test+Course+1',
notification_preference_config=self.sample_config
)
# Mock malformed data
mock_aggregate.return_value = {
'grading': 'invalid_string', # Should be dict
'discussion': {
'notification_types': 'invalid_string', # Should be dict
'core_notification_types': []
},
'updates': {
'notification_types': {
'core': {'web': True, 'push': True, 'email': True},
'invalid_type': None # Invalid notification type data
},
'core_notification_types': 'invalid_string' # Should be list
}
}
command = Command()
with self.assertLogs(level='WARNING') as log:
preferences = command._process_batch([self.user1.id])
self.assertEqual(len(preferences), 0)
self.assertIn('Malformed app_config', log.output[0])
@patch(f'{COMMAND_MODULE}.logger')
def test_handle_dry_run_mode(self, mock_logger):
"""Test command execution in dry-run mode."""
CourseNotificationPreference.objects.create(
user=self.user1,
course_id='course-v1:Test+Course+1',
notification_preference_config=self.sample_config
)
with patch.object(Command, '_process_batch') as mock_process:
mock_process.return_value = [
NotificationPreference(
user_id=self.user1.id,
app='grading',
type='test_type',
web=True,
push=False,
email=True,
email_cadence='Daily'
)
]
call_command('migrate_preferences_to_account_level_model', '--dry-run', '--batch-size=1')
# Check that no actual database changes were made
self.assertEqual(NotificationPreference.objects.count(), 0)
# Verify dry-run logging
mock_logger.info.assert_any_call(
'Performing a DRY RUN. No changes will be made to the database.'
)
def test_handle_normal_execution(self):
"""Test normal command execution without dry-run."""
CourseNotificationPreference.objects.create(
user=self.user1,
course_id='course-v1:Test+Course+1',
notification_preference_config=self.sample_config
)
# Create existing account preferences to test deletion
NotificationPreference.objects.create(
user=self.user1,
app='old_app',
type='old_type',
web=True,
push=False,
email=False,
email_cadence='Daily'
)
with patch.object(Command, '_process_batch') as mock_process:
mock_process.return_value = [
NotificationPreference(
user_id=self.user1.id,
app='grading',
type='test_type',
web=True,
push=False,
email=True,
email_cadence='Daily'
)
]
call_command('migrate_preferences_to_account_level_model', '--batch-size=1')
# Verify old preferences were deleted and new ones created
self.assertEqual(NotificationPreference.objects.count(), 1)
new_pref = NotificationPreference.objects.first()
self.assertEqual(new_pref.app, 'grading')
self.assertEqual(new_pref.type, 'test_type')
@patch(f'{COMMAND_MODULE}.transaction.atomic')
def test_migrate_preferences_to_account_level_model(self, mock_atomic):
"""Test that users are processed in batches correctly."""
# Mock atomic to avoid transaction issues during testing
mock_atomic.return_value.__enter__ = Mock()
mock_atomic.return_value.__exit__ = Mock(return_value=None)
# Create course preferences for multiple users
for i, user in enumerate([self.user1, self.user2, self.user3]):
CourseNotificationPreference.objects.create(
user=user,
course_id=f'course-v1:Test+Course+{i}',
notification_preference_config=self.sample_config
)
call_command('migrate_preferences_to_account_level_model', '--batch-size=2')
# Check that preferences were created for each user
self.assertEqual(NotificationPreference.objects.count(), 18)
def test_command_arguments(self):
"""Test that command arguments are handled correctly."""
command = Command()
parser = command.create_parser('test', 'migrate_preferences_to_account_level_model')
# Test default arguments
options = parser.parse_args([])
self.assertEqual(options.batch_size, 1000)
self.assertFalse(options.dry_run)
# Test custom arguments
options = parser.parse_args(['--batch-size', '500', '--dry-run'])
self.assertEqual(options.batch_size, 500)
self.assertTrue(options.dry_run)
@patch(f'{COMMAND_MODULE}.aggregate_notification_configs')
def test_process_user_preferences_with_core_types(self, mock_aggregate):
"""Test processing of core notification types specifically."""
CourseNotificationPreference.objects.create(
user=self.user1,
course_id='course-v1:Test+Course+1',
notification_preference_config=self.sample_config
)
mock_aggregate.return_value = {
'discussion': {
'notification_types': {
'core': {'web': False, 'push': True, 'email': False, 'email_cadence': 'Weekly'}
},
'core_notification_types': ['new_response', 'new_comment', None, 123] # Include invalid types
}
}
command = Command()
with self.assertLogs(level='WARNING') as log:
preferences = command._process_batch([self.user1.id])
# Should create 2 valid core preferences (ignoring None and 123)
valid_prefs = [p for p in preferences if p.type in ['new_response', 'new_comment']]
self.assertEqual(len(valid_prefs), 2)
# Check that invalid core types were logged as warnings
warning_logs = [log for log in log.output if 'Skipping malformed core_type_name' in log]
self.assertEqual(len(warning_logs), 2)
def test_progress_logging(self):
"""Test that progress is logged at appropriate intervals."""
# Create enough users to trigger progress logging
users = []
for i in range(10):
user = User.objects.create_user(username=f'userX{i}', email=f'userx{i}@example.com')
users.append(user)
CourseNotificationPreference.objects.create(
user=user,
course_id=f'course-v1:Test+Course+{i}',
notification_preference_config=self.sample_config
)
with patch.object(Command, '_process_batch') as mock_process:
mock_process.return_value = []
with patch(f'{COMMAND_MODULE}.logger') as mock_logger:
call_command('migrate_preferences_to_account_level_model', '--batch-size=1')
# Check that progress was logged (every 5 batches)
progress_calls = [call for call in mock_logger.info.call_args_list
if 'PROGRESS:' in str(call)]
self.assertGreater(len(progress_calls), 0)
def test_empty_batch_handling(self):
"""Test handling when no preferences need to be created."""
CourseNotificationPreference.objects.create(
user=self.user1,
course_id='course-v1:Test+Course+1',
notification_preference_config=self.sample_config
)
with patch.object(Command, '_process_batch') as mock_process:
mock_process.return_value = [] # No preferences to create
with patch(f'{COMMAND_MODULE}.logger') as mock_logger:
call_command('migrate_preferences_to_account_level_model', '--batch-size=1')
# Should log that no preferences were created
no_prefs_calls = [call for call in mock_logger.info.call_args_list
if 'No preferences to create' in str(call)]
self.assertEqual(len(no_prefs_calls), 1)

View File

@@ -0,0 +1,37 @@
# Generated by Django 4.2.21 on 2025-05-29 08:27
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('notifications', '0007_alter_notification_group_by_id'),
]
operations = [
migrations.CreateModel(
name='NotificationPreference',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('type', models.CharField(db_index=True, max_length=128)),
('app', models.CharField(db_index=True, max_length=128)),
('web', models.BooleanField(default=True)),
('push', models.BooleanField(default=False)),
('email', models.BooleanField(default=False)),
('email_cadence', models.CharField(choices=[('Daily', 'Daily'), ('Weekly', 'Weekly'), ('Immediately', 'Immediately')], max_length=64)),
('is_active', models.BooleanField(default=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preference', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'app', 'type')},
},
),
]

View File

@@ -125,6 +125,29 @@ class Notification(TimeStampedModel):
return get_notification_content(self.notification_type, self.content_context)
class NotificationPreference(TimeStampedModel):
"""
Model to store notification preferences for users at account level
"""
class EmailCadenceChoices(models.TextChoices):
DAILY = 'Daily'
WEEKLY = 'Weekly'
IMMEDIATELY = 'Immediately'
class Meta:
# Ensures user do not have duplicate preferences.
unique_together = ('user', 'app', 'type',)
user = models.ForeignKey(User, related_name="notification_preference", on_delete=models.CASCADE)
type = models.CharField(max_length=128, db_index=True)
app = models.CharField(max_length=128, null=False, blank=False, db_index=True)
web = models.BooleanField(default=True, null=False, blank=False)
push = models.BooleanField(default=False, null=False, blank=False)
email = models.BooleanField(default=False, null=False, blank=False)
email_cadence = models.CharField(max_length=64, choices=EmailCadenceChoices.choices, null=False, blank=False)
is_active = models.BooleanField(default=True)
class CourseNotificationPreference(TimeStampedModel):
"""
Model to store notification preferences for users