From e4e8565084df8f363804d17aef792ec72d5e6a4a Mon Sep 17 00:00:00 2001 From: Ahtisham Shahid Date: Thu, 19 Jun 2025 18:54:24 +0500 Subject: [PATCH] feat: added account level user preferences model and migration command (#36811) --- ...rate_preferences_to_account_level_model.py | 263 ++++++++++++ .../test_migrate_to_account_level_model.py | 397 ++++++++++++++++++ .../migrations/0008_notificationpreference.py | 37 ++ .../core/djangoapps/notifications/models.py | 23 + 4 files changed, 720 insertions(+) create mode 100644 openedx/core/djangoapps/notifications/management/commands/migrate_preferences_to_account_level_model.py create mode 100644 openedx/core/djangoapps/notifications/management/tests/test_migrate_to_account_level_model.py create mode 100644 openedx/core/djangoapps/notifications/migrations/0008_notificationpreference.py diff --git a/openedx/core/djangoapps/notifications/management/commands/migrate_preferences_to_account_level_model.py b/openedx/core/djangoapps/notifications/management/commands/migrate_preferences_to_account_level_model.py new file mode 100644 index 0000000000..52e4c679e0 --- /dev/null +++ b/openedx/core/djangoapps/notifications/management/commands/migrate_preferences_to_account_level_model.py @@ -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.")) diff --git a/openedx/core/djangoapps/notifications/management/tests/test_migrate_to_account_level_model.py b/openedx/core/djangoapps/notifications/management/tests/test_migrate_to_account_level_model.py new file mode 100644 index 0000000000..cf182dfee9 --- /dev/null +++ b/openedx/core/djangoapps/notifications/management/tests/test_migrate_to_account_level_model.py @@ -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) diff --git a/openedx/core/djangoapps/notifications/migrations/0008_notificationpreference.py b/openedx/core/djangoapps/notifications/migrations/0008_notificationpreference.py new file mode 100644 index 0000000000..4427906ca4 --- /dev/null +++ b/openedx/core/djangoapps/notifications/migrations/0008_notificationpreference.py @@ -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')}, + }, + ), + ] diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index d99f49fa94..2864fe2408 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -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