feat: added account level user preferences model and migration command (#36811)
This commit is contained in:
@@ -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."))
|
||||
@@ -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)
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user