From 0b4c75c21ce9bc4ece6a51df4a8e073a3d68d647 Mon Sep 17 00:00:00 2001 From: Eemaan Amir <57627710+eemaanamir@users.noreply.github.com> Date: Mon, 4 Aug 2025 12:20:54 +0500 Subject: [PATCH] chore: cleaned up course level preferences (#37106) * chore: cleaned up course level preferences * fix: fixed import issue * fix: fixed lint errors --- .../enrollments/tests/test_views.py | 2 +- .../djangoapps/notifications/email/tasks.py | 25 - .../core/djangoapps/notifications/handlers.py | 49 +- ...enerate_course_notification_preferences.py | 34 - ...rate_preferences_to_account_level_model.py | 325 ------ ...enerate_course_notification_preferences.py | 32 - .../test_migrate_to_account_level_model.py | 465 --------- .../djangoapps/notifications/serializers.py | 161 --- .../core/djangoapps/notifications/tasks.py | 59 -- .../notifications/tests/test_tasks.py | 516 ---------- .../test_tasks_with_account_level_pref.py | 76 +- .../notifications/tests/test_utils.py | 349 ------- .../notifications/tests/test_views.py | 921 +----------------- openedx/core/djangoapps/notifications/urls.py | 25 +- .../core/djangoapps/notifications/utils.py | 60 -- .../core/djangoapps/notifications/views.py | 385 +------- 16 files changed, 15 insertions(+), 3469 deletions(-) delete mode 100644 openedx/core/djangoapps/notifications/management/commands/generate_course_notification_preferences.py delete mode 100644 openedx/core/djangoapps/notifications/management/commands/migrate_preferences_to_account_level_model.py delete mode 100644 openedx/core/djangoapps/notifications/management/tests/test_generate_course_notification_preferences.py delete mode 100644 openedx/core/djangoapps/notifications/management/tests/test_migrate_to_account_level_model.py delete mode 100644 openedx/core/djangoapps/notifications/tests/test_tasks.py delete mode 100644 openedx/core/djangoapps/notifications/tests/test_utils.py diff --git a/openedx/core/djangoapps/enrollments/tests/test_views.py b/openedx/core/djangoapps/enrollments/tests/test_views.py index 2318904baf..a6b34cbfc6 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_views.py +++ b/openedx/core/djangoapps/enrollments/tests/test_views.py @@ -39,7 +39,7 @@ from openedx.core.djangoapps.embargo.test_utils import restrict_course from openedx.core.djangoapps.enrollments import api, data from openedx.core.djangoapps.enrollments.errors import CourseEnrollmentError from openedx.core.djangoapps.enrollments.views import EnrollmentUserThrottle -from openedx.core.djangoapps.notifications.handlers import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS from openedx.core.djangoapps.notifications.models import CourseNotificationPreference from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.djangoapps.user_api.models import RetirementState, UserOrgTag, UserRetirementStatus diff --git a/openedx/core/djangoapps/notifications/email/tasks.py b/openedx/core/djangoapps/notifications/email/tasks.py index d30c9b8760..ed9ab78158 100644 --- a/openedx/core/djangoapps/notifications/email/tasks.py +++ b/openedx/core/djangoapps/notifications/email/tasks.py @@ -12,10 +12,8 @@ from edx_django_utils.monitoring import set_code_owner_attribute from openedx.core.djangoapps.notifications.email_notifications import EmailCadence from openedx.core.djangoapps.notifications.models import ( - CourseNotificationPreference, Notification, NotificationPreference, - get_course_notification_preference_config_version ) from .events import send_immediate_email_digest_sent_event, send_user_email_digest_sent_event from .message_type import EmailNotificationMessageType @@ -53,29 +51,6 @@ def get_audience_for_cadence_email(cadence_type): return users -def get_user_preferences_for_courses(course_ids, user): - """ - Returns updated user preference for course_ids - """ - # Create new preferences - new_preferences = [] - preferences = CourseNotificationPreference.objects.filter(user=user, course_id__in=course_ids) - preferences = list(preferences) - for course_id in course_ids: - if not any(preference.course_id == course_id for preference in preferences): - pref = CourseNotificationPreference(user=user, course_id=course_id) - new_preferences.append(pref) - if new_preferences: - CourseNotificationPreference.objects.bulk_create(new_preferences, ignore_conflicts=True) - # Update preferences to latest config version - current_version = get_course_notification_preference_config_version() - for preference in preferences: - if preference.config_version != current_version: - preference = preference.get_user_course_preference(user.id, preference.course_id) - new_preferences.append(preference) - return new_preferences - - def send_digest_email_to_user(user, cadence_type, start_date, end_date, user_language='en', courses_data=None): """ Send [cadence_type] email to user. diff --git a/openedx/core/djangoapps/notifications/handlers.py b/openedx/core/djangoapps/notifications/handlers.py index b03282ea95..c4a2a77279 100644 --- a/openedx/core/djangoapps/notifications/handlers.py +++ b/openedx/core/djangoapps/notifications/handlers.py @@ -4,14 +4,11 @@ Handlers for notifications import logging from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist from django.db import IntegrityError, transaction, ProgrammingError from django.db.models.signals import post_save from django.dispatch import receiver from openedx_events.learning.signals import ( - COURSE_ENROLLMENT_CREATED, COURSE_NOTIFICATION_REQUESTED, - COURSE_UNENROLLMENT_COMPLETED, USER_NOTIFICATION_REQUESTED ) @@ -23,12 +20,9 @@ from openedx.core.djangoapps.notifications.audience_filters import ( ForumRoleAudienceFilter, TeamAudienceFilter ) -from openedx.core.djangoapps.notifications.base_notification import NotificationAppManager, COURSE_NOTIFICATION_TYPES -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS -from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY -from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, NotificationPreference +from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIFICATION_TYPES +from openedx.core.djangoapps.notifications.models import NotificationPreference from openedx.core.djangoapps.notifications.tasks import create_notification_preference -from openedx.core.djangoapps.user_api.models import UserPreference User = get_user_model() log = logging.getLogger(__name__) @@ -42,31 +36,6 @@ AUDIENCE_FILTER_CLASSES = { } -@receiver(COURSE_ENROLLMENT_CREATED) -def course_enrollment_post_save(signal, sender, enrollment, metadata, **kwargs): - """ - Watches for post_save signal for creates on the CourseEnrollment table. - Generate a CourseNotificationPreference if new Enrollment is created - """ - if ENABLE_NOTIFICATIONS.is_enabled(enrollment.course.course_key): - try: - with transaction.atomic(): - email_opt_out = UserPreference.objects.filter( - user_id=enrollment.user.id, - key=ONE_CLICK_EMAIL_UNSUB_KEY - ).exists() - CourseNotificationPreference.objects.create( - user_id=enrollment.user.id, - course_id=enrollment.course.course_key, - notification_preference_config=NotificationAppManager().get_notification_app_preferences( - email_opt_out - ) - ) - except IntegrityError: - log.info(f'CourseNotificationPreference already exists for user {enrollment.user.id} ' - f'and course {enrollment.course.course_key}') - - @receiver(post_save, sender=User) def create_user_account_preferences(sender, instance, created, **kwargs): # pylint: disable=unused-argument """ @@ -88,20 +57,6 @@ def create_user_account_preferences(sender, instance, created, **kwargs): # pyl log.error(f'ProgrammingError encountered while creating user preferences: {e}') -@receiver(COURSE_UNENROLLMENT_COMPLETED) -def on_user_course_unenrollment(enrollment, **kwargs): - """ - Removes user notification preference when user un-enrolls from the course - """ - try: - user_id = enrollment.user.id - course_key = enrollment.course.course_key - preference = CourseNotificationPreference.objects.get(user__id=user_id, course_id=course_key) - preference.delete() - except ObjectDoesNotExist: - log.info(f'Notification Preference does not exist for {enrollment.user.pii.username} in {course_key}') - - @receiver(USER_NOTIFICATION_REQUESTED) def generate_user_notifications(signal, sender, notification_data, metadata, **kwargs): """ diff --git a/openedx/core/djangoapps/notifications/management/commands/generate_course_notification_preferences.py b/openedx/core/djangoapps/notifications/management/commands/generate_course_notification_preferences.py deleted file mode 100644 index fc4558808f..0000000000 --- a/openedx/core/djangoapps/notifications/management/commands/generate_course_notification_preferences.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -Management command for creating Course Notification Preferences for users in course. -""" - -import logging - -from django.core.management.base import BaseCommand - -from openedx.core.djangoapps.notifications.tasks import create_course_notification_preferences_for_courses - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - """ - Invoke with: - - python manage.py [lms|cms] generate_course_notification_preferences [course_id] [course_id] ... - """ - help = ( - "Back-fill missing course notification preferences. This will queue a celery task to " - "create course notification preferences for all active enrollments in the courses provided." - ) - - def add_arguments(self, parser): - parser.add_argument( - 'course_ids', - help='course_ids seperated by space for which to create Course Notification Preferences.', - nargs='*' - ) - - def handle(self, *args, **options): - course_ids = options['course_ids'] - create_course_notification_preferences_for_courses.delay(course_ids) 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 deleted file mode 100644 index 96e826cd0b..0000000000 --- a/openedx/core/djangoapps/notifications/management/commands/migrate_preferences_to_account_level_model.py +++ /dev/null @@ -1,325 +0,0 @@ -""" -Command to migrate course-level notification preferences to account-level preferences. -""" -import gc -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 -from openedx.core.djangoapps.notifications.base_notification import NotificationTypeManager, COURSE_NOTIFICATION_APPS - -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." - ) - parser.add_argument( - '--use-default', - nargs='+', - choices=['web', 'push', 'email', 'email_cadence'], - help="Specify which notification channels should use default values. Can accept multiple values" - " (e.g., --use-default web push email)." - ) - - @staticmethod - def _run_garbage_collection(): - """ - Run manual garbage collection - """ - try: - collected_objects = gc.collect() - logger.debug(f"Garbage collection freed {collected_objects} objects") - return collected_objects - except Exception as e: # pylint: disable=broad-except - logger.warning(f"Garbage collection failed: {e}") - return 0 - - @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], - use_default: List[str] = None - ) -> NotificationPreference: - """ - Helper function to create a NotificationPreference instance. - Args: - user_id: The user ID for whom the preference is being created - app_name: The name of the notification app - notification_type: The type of notification (e.g., 'assignment', 'discussion') - values: A dictionary containing the preference values for web, email, push, etc. - use_default: List of channels that should use default values - """ - if use_default: - non_core_defaults, core_defaults = NotificationTypeManager().get_notification_app_preference(app_name) - - if non_core_defaults and notification_type in non_core_defaults: - for default in use_default: - values[default] = non_core_defaults[notification_type][default] - - elif core_defaults and notification_type in core_defaults: - for default in use_default: - values[default] = COURSE_NOTIFICATION_APPS[app_name][f'core_{default}'] - 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], - use_default: List[str] = None - ) -> List[NotificationPreference]: - """ - Processes a list of preference configs for a single user. - Returns a list of NotificationPreference objects to be created. - - Args: - user_id: The user ID to process preferences for - course_preferences_configs: List of preference configuration dictionaries - use_default: List of channels ('web', 'push', 'email') that should use default values - """ - new_account_preferences: List[NotificationPreference] = [] - use_default = use_default or [] - - 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, use_default) - ) - - # 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, use_default) - ) - return new_account_preferences - - def _process_batch(self, user_ids: List[int], use_default: List[str] = None) -> List[NotificationPreference]: - """ - Fetches all preferences for a batch of users and processes them. - - Args: - user_ids: List of user IDs to process - use_default: List of channels that should use default values - """ - 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, use_default) - 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.") - # Clear local references to help with garbage collection - del prefs_by_user - del course_prefs - - return all_new_preferences - - def handle(self, *args: Any, **options: Any): # pylint: disable=too-many-statements - dry_run = options['dry_run'] - batch_size = options['batch_size'] - use_default = options.get('use_default', []) - - 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.') - - if use_default: - logger.info(f"Using default values for channels: {', '.join(use_default)}") - self._run_garbage_collection() - - 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, use_default) - - 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 - user_id_batch.clear() - del preferences_to_create - 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, use_default) - 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) - del preferences_to_create - self._run_garbage_collection() - 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." - ) - ) - self._run_garbage_collection() - 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_generate_course_notification_preferences.py b/openedx/core/djangoapps/notifications/management/tests/test_generate_course_notification_preferences.py deleted file mode 100644 index 8d884f0972..0000000000 --- a/openedx/core/djangoapps/notifications/management/tests/test_generate_course_notification_preferences.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Test for generate_notification_preferences management command. -""" - -from unittest import mock - -from django.core.management import call_command - -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase - - -class GenerateCourseNotificationPreferencesTests(ModuleStoreTestCase): - """ - Tests for generate_course_notification_preferences management command. - """ - - def setUp(self): - super().setUp() - self.course_ids = ['course-v1:edX+DemoX.1+2T2017', 'course-v1:edX+DemoX.1+2T2018'] - - @mock.patch( - 'openedx.core.djangoapps.notifications.tasks.create_course_notification_preferences_for_courses' - ) - def test_generate_course_notification_preferences(self, mock_task): - """ - Test generate_course_notification_preferences command. - """ - call_command( - 'generate_course_notification_preferences', - self.course_ids, - ) - mock_task.delay.assert_called_once_with(self.course_ids) 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 deleted file mode 100644 index 2265247029..0000000000 --- a/openedx/core/djangoapps/notifications/management/tests/test_migrate_to_account_level_model.py +++ /dev/null @@ -1,465 +0,0 @@ -# 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.core.management import call_command -from django.db.models.signals import post_save -from django.test import TestCase - -from openedx.core.djangoapps.notifications.email_notifications import EmailCadence -from openedx.core.djangoapps.notifications.handlers import create_user_account_preferences -from openedx.core.djangoapps.notifications.management.commands.migrate_preferences_to_account_level_model import Command -from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, NotificationPreference - -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.""" - # Disconnect before creating users - post_save.disconnect(create_user_account_preferences, sender=User) - 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.' - ) - - @patch(f'{COMMAND_MODULE}.logger') - def test_handle_use_default_mode(self, mock_logger): - """Test command execution while using default mode.""" - sample_config = { - "grading": { - "enabled": True, - "notification_types": { - "core": { - "web": True, - "push": True, - "email": True, - "email_cadence": "Daily" - }, - "ora_grade_assigned": { - "web": True, - "push": True, - "email": True, - "email_cadence": "Daily" - } - }, - "core_notification_types": [] - }, - "discussion": { - "enabled": True, - "notification_types": { - "core": { - "web": False, - "push": False, - "email": False, - "email_cadence": "Weekly" - }, - "new_discussion_post": { - "web": True, - "push": True, - "email": True, - "email_cadence": "Immediately" - } - }, - "core_notification_types": ["response_on_followed_post"] - } - } - CourseNotificationPreference.objects.create( - user=self.user1, - course_id='course-v1:Test+Course+1', - notification_preference_config=sample_config - ) - - call_command( - 'migrate_preferences_to_account_level_model', - '--use-default', - 'push' - ) - # Check that no actual database changes were made - self.assertEqual(NotificationPreference.objects.count(), 3) - self.assertEqual( - NotificationPreference.objects.get(type='ora_grade_assigned').push, - False - ) - self.assertEqual( - NotificationPreference.objects.get(type='new_discussion_post').push, - False - ) - self.assertEqual( - NotificationPreference.objects.get(type='response_on_followed_post').push, - True - ) - - 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/serializers.py b/openedx/core/djangoapps/notifications/serializers.py index 582b33a1c5..41c6201bc1 100644 --- a/openedx/core/djangoapps/notifications/serializers.py +++ b/openedx/core/djangoapps/notifications/serializers.py @@ -5,10 +5,8 @@ Serializers for the notifications API. from django.core.exceptions import ValidationError from rest_framework import serializers -from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.notifications.models import ( - CourseNotificationPreference, Notification, get_additional_notification_channel_settings, get_notification_channels @@ -16,7 +14,6 @@ from openedx.core.djangoapps.notifications.models import ( from .base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES, EmailCadence from .email.utils import is_notification_type_channel_editable -from .utils import remove_preferences_with_no_access def add_info_to_notification_config(config_obj): @@ -84,164 +81,6 @@ class CourseOverviewSerializer(serializers.ModelSerializer): fields = ('id', 'display_name') -class NotificationCourseEnrollmentSerializer(serializers.ModelSerializer): - """ - Serializer for CourseEnrollment model. - """ - course = CourseOverviewSerializer() - - class Meta: - model = CourseEnrollment - fields = ('course',) - - -class UserCourseNotificationPreferenceSerializer(serializers.ModelSerializer): - """ - Serializer for user notification preferences. - """ - course_name = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = CourseNotificationPreference - fields = ('id', 'course_name', 'course_id', 'notification_preference_config',) - read_only_fields = ('id', 'course_name', 'course_id',) - write_only_fields = ('notification_preference_config',) - - def to_representation(self, instance): - """ - Override to_representation to add info of all notification types - """ - preferences = super().to_representation(instance) - course_id = self.context['course_id'] - user = self.context['user'] - preferences = add_info_to_notification_config(preferences) - preferences = remove_preferences_with_no_access(preferences, user) - preferences['notification_preference_config'] = add_non_editable_in_preference( - preferences.get('notification_preference_config', {}) - ) - return preferences - - def get_course_name(self, obj): - """ - Returns course name from course id. - """ - return CourseOverview.get_from_id(obj.course_id).display_name - - -class UserNotificationPreferenceUpdateSerializer(serializers.Serializer): - """ - Serializer for user notification preferences update. - """ - - notification_app = serializers.CharField() - value = serializers.BooleanField(required=False) - notification_type = serializers.CharField(required=False) - notification_channel = serializers.CharField(required=False) - email_cadence = serializers.CharField(required=False) - - def validate(self, attrs): - """ - Validation for notification preference update form - """ - notification_app = attrs.get('notification_app') - notification_type = attrs.get('notification_type') - notification_channel = attrs.get('notification_channel') - notification_email_cadence = attrs.get('email_cadence') - - notification_app_config = self.instance.notification_preference_config - - if notification_email_cadence: - if not notification_type: - raise ValidationError( - 'notification_type is required for email_cadence.' - ) - if EmailCadence.get_email_cadence_value(notification_email_cadence) is None: - raise ValidationError( - f'{attrs.get("value")} is not a valid email cadence.' - ) - - if notification_type and not notification_channel: - raise ValidationError( - 'notification_channel is required for notification_type.' - ) - - if not notification_app_config.get(notification_app, None): - raise ValidationError( - f'{notification_app} is not a valid notification app.' - ) - - if notification_type: - notification_types = notification_app_config.get(notification_app).get('notification_types') - - if not notification_types.get(notification_type, None): - raise ValidationError( - f'{notification_type} is not a valid notification type.' - ) - - if ( - notification_channel and - notification_channel not in get_notification_channels() - and notification_channel not in get_additional_notification_channel_settings() - ): - raise ValidationError( - f'{notification_channel} is not a valid notification channel setting.' - ) - - if notification_app and notification_type and notification_channel: - if not is_notification_type_channel_editable( - notification_app, - notification_type, - 'email' if notification_channel == 'email_cadence' else notification_channel - ): - raise ValidationError({ - 'notification_channel': ( - f'{notification_channel} is not editable for notification type ' - f'{notification_type}.' - ) - }) - - return attrs - - def update(self, instance, validated_data): - """ - Update notification preference config. - """ - notification_app = validated_data.get('notification_app') - notification_type = validated_data.get('notification_type') - notification_channel = validated_data.get('notification_channel') - value = validated_data.get('value') - notification_email_cadence = validated_data.get('email_cadence') - - user_notification_preference_config = instance.notification_preference_config - - # Notification email cadence update - if notification_email_cadence and notification_type: - user_notification_preference_config[notification_app]['notification_types'][notification_type][ - 'email_cadence'] = notification_email_cadence - - # Notification type channel update - elif notification_type and notification_channel: - # Update the notification preference for specific notification type - user_notification_preference_config[ - notification_app]['notification_types'][notification_type][notification_channel] = value - - # Notification app-wide channel update - elif notification_channel and not notification_type: - app_prefs = user_notification_preference_config[notification_app] - for notification_type_name, notification_type_preferences in app_prefs['notification_types'].items(): - non_editable_channels = get_non_editable_channels(notification_app).get(notification_type_name, []) - if notification_channel not in non_editable_channels: - app_prefs['notification_types'][notification_type_name][notification_channel] = value - - # Notification app update - else: - # Update the notification preference for notification_app - user_notification_preference_config[notification_app]['enabled'] = value - - instance.save() - return instance - - class NotificationSerializer(serializers.ModelSerializer): """ Serializer for the Notification model. diff --git a/openedx/core/djangoapps/notifications/tasks.py b/openedx/core/djangoapps/notifications/tasks.py index b634a0b67f..fb9f95990d 100644 --- a/openedx/core/djangoapps/notifications/tasks.py +++ b/openedx/core/djangoapps/notifications/tasks.py @@ -8,12 +8,10 @@ from celery import shared_task from celery.utils.log import get_task_logger from django.conf import settings from django.core.exceptions import ValidationError -from django.db import transaction from edx_django_utils.monitoring import set_code_owner_attribute from opaque_keys.edx.keys import CourseKey from pytz import UTC -from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.notifications.audience_filters import NotificationFilter from openedx.core.djangoapps.notifications.base_notification import ( COURSE_NOTIFICATION_APPS, @@ -35,10 +33,8 @@ from openedx.core.djangoapps.notifications.grouping_notifications import ( group_user_notifications ) from openedx.core.djangoapps.notifications.models import ( - CourseNotificationPreference, Notification, NotificationPreference, - get_course_notification_preference_config_version ) from openedx.core.djangoapps.notifications.push.tasks import send_ace_msg_to_push_channel from openedx.core.djangoapps.notifications.utils import clean_arguments, get_list_in_batches @@ -46,31 +42,6 @@ from openedx.core.djangoapps.notifications.utils import clean_arguments, get_lis logger = get_task_logger(__name__) -@shared_task(bind=True, ignore_result=True) -@set_code_owner_attribute -@transaction.atomic -def create_course_notification_preferences_for_courses(self, course_ids): - """ - This task creates Course Notification Preferences for users in courses. - """ - newly_created = 0 - for course_id in course_ids: - enrollments = CourseEnrollment.objects.filter(course_id=course_id, is_active=True) - logger.debug(f'Found {enrollments.count()} enrollments for course {course_id}') - logger.debug(f'Creating Course Notification Preferences for course {course_id}') - for enrollment in enrollments: - _, created = CourseNotificationPreference.objects.get_or_create( - user=enrollment.user, course_id=course_id - ) - if created: - newly_created += 1 - - logger.debug( - f'CourseNotificationPreference back-fill completed for course {course_id}.\n' - f'Newly created course preferences: {newly_created}.\n' - ) - - @shared_task(ignore_result=True) @set_code_owner_attribute def delete_notifications(kwargs): @@ -247,16 +218,6 @@ def is_notification_valid(notification_type, context): return True -def update_user_preference(preference: CourseNotificationPreference, user_id, course_id): - """ - Update user preference if config version is changed. - """ - current_version = get_course_notification_preference_config_version() - if preference.config_version != current_version: - return preference.get_user_course_preference(user_id, course_id) - return preference - - def update_account_user_preference(user_id: int) -> None: """ Update account level user preferences to ensure all notification types are present. @@ -333,26 +294,6 @@ def _get_channel_default(is_core: bool, notification_type: str, channel: str) -> return COURSE_NOTIFICATION_TYPES[notification_type][channel] -def create_notification_pref_if_not_exists(user_ids: List, preferences: List, course_id: CourseKey): - """ - Create notification preference if not exist. - """ - new_preferences = [] - - for user_id in user_ids: - if not any(preference.user_id == int(user_id) for preference in preferences): - new_preferences.append(CourseNotificationPreference( - user_id=user_id, - course_id=course_id, - )) - if new_preferences: - # ignoring conflicts because it is possible that preference is already created by another process - # conflicts may arise because of constraint on user_id and course_id fields in model - CourseNotificationPreference.objects.bulk_create(new_preferences, ignore_conflicts=True) - preferences = preferences + new_preferences - return preferences - - def create_account_notification_pref_if_not_exists(user_ids: List, preferences: List, notification_type: str): """ Create account level notification preference if not exist. diff --git a/openedx/core/djangoapps/notifications/tests/test_tasks.py b/openedx/core/djangoapps/notifications/tests/test_tasks.py deleted file mode 100644 index 883986de0b..0000000000 --- a/openedx/core/djangoapps/notifications/tests/test_tasks.py +++ /dev/null @@ -1,516 +0,0 @@ -""" -Tests for notifications tasks. -""" - -import datetime -from unittest.mock import patch - -import ddt -from django.conf import settings -from django.core.exceptions import ValidationError -from edx_toggles.toggles.testutils import override_waffle_flag - -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory - -from ..config.waffle import ENABLE_NOTIFICATIONS, ENABLE_PUSH_NOTIFICATIONS -from ..models import CourseNotificationPreference, Notification -from ..tasks import ( - create_notification_pref_if_not_exists, - delete_notifications, - send_notifications, - update_user_preference -) -from .utils import create_notification - - -@patch('openedx.core.djangoapps.notifications.models.COURSE_NOTIFICATION_CONFIG_VERSION', 1) -class TestNotificationsTasks(ModuleStoreTestCase): - """ - Tests for notifications tasks. - """ - - def setUp(self): - """ - Create a course and users for the course. - """ - - super().setUp() - self.user = UserFactory() - self.user_1 = UserFactory() - self.user_2 = UserFactory() - self.course_1 = CourseFactory.create( - org='testorg', - number='testcourse', - run='testrun' - ) - self.course_2 = CourseFactory.create( - org='testorg', - number='testcourse_2', - run='testrun' - ) - self.preference_v1 = CourseNotificationPreference.objects.create( - user_id=self.user.id, - course_id=self.course_1.id, - config_version=0, - ) - self.preference_v2 = CourseNotificationPreference.objects.create( - user_id=self.user.id, - course_id=self.course_2.id, - config_version=1, - ) - - def test_update_user_preference(self): - """ - Test whether update_user_preference updates the preference with the latest config version. - """ - # Test whether update_user_preference updates the preference with a different config version - updated_preference = update_user_preference(self.preference_v1, self.user, self.course_1.id) - self.assertEqual(updated_preference.config_version, 1) - - # Test whether update_user_preference does not update the preference if the config version is the same - updated_preference = update_user_preference(self.preference_v2, self.user, self.course_2.id) - self.assertEqual(updated_preference.config_version, 1) - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - def test_create_notification_pref_if_not_exists(self): - """ - Test whether create_notification_pref_if_not_exists creates a new preference if it doesn't exist. - """ - # Test whether create_notification_pref_if_not_exists creates a new preference if it doesn't exist - user_ids = [self.user.id, self.user_1.id, self.user_2.id] - preferences = [self.preference_v2] - updated_preferences = create_notification_pref_if_not_exists(user_ids, preferences, self.course_2.id) - self.assertEqual(len(updated_preferences), 3) # Should have created two new preferences - - # Test whether create_notification_pref_if_not_exists doesn't create a new preference if it already exists - updated_preferences = create_notification_pref_if_not_exists(user_ids, preferences, self.course_2.id) - self.assertEqual(len(updated_preferences), 3) # No new preferences should be created this time - - -@ddt.ddt -class SendNotificationsTest(ModuleStoreTestCase): - """ - Tests for send_notifications. - """ - - def setUp(self): - """ - Create a course and users for the course. - """ - - super().setUp() - self.user = UserFactory() - self.course_1 = CourseFactory.create( - org='testorg', - number='testcourse', - run='testrun' - ) - - self.preference_v1 = CourseNotificationPreference.objects.create( - user_id=self.user.id, - course_id=self.course_1.id, - config_version=0, - ) - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) - @ddt.data( - ('discussion', 'new_comment_on_response'), # core notification - ('discussion', 'new_response'), # non core notification - ) - @ddt.unpack - def test_send_notifications(self, app_name, notification_type): - """ - Test whether send_notifications creates a new notification. - """ - context = { - 'post_title': 'Post title', - 'replier_name': 'replier name', - } - content_url = 'https://example.com/' - - # Call the `send_notifications` function. - with patch('openedx.core.djangoapps.notifications.tasks.notification_generated_event') as event_mock: - send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url) - assert event_mock.called - assert event_mock.call_args[0][0] == [self.user.id] - assert event_mock.call_args[0][1] == app_name - assert event_mock.call_args[0][2] == notification_type - - # Assert that `Notification` objects have been created for the users. - notification = Notification.objects.filter(user_id=self.user.id).first() - # Assert that the `Notification` objects have the correct properties. - self.assertEqual(notification.user_id, self.user.id) - self.assertEqual(notification.app_name, app_name) - self.assertEqual(notification.notification_type, notification_type) - self.assertEqual(notification.content_context, context) - self.assertEqual(notification.content_url, content_url) - self.assertEqual(notification.course_id, self.course_1.id) - - @ddt.data(True, False) - def test_enable_notification_flag(self, flag_value): - """ - Tests if notification is sent when flag is enabled and notification - is not sent when flag is disabled - """ - app_name = "discussion" - notification_type = "new_response" - context = { - 'post_title': 'Post title', - 'replier_name': 'replier name', - } - content_url = 'https://example.com/' - with override_waffle_flag(ENABLE_NOTIFICATIONS, active=flag_value): - send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url) - created_notifications_count = 1 if flag_value else 0 - self.assertEqual(len(Notification.objects.all()), created_notifications_count) - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) - def test_notification_not_send_with_preference_disabled(self): - """ - Tests notification not send if preference is disabled - """ - app_name = "discussion" - notification_type = "new_response" - context = { - 'post_title': 'Post title', - 'replier_name': 'replier name', - } - content_url = 'https://example.com/' - - preference = CourseNotificationPreference.get_user_course_preference(self.user.id, self.course_1.id) - app_prefs = preference.notification_preference_config[app_name] - app_prefs['notification_types']['core']['web'] = False - app_prefs['notification_types']['core']['email'] = False - app_prefs['notification_types']['core']['push'] = False - preference.save() - - send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url) - self.assertEqual(len(Notification.objects.all()), 1) - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) - def test_send_notification_with_grouping_enabled(self): - """ - Test send_notifications with grouping enabled. - """ - ( - self.preference_v1.notification_preference_config['discussion'] - ['notification_types']['new_discussion_post']['web'] - ) = True - self.preference_v1.save() - with patch('openedx.core.djangoapps.notifications.tasks.group_user_notifications') as user_notifications_mock: - context = { - 'post_title': 'Test Post', - 'username': 'Test Author', - 'group_by_id': 'group_by_id' - } - content_url = 'https://example.com/' - send_notifications( - [self.user.id], - str(self.course_1.id), - 'discussion', - 'new_discussion_post', - {**context}, - content_url - ) - send_notifications( - [self.user.id], - str(self.course_1.id), - 'discussion', - 'new_discussion_post', - {**context}, - content_url - ) - self.assertEqual(Notification.objects.filter(user_id=self.user.id).count(), 0) - user_notifications_mock.assert_not_called() - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - def test_notification_not_created_when_context_is_incomplete(self): - try: - send_notifications([self.user.id], str(self.course_1.id), "discussion", "new_comment", {}, "") - except Exception as exc: # pylint: disable=broad-except - assert isinstance(exc, ValidationError) - - -@ddt.ddt -class SendBatchNotificationsTest(ModuleStoreTestCase): - """ - Test that notification and notification preferences are created in batches - """ - - def setUp(self): - """ - Setups test case - """ - super().setUp() - self.course = CourseFactory.create( - org='test_org', - number='test_course', - run='test_run' - ) - - def _create_users(self, num_of_users): - """ - Create users and enroll them in course - """ - users = [ - UserFactory.create(username=f'user{i}', email=f'user{i}@example.com') - for i in range(num_of_users) - ] - for user in users: - CourseEnrollment.enroll(user=user, course_key=self.course.id) - return users - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - @ddt.data( - (settings.NOTIFICATION_CREATION_BATCH_SIZE, 10, 3), - (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 12, 5), - (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 10, 3), - ) - @ddt.unpack - def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count): - """ - Tests notifications and notification preferences are created in batches - """ - notification_app = "discussion" - notification_type = "new_discussion_post" - users = self._create_users(creation_size) - user_ids = [user.id for user in users] - context = { - "post_title": "Test Post", - "username": "Test Author" - } - - # Creating preferences and asserting query count - with self.assertNumQueries(prefs_query_count): - send_notifications(user_ids, str(self.course.id), notification_app, notification_type, - context, "http://test.url") - - # Updating preferences for notification creation - preferences = CourseNotificationPreference.objects.filter( - user_id__in=user_ids, - course_id=self.course.id - ) - for preference in preferences: - discussion_config = preference.notification_preference_config['discussion'] - discussion_config['notification_types'][notification_type]['web'] = True - discussion_config['notification_types'][notification_type]['push'] = True - preference.save() - - # Creating notifications and asserting query count - with self.assertNumQueries(notifications_query_count): - send_notifications(user_ids, str(self.course.id), notification_app, notification_type, - context, "http://test.url") - - def test_preference_not_created_for_default_off_preference(self): - """ - Tests if new preferences are NOT created when default preference for - notification type is False - """ - notification_app = "discussion" - notification_type = "new_discussion_post" - users = self._create_users(20) - user_ids = [user.id for user in users] - context = { - "post_title": "Test Post", - "username": "Test Author" - } - with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): - with self.assertNumQueries(10): - send_notifications(user_ids, str(self.course.id), notification_app, notification_type, - context, "http://test.url") - - def test_preference_created_for_default_on_preference(self): - """ - Tests if new preferences are created when default preference for - notification type is True - """ - notification_app = "discussion" - notification_type = "new_comment" - users = self._create_users(20) - user_ids = [user.id for user in users] - context = { - "post_title": "Test Post", - "author_name": "Test Author", - "replier_name": "Replier Name" - } - with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): - with override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True): - with self.assertNumQueries(12): - send_notifications(user_ids, str(self.course.id), notification_app, notification_type, - context, "http://test.url") - - def _update_user_preference(self, user_id, pref_exists): - """ - Removes or creates user preference based on pref_exists - """ - if pref_exists: - CourseNotificationPreference.objects.get_or_create(user_id=user_id, course_id=self.course.id) - else: - CourseNotificationPreference.objects.filter(user_id=user_id, course_id=self.course.id).delete() - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) - @ddt.data( - ("new_response", True, True, 2), - ("new_response", False, False, 2), - ("new_response", True, False, 2), - ("new_discussion_post", True, True, 0), - ("new_discussion_post", False, False, 0), - ("new_discussion_post", True, False, 0), - ) - @ddt.unpack - def test_preference_enabled_in_batch_audience(self, notification_type, - user_1_pref_exists, user_2_pref_exists, generated_count): - """ - Tests if users with preference enabled in batch gets notification - """ - users = self._create_users(2) - user_ids = [user.id for user in users] - self._update_user_preference(user_ids[0], user_1_pref_exists) - self._update_user_preference(user_ids[1], user_2_pref_exists) - - app_name = "discussion" - context = { - 'post_title': 'Post title', - 'username': 'Username', - 'replier_name': 'replier name', - 'author_name': 'Authorname' - } - content_url = 'https://example.com/' - send_notifications(user_ids, str(self.course.id), app_name, notification_type, context, content_url) - self.assertEqual(len(Notification.objects.all()), generated_count) - - -class TestDeleteNotificationTask(ModuleStoreTestCase): - """ - Tests delete_notification_function - """ - - def setUp(self): - """ - Setup - """ - super().setUp() - self.user = UserFactory() - self.course_1 = CourseFactory.create(org='org', number='num', run='run_01') - self.course_2 = CourseFactory.create(org='org', number='num', run='run_02') - Notification.objects.all().delete() - - def test_app_name_param(self): - """ - Tests if app_name parameter works as expected - """ - assert not Notification.objects.all() - create_notification(self.user, self.course_1.id, app_name='discussion', notification_type='new_comment') - create_notification(self.user, self.course_1.id, app_name='updates', notification_type='course_updates') - delete_notifications({'app_name': 'discussion'}) - assert not Notification.objects.filter(app_name='discussion') - assert Notification.objects.filter(app_name='updates') - - def test_notification_type_param(self): - """ - Tests if notification_type parameter works as expected - """ - assert not Notification.objects.all() - create_notification(self.user, self.course_1.id, app_name='discussion', notification_type='new_comment') - create_notification(self.user, self.course_1.id, app_name='discussion', notification_type='new_response') - delete_notifications({'notification_type': 'new_comment'}) - assert not Notification.objects.filter(notification_type='new_comment') - assert Notification.objects.filter(notification_type='new_response') - - def test_created_param(self): - """ - Tests if created parameter works as expected - """ - assert not Notification.objects.all() - create_notification(self.user, self.course_1.id, created=datetime.datetime(2024, 2, 10)) - create_notification(self.user, self.course_2.id, created=datetime.datetime(2024, 3, 12, 5)) - kwargs = { - 'created': { - 'created__gte': datetime.datetime(2024, 3, 12, 0, 0, 0), - 'created__lte': datetime.datetime(2024, 3, 12, 23, 59, 59), - } - } - delete_notifications(kwargs) - self.assertEqual(Notification.objects.all().count(), 1) - - def test_course_id_param(self): - """ - Tests if course_id parameter works as expected - """ - assert not Notification.objects.all() - create_notification(self.user, self.course_1.id) - create_notification(self.user, self.course_2.id) - delete_notifications({'course_id': self.course_1.id}) - assert not Notification.objects.filter(course_id=self.course_1.id) - assert Notification.objects.filter(course_id=self.course_2.id) - - -@ddt.ddt -class NotificationCreationOnChannelsTests(ModuleStoreTestCase): - """ - Tests for notification creation and channels value. - """ - - def setUp(self): - """ - Create a course and users for tests. - """ - - super().setUp() - self.user = UserFactory() - self.course = CourseFactory.create( - org='testorg', - number='testcourse', - run='testrun' - ) - - self.preference = CourseNotificationPreference.objects.create( - user_id=self.user.id, - course_id=self.course.id, - config_version=0, - ) - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - @ddt.data( - (False, False, 0), - (False, True, 0), - (True, False, 0), - (True, True, 0), - ) - @ddt.unpack - def test_notification_is_created_when_any_channel_is_enabled(self, web_value, email_value, generated_count): - """ - Tests if notification is created if any preference is enabled - """ - app_name = 'discussion' - notification_type = 'new_discussion_post' - app_prefs = self.preference.notification_preference_config[app_name] - app_prefs['notification_types'][notification_type]['web'] = web_value - app_prefs['notification_types'][notification_type]['email'] = email_value - kwargs = { - 'user_ids': [self.user.id], - 'course_key': str(self.course.id), - 'app_name': app_name, - 'notification_type': notification_type, - 'content_url': 'https://example.com/', - 'context': { - 'post_title': 'Post title', - 'username': 'user name', - }, - } - self.preference.save() - with patch('openedx.core.djangoapps.notifications.tasks.notification_generated_event') as event_mock: - send_notifications(**kwargs) - notifications = Notification.objects.all() - assert len(notifications) == generated_count - if notifications: - notification = Notification.objects.all()[0] - assert notification.web == web_value - assert notification.email == email_value diff --git a/openedx/core/djangoapps/notifications/tests/test_tasks_with_account_level_pref.py b/openedx/core/djangoapps/notifications/tests/test_tasks_with_account_level_pref.py index 19ccc52971..ae65afc9aa 100644 --- a/openedx/core/djangoapps/notifications/tests/test_tasks_with_account_level_pref.py +++ b/openedx/core/djangoapps/notifications/tests/test_tasks_with_account_level_pref.py @@ -18,78 +18,12 @@ from xmodule.modulestore.tests.factories import CourseFactory from ..config.waffle import ENABLE_NOTIFICATIONS from ..models import CourseNotificationPreference, Notification, NotificationPreference from ..tasks import ( - create_notification_pref_if_not_exists, delete_notifications, send_notifications, - update_user_preference ) from .utils import create_notification -@patch('openedx.core.djangoapps.notifications.models.COURSE_NOTIFICATION_CONFIG_VERSION', 1) -class TestNotificationsTasks(ModuleStoreTestCase): - """ - Tests for notifications tasks. - """ - - def setUp(self): - """ - Create a course and users for the course. - """ - - super().setUp() - self.user = UserFactory() - self.user_1 = UserFactory() - self.user_2 = UserFactory() - self.course_1 = CourseFactory.create( - org='testorg', - number='testcourse', - run='testrun' - ) - self.course_2 = CourseFactory.create( - org='testorg', - number='testcourse_2', - run='testrun' - ) - self.preference_v1 = CourseNotificationPreference.objects.create( - user_id=self.user.id, - course_id=self.course_1.id, - config_version=0, - ) - self.preference_v2 = CourseNotificationPreference.objects.create( - user_id=self.user.id, - course_id=self.course_2.id, - config_version=1, - ) - - def test_update_user_preference(self): - """ - Test whether update_user_preference updates the preference with the latest config version. - """ - # Test whether update_user_preference updates the preference with a different config version - updated_preference = update_user_preference(self.preference_v1, self.user, self.course_1.id) - self.assertEqual(updated_preference.config_version, 1) - - # Test whether update_user_preference does not update the preference if the config version is the same - updated_preference = update_user_preference(self.preference_v2, self.user, self.course_2.id) - self.assertEqual(updated_preference.config_version, 1) - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - def test_create_notification_pref_if_not_exists(self): - """ - Test whether create_notification_pref_if_not_exists creates a new preference if it doesn't exist. - """ - # Test whether create_notification_pref_if_not_exists creates a new preference if it doesn't exist - user_ids = [self.user.id, self.user_1.id, self.user_2.id] - preferences = [self.preference_v2] - updated_preferences = create_notification_pref_if_not_exists(user_ids, preferences, self.course_2.id) - self.assertEqual(len(updated_preferences), 3) # Should have created two new preferences - - # Test whether create_notification_pref_if_not_exists doesn't create a new preference if it already exists - updated_preferences = create_notification_pref_if_not_exists(user_ids, preferences, self.course_2.id) - self.assertEqual(len(updated_preferences), 3) # No new preferences should be created this time - - @ddt.ddt class SendNotificationsTest(ModuleStoreTestCase): """ @@ -282,9 +216,9 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) @ddt.data( - (settings.NOTIFICATION_CREATION_BATCH_SIZE, 10, 3), - (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 12, 5), - (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 10, 3), + (settings.NOTIFICATION_CREATION_BATCH_SIZE, 12, 3), + (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 14, 5), + (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 12, 3), ) @ddt.unpack def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count): @@ -334,7 +268,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): "username": "Test Author" } with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): - with self.assertNumQueries(10): + with self.assertNumQueries(12): send_notifications(user_ids, str(self.course.id), notification_app, notification_type, context, "http://test.url") @@ -354,7 +288,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): "replier_name": "Replier Name" } with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): - with self.assertNumQueries(12): + with self.assertNumQueries(14): send_notifications(user_ids, str(self.course.id), notification_app, notification_type, context, "http://test.url") diff --git a/openedx/core/djangoapps/notifications/tests/test_utils.py b/openedx/core/djangoapps/notifications/tests/test_utils.py deleted file mode 100644 index c00f2ba237..0000000000 --- a/openedx/core/djangoapps/notifications/tests/test_utils.py +++ /dev/null @@ -1,349 +0,0 @@ -""" -Test cases for the notification utility functions. -""" -import copy -import unittest - -import pytest - -from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.django_comment_common.models import assign_role, FORUM_ROLE_MODERATOR -from openedx.core.djangoapps.notifications.utils import aggregate_notification_configs, \ - filter_out_visible_preferences_by_course_ids - - -class TestAggregateNotificationConfigs(unittest.TestCase): - """ - Test cases for the aggregate_notification_configs function. - """ - - def test_empty_configs_list_returns_default(self): - """ - If the configs list is empty, the default config should be returned. - """ - default_config = [{ - "grading": { - "enabled": False, - "non_editable": {}, - "notification_types": { - "core": { - "web": False, - "push": False, - "email": False, - "email_cadence": "Daily" - } - } - } - }] - - result = aggregate_notification_configs(default_config) - assert result == default_config[0] - - def test_enable_notification_type(self): - """ - If a config enables a notification type, it should be enabled in the result. - """ - - config_list = [ - { - "grading": { - "enabled": False, - "non_editable": {}, - "notification_types": { - "core": { - "web": False, - "push": False, - "email": False, - "email_cadence": "Weekly" - } - } - } - }, - { - "grading": { - "enabled": True, - "notification_types": { - "core": { - "web": True, - "push": True, - "email": True, - "email_cadence": "Weekly" - } - } - } - }] - - result = aggregate_notification_configs(config_list) - assert result["grading"]["enabled"] is True - assert result["grading"]["notification_types"]["core"]["web"] is True - assert result["grading"]["notification_types"]["core"]["push"] is True - assert result["grading"]["notification_types"]["core"]["email"] is True - # Use default email_cadence - assert result["grading"]["notification_types"]["core"]["email_cadence"] == "Weekly" - - def test_merge_core_notification_types(self): - """ - Core notification types should be merged across configs. - """ - - config_list = [ - { - "discussion": { - "enabled": True, - "core_notification_types": ["new_comment"], - "notification_types": {} - } - }, - { - "discussion": { - "core_notification_types": ["new_response", "new_comment"] - } - - }] - - result = aggregate_notification_configs(config_list) - assert set(result["discussion"]["core_notification_types"]) == { - "new_comment", "new_response" - } - - def test_multiple_configs_aggregate(self): - """ - Multiple configs should be aggregated together. - """ - - config_list = [ - { - "updates": { - "enabled": False, - "notification_types": { - "course_updates": { - "web": False, - "push": False, - "email": False, - "email_cadence": "Weekly" - } - } - } - }, - { - "updates": { - "enabled": True, - "notification_types": { - "course_updates": { - "web": True, - "email_cadence": "Weekly" - } - } - } - }, - { - "updates": { - "notification_types": { - "course_updates": { - "push": True, - "email_cadence": "Weekly" - } - } - } - } - ] - - result = aggregate_notification_configs(config_list) - assert result["updates"]["enabled"] is True - assert result["updates"]["notification_types"]["course_updates"]["web"] is True - assert result["updates"]["notification_types"]["course_updates"]["push"] is True - assert result["updates"]["notification_types"]["course_updates"]["email"] is False - # Use default email_cadence - assert result["updates"]["notification_types"]["course_updates"]["email_cadence"] == "Weekly" - - def test_ignore_unknown_notification_types(self): - """ - Unknown notification types should be ignored. - """ - config_list = [ - { - "grading": { - "enabled": False, - "notification_types": { - "core": { - "web": False, - "push": False, - "email": False, - "email_cadence": "Daily" - } - } - } - }, - { - "grading": { - "notification_types": { - "unknown_type": { - "web": True, - "push": True, - "email": True - } - } - } - }] - - result = aggregate_notification_configs(config_list) - assert "unknown_type" not in result["grading"]["notification_types"] - assert result["grading"]["notification_types"]["core"]["web"] is False - - def test_ignore_unknown_categories(self): - """ - Unknown categories should be ignored. - """ - - config_list = [ - { - "grading": { - "enabled": False, - "notification_types": {} - } - }, - { - "unknown_category": { - "enabled": True, - "notification_types": {} - } - }] - - result = aggregate_notification_configs(config_list) - assert "unknown_category" not in result - assert result["grading"]["enabled"] is False - - def test_preserves_default_structure(self): - """ - The resulting config should have the same structure as the default config. - """ - - config_list = [ - { - "discussion": { - "enabled": False, - "non_editable": {"core": ["web"]}, - "notification_types": { - "core": { - "web": False, - "push": False, - "email": False, - "email_cadence": "Weekly" - } - }, - "core_notification_types": [] - } - }, - { - "discussion": { - "enabled": True, - "extra_field": "should_not_appear" - } - } - ] - - result = aggregate_notification_configs(config_list) - assert set(result["discussion"].keys()) == { - "enabled", "non_editable", "notification_types", "core_notification_types" - } - assert "extra_field" not in result["discussion"] - - def test_if_email_cadence_has_diff_set_mix_as_value(self): - """ - If email_cadence is different in the configs, set it to "Mixed". - """ - config_list = [ - { - "grading": { - "enabled": False, - "notification_types": { - "core": { - "web": False, - "push": False, - "email": False, - "email_cadence": "Daily" - } - } - } - }, - { - "grading": { - "enabled": True, - "notification_types": { - "core": { - "web": True, - "push": True, - "email": True, - "email_cadence": "Weekly" - } - } - } - }, - { - "grading": { - "notification_types": { - "core": { - "email_cadence": "Monthly" - } - } - } - } - ] - - result = aggregate_notification_configs(config_list) - assert result["grading"]["notification_types"]["core"]["email_cadence"] == "Mixed" - - -@pytest.mark.django_db -class TestVisibilityFilter(unittest.TestCase): - """ - Test cases for the filter_out_visible_preferences_by_course_ids function. - """ - - def setUp(self): - self.user = UserFactory() - self.course_key = "course-v1:edX+DemoX+Demo_Course" - self.mock_preferences = { - 'discussion': { - 'enabled': True, - 'non_editable': {'core': ['web']}, - 'notification_types': { - 'core': {'web': True, 'push': True, 'email': True, 'email_cadence': 'Daily'}, - 'content_reported': {'web': True, 'push': True, 'email': True, 'email_cadence': 'Daily'}, - 'new_question_post': {'web': False, 'push': False, 'email': False, 'email_cadence': 'Daily'}, - 'new_discussion_post': {'web': False, 'push': False, 'email': False, 'email_cadence': 'Daily'}, - 'new_instructor_all_learners_post': { - 'web': True, 'push': False, 'email': False, 'email_cadence': 'Daily' - } - }, - 'core_notification_types': [ - 'new_response', 'comment_on_followed_post', - 'response_endorsed_on_thread', 'new_comment_on_response', - 'new_comment', 'response_on_followed_post', 'response_endorsed' - ] - } - } - - def test_visibility_filter_with_no_role(self): - """ - Test that the preferences are filtered out correctly when the user has no role. - """ - updated_preferences = filter_out_visible_preferences_by_course_ids( - self.user, - copy.deepcopy(self.mock_preferences), - [self.course_key] - ) - assert updated_preferences != self.mock_preferences - assert not updated_preferences["discussion"]["notification_types"].get("content_reported", False) - - def test_visibility_filter_with_instructor_role(self): - """ - Instructors should see all preferences. - """ - updated_preferences = filter_out_visible_preferences_by_course_ids( - self.user, - self.mock_preferences, - [self.course_key] - ) - assign_role(self.course_key, self.user, FORUM_ROLE_MODERATOR) - assert updated_preferences == self.mock_preferences diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 8db3de117f..104f05df8c 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -1,12 +1,8 @@ """ Tests for the views in the notifications app. """ -import itertools -import json -from copy import deepcopy from datetime import datetime, timedelta from unittest import mock -from unittest.mock import patch import ddt from django.conf import settings @@ -14,8 +10,6 @@ from django.contrib.auth import get_user_model from django.test.utils import override_settings from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag -from openedx_events.learning.data import CourseData, CourseEnrollmentData, UserData, UserPersonalData -from openedx_events.learning.signals import COURSE_ENROLLMENT_CREATED from pytz import UTC from rest_framework import status from rest_framework.test import APIClient, APITestCase @@ -31,578 +25,22 @@ from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_MODERATOR ) from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS -from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY from openedx.core.djangoapps.notifications.email.utils import encrypt_object, encrypt_string from openedx.core.djangoapps.notifications.models import ( CourseNotificationPreference, Notification, get_course_notification_preference_config_version, NotificationPreference ) -from openedx.core.djangoapps.notifications.serializers import NotificationCourseEnrollmentSerializer, \ - add_non_editable_in_preference -from openedx.core.djangoapps.user_api.models import UserPreference -from openedx.core.djangoapps.notifications.email.utils import update_user_preferences_from_patch +from openedx.core.djangoapps.notifications.serializers import add_non_editable_in_preference from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from ..base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES, NotificationAppManager, \ - NotificationTypeManager +from ..base_notification import COURSE_NOTIFICATION_APPS, NotificationTypeManager from ..utils import get_notification_types_with_visibility_settings, exclude_inaccessible_preferences User = get_user_model() -@ddt.ddt -class CourseEnrollmentListViewTest(ModuleStoreTestCase): - """ - Tests for the CourseEnrollmentListView. - """ - - def setUp(self): - """ - Set up the test. - """ - super().setUp() - self.client = APIClient() - self.user = UserFactory() - course_1 = CourseFactory.create( - org='testorg', - number='testcourse', - run='testrun' - ) - course_2 = CourseFactory.create( - org='testorg', - number='testcourse_two', - run='testrun' - ) - course_overview_1 = CourseOverviewFactory.create(id=course_1.id, org='AwesomeOrg') - course_overview_2 = CourseOverviewFactory.create(id=course_2.id, org='AwesomeOrg') - - self.enrollment1 = CourseEnrollment.objects.create( - user=self.user, - course=course_overview_1, - is_active=True, - mode='audit' - ) - self.enrollment2 = CourseEnrollment.objects.create( - user=self.user, - course=course_overview_2, - is_active=False, - mode='honor' - ) - - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - @ddt.unpack - def test_course_enrollment_list_view(self): - """ - Test the CourseEnrollmentListView. - """ - self.client.login(username=self.user.username, password=self.TEST_PASSWORD) - url = reverse('enrollment-list') - response = self.client.get(url) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - data = response.data['results'] - enrollments = CourseEnrollment.objects.filter(user=self.user, is_active=True) - expected_data = NotificationCourseEnrollmentSerializer(enrollments, many=True).data - - self.assertEqual(len(data), 1) - self.assertEqual(data, expected_data) - self.assertEqual(response.data['show_preferences'], True) - - def test_course_enrollment_api_permission(self): - """ - Calls api without login. - Check is 401 is returned - """ - url = reverse('enrollment-list') - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - -@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) -@ddt.ddt -class CourseEnrollmentPostSaveTest(ModuleStoreTestCase): - """ - Tests for the post_save signal for CourseEnrollment. - """ - - def setUp(self): - """ - Set up the test. - """ - super().setUp() - self.user = UserFactory() - self.course = CourseFactory.create( - org='testorg', - number='testcourse', - run='testrun' - ) - - course_overview = CourseOverviewFactory.create(id=self.course.id, org='AwesomeOrg') - self.course_enrollment = CourseEnrollment.objects.create( - user=self.user, - course=course_overview, - is_active=True, - mode='audit' - ) - - def test_course_enrollment_post_save(self): - """ - Test the post_save signal for CourseEnrollment. - """ - # Emit post_save signal - enrollment_data = CourseEnrollmentData( - user=UserData( - pii=UserPersonalData( - username=self.user.username, - email=self.user.email, - name=self.user.profile.name, - ), - id=self.user.id, - is_active=self.user.is_active, - ), - course=CourseData( - course_key=self.course.id, - display_name=self.course.display_name, - ), - mode=self.course_enrollment.mode, - is_active=self.course_enrollment.is_active, - creation_date=self.course_enrollment.created, - ) - COURSE_ENROLLMENT_CREATED.send_event( - enrollment=enrollment_data - ) - - # Assert that CourseNotificationPreference object was created with correct attributes - notification_preferences = CourseNotificationPreference.objects.all() - - self.assertEqual(notification_preferences.count(), 1) - self.assertEqual(notification_preferences[0].user, self.user) - - def test_disabled_email_preference_is_generated_after_unsubscribe(self): - """ - Test the post_save signal for CourseEnrollment for user with one-click unsubscribe. - """ - UserPreference.objects.create(user_id=self.user.id, key=ONE_CLICK_EMAIL_UNSUB_KEY) - enrollment_data = CourseEnrollmentData( - user=UserData( - pii=UserPersonalData( - username=self.user.username, - email=self.user.email, - name=self.user.profile.name, - ), - id=self.user.id, - is_active=self.user.is_active, - ), - course=CourseData( - course_key=self.course.id, - display_name=self.course.display_name, - ), - mode=self.course_enrollment.mode, - is_active=self.course_enrollment.is_active, - creation_date=self.course_enrollment.created, - ) - COURSE_ENROLLMENT_CREATED.send_event( - enrollment=enrollment_data - ) - - notification_preferences = CourseNotificationPreference.objects.all() - - self.assertEqual(notification_preferences.count(), 1) - self.assertEqual(notification_preferences[0].user, self.user) - - email_preferences = [ - notification["email"] - for app in notification_preferences[0].notification_preference_config.values() - for notification in app["notification_types"].values() - ] - - self.assertEqual(email_preferences, [False] * len(email_preferences)) - - @ddt.data(*itertools.product(('web', 'email'), (True, False))) - @ddt.unpack - def test_course_preference_creation_for_inactive_enrollments_on_unsub( - self, - channel, - value - ): - """ - Test that unsubscribing through one click email does not create new course preferences for inactive enrollments - if not already exists. - """ - self.course_enrollment.is_active = False - self.course_enrollment.save() - encrypted_username = encrypt_string(self.user.username) - encrypted_patch = encrypt_object({ - 'channel': channel, - 'value': value - }) - update_user_preferences_from_patch(encrypted_username, encrypted_patch) - - self.assertEqual(CourseNotificationPreference.objects.all().count(), 0) - - -@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) -@ddt.ddt -class UserNotificationPreferenceAPITest(ModuleStoreTestCase): - """ - Test for user notification preference API. - """ - - def setUp(self): - super().setUp() - self.user = UserFactory() - self.course = CourseFactory.create( - org='testorg', - number='testcourse', - run='testrun' - ) - - course_overview = CourseOverviewFactory.create(id=self.course.id, org='AwesomeOrg') - self.course_enrollment = CourseEnrollment.objects.create( - user=self.user, - course=course_overview, - is_active=True, - mode='audit' - ) - self.client = APIClient() - self.path = reverse('notification-preferences', kwargs={'course_key_string': self.course.id}) - - enrollment_data = CourseEnrollmentData( - user=UserData( - pii=UserPersonalData( - username=self.user.username, - email=self.user.email, - name=self.user.profile.name, - ), - id=self.user.id, - is_active=self.user.is_active, - ), - course=CourseData( - course_key=self.course.id, - display_name=self.course.display_name, - ), - mode=self.course_enrollment.mode, - is_active=self.course_enrollment.is_active, - creation_date=self.course_enrollment.created, - ) - COURSE_ENROLLMENT_CREATED.send_event( - enrollment=enrollment_data - ) - - def _expected_api_response(self, is_staff=False): - """ - Helper method to return expected API response. - """ - response = { - 'id': 1, - 'course_name': 'course-v1:testorg+testcourse+testrun Course', - 'course_id': 'course-v1:testorg+testcourse+testrun', - 'notification_preference_config': { - 'discussion': { - 'enabled': True, - 'core_notification_types': [ - 'new_comment_on_response', - 'new_comment', - 'new_response', - 'response_on_followed_post', - 'comment_on_followed_post', - 'response_endorsed_on_thread', - 'response_endorsed' - ], - 'notification_types': { - 'new_discussion_post': { - 'web': False, - 'email': False, - 'push': False, - 'email_cadence': 'Daily', - 'info': '' - }, - 'new_question_post': { - 'web': False, - 'email': False, - 'push': False, - 'email_cadence': 'Daily', - 'info': '' - }, - 'core': { - 'web': True, - 'email': True, - 'push': True, - 'email_cadence': 'Daily', - 'info': 'Notifications for responses and comments on your posts, and the ones you’re ' - 'following, including endorsements to your responses and on your posts.' - }, - 'content_reported': { - 'web': True, - 'email': True, - 'push': False, - 'info': '', - 'email_cadence': 'Daily', - }, - 'new_instructor_all_learners_post': { - 'web': True, - 'email': False, - 'push': False, - 'email_cadence': 'Daily', - 'info': '' - }, - }, - 'non_editable': { - 'new_discussion_post': ['push'], - 'new_question_post': ['push'], - 'new_instructor_all_learners_post': ['push'], - } - }, - 'updates': { - 'enabled': True, - 'core_notification_types': [], - 'notification_types': { - 'course_updates': { - 'web': True, - 'email': False, - 'push': False, - 'email_cadence': 'Daily', - 'info': '' - }, - 'core': { - 'web': True, - 'email': True, - 'push': True, - 'email_cadence': 'Daily', - 'info': 'Notifications for new announcements and updates from the course team.' - } - }, - 'non_editable': { - 'course_updates': ['push'] - } - }, - 'grading': { - 'enabled': True, - 'core_notification_types': [], - 'notification_types': { - 'ora_staff_notifications': { - 'web': True, - 'email': False, - 'push': False, - 'email_cadence': 'Daily', - 'info': 'Notifications for when a submission is made for ORA that includes staff grading ' - 'step.' - }, - 'core': { - 'web': True, - 'email': True, - 'push': True, - 'email_cadence': 'Daily', - 'info': 'Notifications for submission grading.' - }, - 'ora_grade_assigned': { - 'web': True, - 'email': True, - 'push': False, - 'email_cadence': 'Daily', - 'info': '' - }, - }, - 'non_editable': { - 'ora_grade_assigned': ['push'] - } - }, - "enrollments": { - "enabled": True, - "core_notification_types": [], - "notification_types": { - "audit_access_expiring_soon": { - "web": True, - "email": False, - "push": False, - "email_cadence": "Daily", - "info": "" - }, - "core": { - "web": True, - "email": True, - "push": True, - "email_cadence": "Daily", - "info": "Notifications for enrollments." - } - }, - "non_editable": {} - } - } - } - if is_staff: - response['notification_preference_config']['grading']['non_editable'] = { - 'ora_staff_notifications': ['push'], - 'ora_grade_assigned': ['push'] - } - return response - - def test_get_user_notification_preference_without_login(self): - """ - Test get user notification preference without login. - """ - response = self.client.get(self.path) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - @mock.patch("eventtracking.tracker.emit") - def test_get_user_notification_preference(self, mock_emit): - """ - Test get user notification preference. - """ - self.client.login(username=self.user.username, password=self.TEST_PASSWORD) - response = self.client.get(self.path) - self.assertEqual(response.status_code, status.HTTP_200_OK) - expected_response = self._expected_api_response() - expected_response = remove_notifications_with_visibility_settings(expected_response) - self.assertEqual(response.data, expected_response) - event_name, event_data = mock_emit.call_args[0] - self.assertEqual(event_name, 'edx.notifications.preferences.viewed') - - @mock.patch("eventtracking.tracker.emit") - @mock.patch.dict(COURSE_NOTIFICATION_TYPES, { - **COURSE_NOTIFICATION_TYPES, - **{ - 'content_reported': { - 'name': 'content_reported', - 'visible_to': [FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_ADMINISTRATOR] - } - } - }) - @ddt.data( - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_ADMINISTRATOR, - None - ) - def test_get_user_notification_preference_with_visibility_settings(self, role, mock_emit): - """ - Test get user notification preference. - """ - if role: - CourseStaffRole(self.course.id).add_users(self.user) - self.client.login(username=self.user.username, password=self.TEST_PASSWORD) - - role_instance = None - if role: - role_instance = RoleFactory(name=role, course_id=self.course.id) - role_instance.users.add(self.user) - - response = self.client.get(self.path) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - expected_response = self._expected_api_response(is_staff=bool(role)) - - if not role: - expected_response = remove_notifications_with_visibility_settings(expected_response) - self.assertEqual(response.data, expected_response) - event_name, event_data = mock_emit.call_args[0] - self.assertEqual(event_name, 'edx.notifications.preferences.viewed') - if role_instance: - role_instance.users.clear() - - @ddt.data( - ('discussion', None, None, True, status.HTTP_200_OK, 'app_update'), - ('discussion', None, None, False, status.HTTP_200_OK, 'app_update'), - ('invalid_notification_app', None, None, True, status.HTTP_400_BAD_REQUEST, None), - - ('discussion', 'core', 'email', True, status.HTTP_200_OK, 'type_update'), - ('discussion', 'core', 'email', False, status.HTTP_200_OK, 'type_update'), - - # Test for email cadence update - ('discussion', 'core', 'email_cadence', 'Daily', status.HTTP_200_OK, 'type_update'), - ('discussion', 'core', 'email_cadence', 'Weekly', status.HTTP_200_OK, 'type_update'), - - # Test for app-wide channel update - ('discussion', None, 'email', True, status.HTTP_200_OK, 'app-wide-channel-update'), - ('discussion', None, 'email', False, status.HTTP_200_OK, 'app-wide-channel-update'), - - ('discussion', 'invalid_notification_type', 'email', True, status.HTTP_400_BAD_REQUEST, None), - ('discussion', 'new_comment', 'invalid_notification_channel', False, status.HTTP_400_BAD_REQUEST, None), - ) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_patch_user_notification_preference( - self, notification_app, notification_type, notification_channel, value, expected_status, update_type, mock_emit, - ): - """ - Test update of user notification preference. - """ - self.client.login(username=self.user.username, password=self.TEST_PASSWORD) - payload = { - 'notification_app': notification_app, - 'value': value, - } - if notification_type: - payload['notification_type'] = notification_type - if notification_channel: - payload['notification_channel'] = notification_channel - - response = self.client.patch(self.path, json.dumps(payload), content_type='application/json') - self.assertEqual(response.status_code, expected_status) - expected_data = self._expected_api_response() - - if update_type == 'app_update': - expected_data = self._expected_api_response() - expected_data = remove_notifications_with_visibility_settings(expected_data) - expected_data['notification_preference_config'][notification_app]['enabled'] = value - self.assertEqual(response.data, expected_data) - - elif update_type == 'type_update': - expected_data = self._expected_api_response() - expected_data = remove_notifications_with_visibility_settings(expected_data) - expected_data['notification_preference_config'][notification_app][ - 'notification_types'][notification_type][notification_channel] = value - self.assertEqual(response.data, expected_data) - - elif update_type == 'app-wide-channel-update': - expected_data = remove_notifications_with_visibility_settings(expected_data) - app_prefs = expected_data['notification_preference_config'][notification_app] - for notification_type_name, notification_type_preferences in app_prefs['notification_types'].items(): - non_editable_channels = app_prefs['non_editable'].get(notification_type_name, []) - if notification_channel not in non_editable_channels: - app_prefs['notification_types'][notification_type_name][notification_channel] = value - self.assertEqual(response.data, expected_data) - - if expected_status == status.HTTP_200_OK: - event_name, event_data = mock_emit.call_args[0] - self.assertEqual(event_name, 'edx.notifications.preferences.updated') - self.assertEqual(event_data['notification_app'], notification_app) - self.assertEqual(event_data['notification_type'], notification_type or '') - self.assertEqual(event_data['notification_channel'], notification_channel or '') - self.assertEqual(event_data['value'], value) - - def test_info_is_not_saved_in_json(self): - default_prefs = NotificationAppManager().get_notification_app_preferences() - for notification_app, app_prefs in default_prefs.items(): - for _, type_prefs in app_prefs.get('notification_types', {}).items(): - assert 'info' not in type_prefs.keys() - - def test_non_editable_is_not_saved_in_json(self): - default_prefs = NotificationAppManager().get_notification_app_preferences() - for app_prefs in default_prefs.values(): - assert 'non_editable' not in app_prefs.keys() - - @ddt.data(*itertools.product(('email', 'web'), (True, False))) - @ddt.unpack - def test_unsub_user_preferences_removal_on_email_enabled(self, channel, value): - """ - Test one click unsub user preference should be removed on email enable for any app. - """ - UserPreference.objects.create(user=self.user, key=ONE_CLICK_EMAIL_UNSUB_KEY) - self.client.login(username=self.user.username, password=self.TEST_PASSWORD) - payload = { - 'notification_app': 'discussion', - 'notification_type': 'core', - 'notification_channel': channel, - 'value': value - } - self.client.patch(self.path, json.dumps(payload), content_type='application/json') - result = 0 if channel == 'email' and value else 1 - self.assertEqual(UserPreference.objects.count(), result) - - @ddt.ddt class NotificationListAPIViewTest(APITestCase): """ @@ -1108,361 +546,6 @@ def remove_notifications_with_visibility_settings(expected_response): return expected_response -@ddt.ddt -class UpdateAllNotificationPreferencesViewTests(APITestCase): - """ - Tests for the UpdateAllNotificationPreferencesView. - """ - - def setUp(self): - # Create test user - self.user = User.objects.create_user( - username='testuser', - password='testpass123' - ) - self.client = APIClient() - self.client.force_authenticate(user=self.user) - self.url = reverse('update-all-notification-preferences') - - # Complex notification config structure - self.base_config = { - "grading": { - "enabled": True, - "non_editable": {}, - "notification_types": { - "core": { - "web": True, - "push": True, - "email": True, - "email_cadence": "Daily" - }, - "ora_staff_notifications": { - "web": False, - "push": False, - "email": False, - "email_cadence": "Daily" - } - }, - "core_notification_types": [] - }, - "updates": { - "enabled": True, - "non_editable": {}, - "notification_types": { - "core": { - "web": True, - "push": True, - "email": True, - "email_cadence": "Daily" - }, - "course_updates": { - "web": True, - "push": True, - "email": False, - "email_cadence": "Daily" - } - }, - "core_notification_types": [] - }, - "discussion": { - "enabled": True, - "non_editable": { - "core": ["web"] - }, - "notification_types": { - "core": { - "web": True, - "push": True, - "email": True, - "email_cadence": "Daily" - }, - "content_reported": { - "web": True, - "push": True, - "email": True, - "email_cadence": "Daily" - }, - "new_question_post": { - "web": True, - "push": False, - "email": False, - "email_cadence": "Daily" - }, - "new_discussion_post": { - "web": True, - "push": False, - "email": False, - "email_cadence": "Daily" - } - }, - "core_notification_types": [ - "new_comment_on_response", - "new_comment", - "new_response", - "response_on_followed_post", - "comment_on_followed_post", - "response_endorsed_on_thread", - "response_endorsed" - ] - } - } - - # Create test notification preferences - self.preferences = [] - for i in range(3): - pref = CourseNotificationPreference.objects.create( - user=self.user, - course_id=f'course-v1:TestX+Test{i}+2024', - notification_preference_config=deepcopy(self.base_config), - is_active=True - ) - self.preferences.append(pref) - - # Create an inactive preference - self.inactive_pref = CourseNotificationPreference.objects.create( - user=self.user, - course_id='course-v1:TestX+Inactive+2024', - notification_preference_config=deepcopy(self.base_config), - is_active=False - ) - - def test_update_discussion_notification(self): - """ - Test updating discussion notification settings - """ - data = { - 'notification_app': 'discussion', - 'notification_type': 'core', - 'notification_channel': 'web', - 'value': False - } - - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['status'], 'success') - self.assertEqual(response.data['data']['total_updated'], 3) - - # Verify database updates - for pref in CourseNotificationPreference.objects.filter(is_active=True): - self.assertFalse( - pref.notification_preference_config['discussion'][ - 'notification_types']['core']['web'] - ) - - def test_update_non_editable_field(self): - """ - Test attempting to update a non-editable field - """ - data = { - 'notification_app': 'discussion', - 'notification_type': 'core', - 'notification_channel': 'web', - 'value': False - } - - response = self.client.post(self.url, data, format='json') - - # Should fail because 'web' is non-editable for 'core' in discussion - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['status'], 'success') - - # Verify database remains unchanged - for pref in CourseNotificationPreference.objects.filter(is_active=True): - self.assertFalse( - pref.notification_preference_config['discussion']['notification_types']['core']['web'] - ) - - def test_update_email_cadence(self): - """ - Test updating email cadence setting - """ - data = { - 'notification_app': 'discussion', - 'notification_type': 'content_reported', - 'email_cadence': 'Weekly' - } - - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['status'], 'success') - - # Verify database updates - for pref in CourseNotificationPreference.objects.filter(is_active=True): - notification_type = pref.notification_preference_config['discussion']['notification_types'][ - 'content_reported'] - self.assertEqual( - notification_type['email_cadence'], - 'Weekly' - ) - - @patch.dict('openedx.core.djangoapps.notifications.serializers.COURSE_NOTIFICATION_APPS', { - **COURSE_NOTIFICATION_APPS, - 'grading': { - 'enabled': False, - 'core_info': 'Notifications for submission grading.', - 'core_web': True, - 'core_email': True, - 'core_push': True, - 'core_email_cadence': 'Daily', - 'non_editable': [] - } - }) - def test_update_disabled_app(self): - """ - Test updating notification for a disabled app - """ - # Disable the grading app in all preferences - for pref in self.preferences: - config = pref.notification_preference_config - config['grading']['enabled'] = False - pref.notification_preference_config = config - pref.save() - - data = { - 'notification_app': 'grading', - 'notification_type': 'core', - 'notification_channel': 'email', - 'value': False - } - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data['status'], 'error') - - def test_invalid_serializer_data(self): - """ - Test handling of invalid input data - """ - test_cases = [ - { - 'notification_app': 'invalid_app', - 'notification_type': 'core', - 'notification_channel': 'push', - 'value': False - }, - { - 'notification_app': 'discussion', - 'notification_type': 'invalid_type', - 'notification_channel': 'push', - 'value': False - }, - { - 'notification_app': 'discussion', - 'notification_type': 'core', - 'notification_channel': 'invalid_channel', - 'value': False - }, - { - 'notification_app': 'discussion', - 'notification_type': 'core', - 'notification_channel': 'email_cadence', - 'value': 'Invalid_Cadence' - } - ] - - for test_case in test_cases: - response = self.client.post(self.url, test_case, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - @ddt.data(*itertools.product(('email', 'web'), (True, False))) - @ddt.unpack - def test_unsub_user_preferences_removal_on_account_email_enabled(self, channel, value): - """ - Test one click unsub user preference should be removed on email enable for any app through account preferences - """ - UserPreference.objects.create(user=self.user, key=ONE_CLICK_EMAIL_UNSUB_KEY) - payload = { - 'notification_app': 'grading', - 'notification_type': 'core', - 'notification_channel': channel, - 'value': value - } - self.client.post(self.url, payload, format='json') - result = 0 if channel == 'email' and value else 1 - self.assertEqual(UserPreference.objects.count(), result) - - -class GetAggregateNotificationPreferencesTest(APITestCase): - """ - Tests for the GetAggregateNotificationPreferences API view. - """ - - def setUp(self): - # Set up a user and API client - self.user = User.objects.create_user(username='testuser', password='testpass') - self.client = APIClient() - self.client.force_authenticate(user=self.user) - self.url = reverse('notification-preferences-aggregated') # Adjust with the actual name - - def test_no_active_notification_preferences(self): - """ - Test case: No active notification preferences found for the user - """ - response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(response.data['status'], 'error') - self.assertEqual(response.data['message'], 'No active notification preferences found') - - @patch('openedx.core.djangoapps.notifications.views.aggregate_notification_configs') - def test_with_active_notification_preferences(self, mock_aggregate): - """ - Test case: Active notification preferences found for the user - """ - # Mock aggregate_notification_configs for a controlled output - mock_aggregate.return_value = {'mocked': {'notification_types': {}}} - - # Create active notification preferences for the user - CourseNotificationPreference.objects.create( - user=self.user, - is_active=True, - notification_preference_config={'example': 'config'} - ) - response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['status'], 'success') - self.assertEqual(response.data['message'], 'Notification preferences retrieved') - self.assertDictEqual(response.data['data'], {'mocked': {'notification_types': {}, 'non_editable': {}}}) - - def test_unauthenticated_user(self): - """ - Test case: Request without authentication - """ - # Test case: Request without authentication - self.client.logout() # Remove authentication - response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) - - @mock.patch.dict(COURSE_NOTIFICATION_APPS, { - **COURSE_NOTIFICATION_APPS, - **{ - 'discussion': { - 'name': 'content_reported', - 'non_editable': ["web"] - } - } - }) - @mock.patch.dict(COURSE_NOTIFICATION_TYPES, { - **COURSE_NOTIFICATION_TYPES, - **{ - 'course_updates': { - **COURSE_NOTIFICATION_TYPES['course_updates'], - 'non_editable': ["email"] - } - } - }) - def test_non_editable_is_added_in_api_response(self): - CourseNotificationPreference.objects.create(user=self.user, is_active=True) - response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - prefs = response.data['data'] - self.assertDictEqual(prefs['updates']['non_editable'], {'course_updates': ['email']}) - self.assertDictEqual(prefs['discussion']['non_editable'], { - 'new_discussion_post': ['push'], - 'new_question_post': ['push'], - 'new_instructor_all_learners_post': ['push'], - 'core': ['web'] - }) - - @ddt.ddt class TestNotificationPreferencesView(ModuleStoreTestCase): """ diff --git a/openedx/core/djangoapps/notifications/urls.py b/openedx/core/djangoapps/notifications/urls.py index 17e9f272f9..2f8b924228 100644 --- a/openedx/core/djangoapps/notifications/urls.py +++ b/openedx/core/djangoapps/notifications/urls.py @@ -1,35 +1,21 @@ """ URLs for the notifications API. """ -from django.conf import settings -from django.urls import path, re_path +from django.urls import path from rest_framework import routers from .views import ( - CourseEnrollmentListView, MarkNotificationsSeenAPIView, NotificationCountView, NotificationListAPIView, NotificationReadAPIView, - UpdateAllNotificationPreferencesView, - UserNotificationPreferenceView, - preference_update_from_encrypted_username_view, AggregatedNotificationPreferences, NotificationPreferencesView + preference_update_from_encrypted_username_view, + NotificationPreferencesView, ) router = routers.DefaultRouter() urlpatterns = [ - path('enrollments/', CourseEnrollmentListView.as_view(), name='enrollment-list'), - re_path( - fr'^configurations/{settings.COURSE_KEY_PATTERN}$', - UserNotificationPreferenceView.as_view(), - name='notification-preferences' - ), - path( - 'configurations/', - AggregatedNotificationPreferences.as_view(), - name='notification-preferences-aggregated' - ), path( 'v2/configurations/', NotificationPreferencesView.as_view(), @@ -45,11 +31,6 @@ urlpatterns = [ path('read/', NotificationReadAPIView.as_view(), name='notifications-read'), path('preferences/update///', preference_update_from_encrypted_username_view, name='preference_update_from_encrypted_username_view'), - path( - 'preferences/update-all/', - UpdateAllNotificationPreferencesView.as_view(), - name='update-all-notification-preferences' - ), ] urlpatterns += router.urls diff --git a/openedx/core/djangoapps/notifications/utils.py b/openedx/core/djangoapps/notifications/utils.py index d6f6d9f102..7b3f574813 100644 --- a/openedx/core/djangoapps/notifications/utils.py +++ b/openedx/core/djangoapps/notifications/utils.py @@ -1,13 +1,11 @@ """ Utils function for notifications app """ -import copy from typing import Dict, List, Set from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment from openedx.core.djangoapps.django_comment_common.models import Role from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS -from openedx.core.djangoapps.notifications.email_notifications import EmailCadence from openedx.core.lib.cache_utils import request_cached @@ -230,64 +228,6 @@ def process_app_config( update_notification_types(app_config, user_app_config) -def aggregate_notification_configs(existing_user_configs: List[Dict]) -> Dict: - """ - Update default notification config with values from other configs. - Rules: - 1. Start with default config as base - 2. If any value is True in other configs, make it True - 3. Set email_cadence to "Mixed" if different cadences found, else use default - - Args: - existing_user_configs: List of notification config dictionaries to apply - - Returns: - Updated config following the same structure - """ - if not existing_user_configs: - return {} - - result_config = copy.deepcopy(existing_user_configs[0]) - apps = result_config.keys() - - for app in apps: - app_config = result_config[app] - - for user_config in existing_user_configs: - process_app_config(app_config, user_config, app, existing_user_configs[0]) - - # if email_cadence is mixed, set it to "Mixed" - for app in result_config: - for type_key, type_config in result_config[app]["notification_types"].items(): - if len(type_config.get("email_cadence", [])) > 1: - result_config[app]["notification_types"][type_key]["email_cadence"] = "Mixed" - else: - if result_config[app]["notification_types"][type_key].get('email_cadence'): - result_config[app]["notification_types"][type_key]["email_cadence"] = ( - result_config[app]["notification_types"][type_key]["email_cadence"].pop()) - else: - result_config[app]["notification_types"][type_key]["email_cadence"] = EmailCadence.DAILY - return result_config - - -def filter_out_visible_preferences_by_course_ids(user, preferences: Dict, course_ids: List) -> Dict: - """ - Filter out notifications visible to forum roles from user preferences. - """ - forum_roles = Role.objects.filter(users__id=user.id).values_list('name', flat=True) - course_roles = CourseAccessRole.objects.filter( - user=user, - course_id__in=course_ids - ).values_list('role', flat=True) - notification_types_with_visibility = get_notification_types_with_visibility_settings() - return filter_out_visible_notifications( - preferences, - notification_types_with_visibility, - forum_roles, - course_roles - ) - - def get_user_forum_access_roles(user_id: int) -> List[str]: """ Get forum roles for the given user in all course. diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py index cc1d3b0e08..d257439106 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -1,15 +1,12 @@ """ Views for the notifications API. """ -import copy from datetime import datetime, timedelta from django.conf import settings -from django.db import transaction from django.db.models import Count from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ -from opaque_keys.edx.keys import CourseKey from pytz import UTC from rest_framework import generics, status from rest_framework.decorators import api_view @@ -17,245 +14,31 @@ from rest_framework.generics import UpdateAPIView from rest_framework.response import Response from rest_framework.views import APIView -from common.djangoapps.student.models import CourseEnrollment -from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY from openedx.core.djangoapps.notifications.email.utils import update_user_preferences_from_patch -from openedx.core.djangoapps.notifications.models import get_course_notification_preference_config_version, \ - NotificationPreference +from openedx.core.djangoapps.notifications.models import NotificationPreference from openedx.core.djangoapps.notifications.permissions import allow_any_authenticated_user -from openedx.core.djangoapps.notifications.serializers import add_info_to_notification_config -from openedx.core.djangoapps.user_api.models import UserPreference from .base_notification import COURSE_NOTIFICATION_APPS, NotificationAppManager, COURSE_NOTIFICATION_TYPES, \ NotificationTypeManager -from .config.waffle import ENABLE_NOTIFICATIONS from .events import ( notification_preference_update_event, - notification_preferences_viewed_event, notification_read_event, notification_tray_opened_event, notifications_app_all_read_event ) -from .models import CourseNotificationPreference, Notification +from .models import Notification from .serializers import ( - NotificationCourseEnrollmentSerializer, NotificationSerializer, - UserCourseNotificationPreferenceSerializer, UserNotificationPreferenceUpdateAllSerializer, - UserNotificationPreferenceUpdateSerializer, add_non_editable_in_preference ) from .tasks import create_notification_preference from .utils import ( - aggregate_notification_configs, - filter_out_visible_preferences_by_course_ids, get_show_notifications_tray, exclude_inaccessible_preferences ) -@allow_any_authenticated_user() -class CourseEnrollmentListView(generics.ListAPIView): - """ - API endpoint to get active CourseEnrollments for requester. - - **Permissions**: User must be authenticated. - **Response Format** (paginated): - - { - "next": (str) url_to_next_page_of_courses, - "previous": (str) url_to_previous_page_of_courses, - "count": (int) total_number_of_courses, - "num_pages": (int) total_number_of_pages, - "current_page": (int) current_page_number, - "start": (int) index_of_first_course_on_page, - "results" : [ - { - "course": { - "id": (int) course_id, - "display_name": (str) course_display_name - }, - }, - ... - ], - } - - Response Error Codes: - - 403: The requester cannot access resource. - """ - serializer_class = NotificationCourseEnrollmentSerializer - - def get_paginated_response(self, data): - """ - Return a response given serialized page data with show_preferences flag. - """ - response = super().get_paginated_response(data) - response.data["show_preferences"] = get_show_notifications_tray(self.request.user) - return response - - def get_queryset(self): - user = self.request.user - return CourseEnrollment.objects.filter(user=user, is_active=True) - - def list(self, request, *args, **kwargs): - """ - Returns the list of active course enrollments for which ENABLE_NOTIFICATIONS - Waffle flag is enabled - """ - queryset = self.filter_queryset(self.get_queryset()) - course_ids = queryset.values_list('course_id', flat=True) - - for course_id in course_ids: - if not ENABLE_NOTIFICATIONS.is_enabled(course_id): - queryset = queryset.exclude(course_id=course_id) - - queryset = queryset.select_related('course').order_by('-id') - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) - - return Response({ - "show_preferences": get_show_notifications_tray(request.user), - "results": self.get_serializer(queryset, many=True).data - }) - - -@allow_any_authenticated_user() -class UserNotificationPreferenceView(APIView): - """ - Supports retrieving and patching the UserNotificationPreference - model. - - **Example Requests** - GET /api/notifications/configurations/{course_id} - PATCH /api/notifications/configurations/{course_id} - - **Example Response**: - { - 'id': 1, - 'course_name': 'testcourse', - 'course_id': 'course-v1:testorg+testcourse+testrun', - 'notification_preference_config': { - 'discussion': { - 'enabled': False, - 'core': { - 'info': '', - 'web': False, - 'push': False, - 'email': False, - }, - 'notification_types': { - 'new_post': { - 'info': '', - 'web': False, - 'push': False, - 'email': False, - }, - }, - 'not_editable': {}, - }, - } - } - """ - - def get(self, request, course_key_string): - """ - Returns notification preference for user for a course. - - Parameters: - request (Request): The request object. - course_key_string (int): The ID of the course to retrieve notification preference. - - Returns: - { - 'id': 1, - 'course_name': 'testcourse', - 'course_id': 'course-v1:testorg+testcourse+testrun', - 'notification_preference_config': { - 'discussion': { - 'enabled': False, - 'core': { - 'info': '', - 'web': False, - 'push': False, - 'email': False, - }, - 'notification_types': { - 'new_post': { - 'info': '', - 'web': False, - 'push': False, - 'email': False, - }, - }, - 'not_editable': {}, - }, - } - } - """ - course_id = CourseKey.from_string(course_key_string) - user_preference = CourseNotificationPreference.get_updated_user_course_preferences(request.user, course_id) - serializer_context = { - 'course_id': course_id, - 'user': request.user - } - serializer = UserCourseNotificationPreferenceSerializer(user_preference, context=serializer_context) - notification_preferences_viewed_event(request, course_id) - return Response(serializer.data) - - def patch(self, request, course_key_string): - """ - Update an existing user notification preference with the data in the request body. - - Parameters: - request (Request): The request object - course_key_string (int): The ID of the course of the notification preference to be updated. - - Returns: - 200: The updated preference, serialized using the UserNotificationPreferenceSerializer - 404: If the preference does not exist - 403: If the user does not have permission to update the preference - 400: Validation error - """ - course_id = CourseKey.from_string(course_key_string) - user_course_notification_preference = CourseNotificationPreference.objects.get( - user=request.user, - course_id=course_id, - is_active=True, - ) - if user_course_notification_preference.config_version != get_course_notification_preference_config_version(): - return Response( - {'error': _('The notification preference config version is not up to date.')}, - status=status.HTTP_409_CONFLICT, - ) - - if request.data.get('notification_channel', '') == 'email_cadence': - request.data['email_cadence'] = request.data['value'] - del request.data['value'] - - preference_update = UserNotificationPreferenceUpdateSerializer( - user_course_notification_preference, data=request.data, partial=True - ) - preference_update.is_valid(raise_exception=True) - updated_notification_preferences = preference_update.save() - - if request.data.get('notification_channel', '') == 'email' and request.data.get('value', False): - UserPreference.objects.filter( - user_id=request.user.id, - key=ONE_CLICK_EMAIL_UNSUB_KEY - ).delete() - notification_preference_update_event(request.user, course_id, preference_update.validated_data) - - serializer_context = { - 'course_id': course_id, - 'user': request.user - } - serializer = UserCourseNotificationPreferenceSerializer(updated_notification_preferences, - context=serializer_context) - return Response(serializer.data, status=status.HTTP_200_OK) - - @allow_any_authenticated_user() class NotificationListAPIView(generics.ListAPIView): """ @@ -462,170 +245,6 @@ def preference_update_from_encrypted_username_view(request, username, patch): return Response({"result": "success"}, status=status.HTTP_200_OK) -@allow_any_authenticated_user() -class UpdateAllNotificationPreferencesView(APIView): - """ - API view for updating all notification preferences for the current user. - """ - - def post(self, request): - """ - Update all notification preferences for the current user. - """ - # check if request have required params - serializer = UserNotificationPreferenceUpdateAllSerializer(data=request.data) - if not serializer.is_valid(): - return Response({ - 'status': 'error', - 'message': serializer.errors - }, status=status.HTTP_400_BAD_REQUEST) - # check if required config is not editable - try: - with transaction.atomic(): - # Get all active notification preferences for the current user - notification_preferences = ( - CourseNotificationPreference.objects - .select_for_update() - .filter( - user=request.user, - is_active=True - ) - ) - - if not notification_preferences.exists(): - return Response({ - 'status': 'error', - 'message': 'No active notification preferences found' - }, status=status.HTTP_404_NOT_FOUND) - - data = serializer.validated_data - app = data['notification_app'] - email_cadence = data.get('email_cadence', None) - channel = data.get('notification_channel', 'email_cadence' if email_cadence else None) - notification_type = data['notification_type'] - value = data.get('value', email_cadence if email_cadence else None) - - updated_courses = [] - errors = [] - - # Update each preference - for preference in notification_preferences: - try: - # Create a deep copy of the current config - updated_config = copy.deepcopy(preference.notification_preference_config) - - # Check if the path exists and update the value - if ( - updated_config.get(app, {}) - .get('notification_types', {}) - .get(notification_type, {}) - .get(channel) - ) is not None: - - # Update the specific setting in the config - updated_config[app]['notification_types'][notification_type][channel] = value - - # Update the notification preference - preference.notification_preference_config = updated_config - preference.save() - - updated_courses.append({ - 'course_id': str(preference.course_id), - 'current_setting': updated_config[app]['notification_types'][notification_type] - }) - else: - errors.append({ - 'course_id': str(preference.course_id), - 'error': f'Invalid path: {app}.notification_types.{notification_type}.{channel}' - }) - - except (KeyError, AttributeError, ValueError) as e: - errors.append({ - 'course_id': str(preference.course_id), - 'error': str(e) - }) - if channel == 'email' and value: - UserPreference.objects.filter( - user_id=request.user, - key=ONE_CLICK_EMAIL_UNSUB_KEY - ).delete() - response_data = { - 'status': 'success' if updated_courses else 'partial_success' if errors else 'error', - 'message': 'Notification preferences update completed', - 'data': { - 'updated_value': value, - 'notification_type': notification_type, - 'channel': channel, - 'app': app, - 'successfully_updated_courses': updated_courses, - 'total_updated': len(updated_courses), - 'total_courses': notification_preferences.count() - } - } - if errors: - response_data['errors'] = errors - event_data = { - 'notification_app': app, - 'notification_type': notification_type, - 'notification_channel': channel, - 'value': value, - 'email_cadence': value - } - notification_preference_update_event( - request.user, - [course['course_id'] for course in updated_courses], - event_data - ) - return Response( - response_data, - status=status.HTTP_200_OK if updated_courses else status.HTTP_400_BAD_REQUEST - ) - - except (KeyError, AttributeError, ValueError) as e: - return Response({ - 'status': 'error', - 'message': str(e) - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -@allow_any_authenticated_user() -class AggregatedNotificationPreferences(APIView): - """ - API view for getting the aggregate notification preferences for the current user. - """ - - def get(self, request): - """ - API view for getting the aggregate notification preferences for the current user. - """ - notification_preferences = CourseNotificationPreference.get_user_notification_preferences(request.user) - if not notification_preferences.exists(): - return Response({ - 'status': 'error', - 'message': 'No active notification preferences found' - }, status=status.HTTP_404_NOT_FOUND) - notification_configs = notification_preferences.values_list('notification_preference_config', flat=True) - notification_configs = aggregate_notification_configs( - notification_configs - ) - course_ids = notification_preferences.values_list('course_id', flat=True) - - filter_out_visible_preferences_by_course_ids( - request.user, - notification_configs, - course_ids, - ) - - notification_preferences_viewed_event(request) - notification_configs = add_info_to_notification_config(notification_configs) - - return Response({ - 'status': 'success', - 'message': 'Notification preferences retrieved', - 'data': add_non_editable_in_preference(notification_configs) - }, status=status.HTTP_200_OK) - - @allow_any_authenticated_user() class NotificationPreferencesView(APIView): """