diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index 40f66cf2df..5f155ae1e9 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _ from .email_notifications import EmailCadence from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from .settings_override import get_notification_types_config, get_notification_apps_config from .utils import find_app_in_normalized_apps, find_pref_in_normalized_prefs from ..django_comment_common.models import FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA from .notification_content import get_notification_type_context_function @@ -61,7 +62,7 @@ class NotificationType(TypedDict): # For help defining new notifications, see ./docs/creating_a_new_notification_guide.md -COURSE_NOTIFICATION_TYPES = { +_COURSE_NOTIFICATION_TYPES = { 'new_comment_on_response': { 'notification_app': 'discussion', 'name': 'new_comment_on_response', @@ -339,7 +340,7 @@ class NotificationApp(TypedDict): # For help defining new notifications and notification apps, see ./docs/creating_a_new_notification_guide.md -COURSE_NOTIFICATION_APPS: dict[str, NotificationApp] = { +_COURSE_NOTIFICATION_APPS: dict[str, NotificationApp] = { 'discussion': { 'enabled': True, 'core_info': _('Notifications for responses and comments on your posts, and the ones you’re ' @@ -370,6 +371,9 @@ COURSE_NOTIFICATION_APPS: dict[str, NotificationApp] = { }, } +COURSE_NOTIFICATION_TYPES = get_notification_types_config() +COURSE_NOTIFICATION_APPS = get_notification_apps_config() + class NotificationPreferenceSyncManager: """ diff --git a/openedx/core/djangoapps/notifications/docs/settings.md b/openedx/core/djangoapps/notifications/docs/settings.md new file mode 100644 index 0000000000..4b6afdbf99 --- /dev/null +++ b/openedx/core/djangoapps/notifications/docs/settings.md @@ -0,0 +1,117 @@ + +# Notification Configuration Guide + +This guide explains how to override default notification settings for the platform without modifying the core code base. You can customize delivery channels (Web, Email) and behavior for specific notification types or entire notification apps using your Django settings. + +## Overview + +The notification system consists of two main components: + +1. **Notification Types**: Specific events (e.g., "New comment on your post", "Grade received"). +2. **Notification Apps**: Groups of related notifications (e.g., "Discussions", "Grading"). + +You can override defaults for both using the following dictionaries in your `lms.yml`, `cms.yml`, or `settings.py`: + +* `NOTIFICATION_TYPES_OVERRIDE` +* `NOTIFICATION_APPS_OVERRIDE` + +--- + +## 1. Overriding Notification Types + +Use `NOTIFICATION_TYPES_OVERRIDE` to change delivery defaults for specific events. + +### Allowed Overrides + +You can only modify the following fields for a notification type. Any other fields (like templates or triggers) are protected and cannot be changed via settings. + +| Key | Type | Description | +| --- | --- | --- | +| `web` | `bool` | Enable/Disable in-browser notifications. | +| `email` | `bool` | Enable/Disable email delivery. | +| `push` | `bool` | Enable/Disable push notifications. | +| `non_editable` | `list` | Prevent users from changing preferences for these channels. | + +### Example Configuration + +In your `settings.py` (or equivalent): + +```python +NOTIFICATION_TYPES_OVERRIDE = { + # CASE 1: Disable emails for new discussion posts by default + 'new_discussion_post': { + 'email': False, + 'web': True + }, + + # CASE 2: Force "Course Updates" to be strictly email-only (users cannot disable it) + 'course_updates': { + 'email': True, + 'web': False, + 'non_editable': ['email'] # User UI will lock the email toggle + } +} + +``` + +### Common Notification Types + +| ID | Description | Default Channels | +| --- | --- | --- | +| `new_comment` | A reply to your post. | Web, Email | +| `course_updates` | Announcements from course staff. | Web, Email | +| `ora_grade_assigned` | Grade received on an Open Response Assessment. | Web, Email | +| `content_reported` | Content flagged for moderation. | Web, Email | + +--- + +## 2. Overriding Notification Apps + +Use `NOTIFICATION_APPS_OVERRIDE` to change defaults for "Core" notifications. Many notification types are marked as `is_core: True`, meaning they inherit their settings from the App configuration rather than the individual Type configuration. + +### Allowed Overrides + +These keys affect all "Core" notifications belonging to the app. + +| Key | Type | Description | +| --- | --- | --- | +| `core_web` | `bool` | Enable/Disable web delivery for core events. | +| `core_email` | `bool` | Enable/Disable email delivery for core events. | +| `core_push` | `bool` | Enable/Disable push delivery for core events. | +| `non_editable` | `list` | Channels users cannot modify (e.g., `['email']`). | + +### Example Configuration + +```python +NOTIFICATION_APPS_OVERRIDE = { + # CASE: Make all Discussion notifications (comments, responses, etc.) + # Web-only by default to reduce email spam. + 'discussion': { + 'core_email': False, + 'core_web': True, + }, + + # CASE: Ensure Grading notifications are always delivered via email + # and users cannot disable them. + 'grading': { + 'core_email': True, + 'non_editable': ['email'] + } +} + +``` + +### Available Apps + +* `discussion`: Handles all forum interactions (replies, threads, comments). +* `grading`: Handles ORA (Open Response Assessment) submissions and grades. +* `updates`: Handles course-wide announcements. + +--- + +## Troubleshooting + +**Why isn't my override working?** + +1. **Check the Key Name:** Ensure you are using the exact ID (e.g., `new_discussion_post`, not `New Discussion Post`). +2. **Check for "Core" Status:** If a notification is defined as `is_core: True` in the code, it will ignore overrides in `NOTIFICATION_TYPES_OVERRIDE` regarding channels (`web`, `email`). You must override the parent **App** in `NOTIFICATION_APPS_OVERRIDE` instead. diff --git a/openedx/core/djangoapps/notifications/settings_override.py b/openedx/core/djangoapps/notifications/settings_override.py new file mode 100644 index 0000000000..309e5c9511 --- /dev/null +++ b/openedx/core/djangoapps/notifications/settings_override.py @@ -0,0 +1,62 @@ +""" +Settings override module for notification configurations. + +This module provides functionality to override notification configurations +via Django settings. +""" +from copy import deepcopy +from typing import Dict, Set, Any +from django.conf import settings + + +def _apply_overrides( + default_config: Dict[str, Any], + setting_name: str, + allowed_keys: Set[str] +) -> Dict[str, Any]: + """ + Internal helper to apply settings overrides to a default configuration dictionary. + + Args: + default_config: The base dictionary to copy. + setting_name: The name of the Django setting to check for overrides. + allowed_keys: A set of keys that are permitted to be overridden. + """ + config = deepcopy(default_config) + overrides = getattr(settings, setting_name, {}) + for name, override_data in overrides.items(): + if name in config: + # efficient filtering and updating + valid_updates = { + k: v for k, v in override_data.items() + if k in allowed_keys + } + config[name].update(valid_updates) + + return config + + +def get_notification_types_config() -> Dict[str, Any]: + """ + Get COURSE_NOTIFICATION_TYPES configuration with settings overrides applied. + """ + from .base_notification import _COURSE_NOTIFICATION_TYPES as DEFAULT_TYPES + + return _apply_overrides( + default_config=DEFAULT_TYPES, + setting_name='NOTIFICATION_TYPES_OVERRIDE', + allowed_keys={'web', 'email', 'push', 'non_editable'} + ) + + +def get_notification_apps_config() -> Dict[str, Any]: + """ + Get COURSE_NOTIFICATION_APPS configuration with settings overrides applied. + """ + from .base_notification import _COURSE_NOTIFICATION_APPS as DEFAULT_APPS + + return _apply_overrides( + default_config=DEFAULT_APPS, + setting_name='NOTIFICATION_APPS_OVERRIDE', + allowed_keys={'core_web', 'core_email', 'core_push', 'non_editable'} + ) diff --git a/openedx/core/djangoapps/notifications/tests/test_settings_override.py b/openedx/core/djangoapps/notifications/tests/test_settings_override.py new file mode 100644 index 0000000000..26420bed1d --- /dev/null +++ b/openedx/core/djangoapps/notifications/tests/test_settings_override.py @@ -0,0 +1,108 @@ +""" +Unit tests for settings_override module using real base_notification configurations. +""" +from django.test import TestCase, override_settings + +from openedx.core.djangoapps.notifications.base_notification import ( + _COURSE_NOTIFICATION_APPS, + _COURSE_NOTIFICATION_TYPES +) +from openedx.core.djangoapps.notifications.settings_override import ( + get_notification_apps_config, + get_notification_types_config +) + + +class SettingsOverrideIntegrationTest(TestCase): + """ + Integration tests for settings_override using the REAL base_notification configurations. + """ + + @override_settings(NOTIFICATION_TYPES_OVERRIDE={ + 'new_discussion_post': { + 'email': True, + 'email_cadence': 'immediately', + 'is_core': True + } + }) + def test_override_notification_types_real_config(self): + """ + Test overriding 'new_discussion_post' which exists in the real config. + We verify that allowed keys change and forbidden keys (is_core) do not. + """ + config = get_notification_types_config() + + target_notification = config['new_discussion_post'] + + self.assertTrue( + target_notification['email'], + "The 'email' setting should be overridden to True." + ) + + self.assertFalse( + target_notification['is_core'], + "The 'is_core' field should not be overridable via settings." + ) + + # IMMUTABILITY CHECK: Ensure the global module variable wasn't touched + self.assertFalse( + _COURSE_NOTIFICATION_TYPES['new_discussion_post']['email'], + "The original global _COURSE_NOTIFICATION_TYPES must remain immutable." + ) + + @override_settings(NOTIFICATION_TYPES_OVERRIDE={ + 'non_existent_notification': {'email': True} + }) + def test_override_types_ignores_unknown_keys(self): + """ + Test that defining a key in settings that doesn't exist in base_notification + is safely ignored. + """ + config = get_notification_types_config() + self.assertNotIn('non_existent_notification', config) + + @override_settings(NOTIFICATION_APPS_OVERRIDE={ + 'discussion': { + 'core_email': False, + 'enabled': False + } + }) + def test_override_notification_apps_real_config(self): + """ + Test overriding the 'discussion' app which exists in the real config. + """ + config = get_notification_apps_config() + + target_app = config['discussion'] + + self.assertFalse( + target_app['core_email'], + "The 'core_email' setting should be overridden to False." + ) + + self.assertTrue( + target_app['enabled'], + "The 'enabled' field should not be overridable via settings." + ) + + self.assertTrue( + _COURSE_NOTIFICATION_APPS['discussion']['core_email'], + "The original global _COURSE_NOTIFICATION_APPS must remain immutable." + ) + + @override_settings(NOTIFICATION_TYPES_OVERRIDE={ + 'course_updates': {'web': False} + }) + def test_partial_update_preserves_other_fields(self): + """ + Test that overriding one field (web) does not wipe out other fields (email). + """ + config = get_notification_types_config() + target = config['course_updates'] + + self.assertFalse(target['web']) + + self.assertTrue( + target['email'], + "The 'email' field should be preserved from the default config." + ) diff --git a/openedx/envs/common.py b/openedx/envs/common.py index 5fa6e22ebe..73345c7afd 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -2225,6 +2225,11 @@ NOTIFICATION_CREATION_BATCH_SIZE = 76 NOTIFICATIONS_DEFAULT_FROM_EMAIL = "no-reply@example.com" NOTIFICATION_DIGEST_LOGO = DEFAULT_EMAIL_LOGO_URL +# These settings are used to override the default notification preferences values for apps and types. +# Here is complete documentation about how to use them: openedx/core/djangoapps/notifications/docs/settings.md +NOTIFICATION_APPS_OVERRIDE = {} +NOTIFICATION_TYPES_OVERRIDE = {} + ############################# AI Translations ############################## AI_TRANSLATIONS_API_URL = 'http://localhost:18760/api/v1'