feat: added option to override default preferences (#37807)

This commit is contained in:
Ahtisham Shahid
2025-12-24 13:19:11 +05:00
committed by GitHub
parent c5333b3550
commit 432926d2dc
5 changed files with 298 additions and 2 deletions

View File

@@ -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 youre '
@@ -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:
"""

View File

@@ -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.

View File

@@ -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'}
)

View File

@@ -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."
)

View File

@@ -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'