Skip to content

Commit

Permalink
feat: add email cadence setting in notification preferences for emails
Browse files Browse the repository at this point in the history
  • Loading branch information
Saad Yousaf authored and Saad Yousaf committed Mar 18, 2024
1 parent 49c1d7c commit 9c00b13
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 30 deletions.
13 changes: 11 additions & 2 deletions openedx/core/djangoapps/notifications/base_notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
"""
from django.utils.translation import gettext_lazy as _

from .email_notifications import EmailCadence
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

FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE = 'filter_audit_expired_users_with_no_role'


COURSE_NOTIFICATION_TYPES = {
'new_comment_on_response': {
'notification_app': 'discussion',
Expand Down Expand Up @@ -56,6 +58,7 @@
'info': '',
'web': False,
'email': False,
'email_cadence': EmailCadence.DAILY,
'push': False,
'non_editable': [],
'content_template': _('<{p}><{strong}>{username}</{strong}> posted <{strong}>{post_title}</{strong}></{p}>'),
Expand All @@ -73,6 +76,7 @@
'info': '',
'web': False,
'email': False,
'email_cadence': EmailCadence.DAILY,
'push': False,
'non_editable': [],
'content_template': _('<{p}><{strong}>{username}</{strong}> asked <{strong}>{post_title}</{strong}></{p}>'),
Expand Down Expand Up @@ -121,6 +125,7 @@
'info': '',
'web': True,
'email': True,
'email_cadence': EmailCadence.DAILY,
'push': True,
'non_editable': [],
'content_template': _('<p><strong>{username}’s </strong> {content_type} has been reported <strong> {'
Expand Down Expand Up @@ -189,6 +194,7 @@
'core_web': True,
'core_email': True,
'core_push': True,
'core_email_cadence': EmailCadence.DAILY,
'non_editable': ['web']
},
'updates': {
Expand Down Expand Up @@ -264,6 +270,7 @@ def denormalize_preferences(normalized_preferences):
'push': preference.get('push'),
'email': preference.get('email'),
'info': preference.get('info'),
'email_cadence': preference.get('email_cadence'),
}
return denormalized_preferences

Expand Down Expand Up @@ -298,8 +305,8 @@ def update_preferences(preferences):
app_name = preference.get('app_name')
pref = find_pref_in_normalized_prefs(pref_name, app_name, old_preferences.get('preferences'))
if pref:
for channel in ['web', 'email', 'push']:
preference[channel] = pref[channel]
for channel in ['web', 'email', 'push', 'email_cadence']:
preference[channel] = pref.get(channel, preference.get(channel))
return NotificationPreferenceSyncManager.denormalize_preferences(new_prefs)


Expand Down Expand Up @@ -357,6 +364,7 @@ def get_non_core_notification_type_preferences(non_core_notification_types):
'web': notification_type.get('web', False),
'email': notification_type.get('email', False),
'push': notification_type.get('push', False),
'email_cadence': notification_type.get('email_cadence', 'Daily'),
}
return non_core_notification_type_preferences

Expand Down Expand Up @@ -388,6 +396,7 @@ def add_core_notification_preference(self, notification_app_attrs, notification_
'web': notification_app_attrs.get('core_web', False),
'email': notification_app_attrs.get('core_email', False),
'push': notification_app_attrs.get('core_push', False),
'email_cadence': notification_app_attrs.get('core_email_cadence', 'Daily'),
}

def add_core_notification_non_editable(self, notification_app_attrs, non_editable_channels):
Expand Down
35 changes: 35 additions & 0 deletions openedx/core/djangoapps/notifications/email_notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""
Email notifications module.
"""
from django.utils.translation import gettext_lazy as _


class EmailCadence:
"""
Email cadence class
"""
DAILY = 'Daily'
WEEKLY = 'Weekly'
INSTANTLY = 'Instantly'
NEVER = 'Never'
EMAIL_CADENCE_CHOICES = [
(DAILY, _('Daily')),
(WEEKLY, _('Weekly')),
(INSTANTLY, _('Instantly')),
(NEVER, _('Never')),
]
EMAIL_CADENCE_CHOICES_DICT = dict(EMAIL_CADENCE_CHOICES)

@classmethod
def get_email_cadence_choices(cls):
"""
Returns email cadence choices.
"""
return cls.EMAIL_CADENCE_CHOICES

@classmethod
def get_email_cadence_value(cls, email_cadence):
"""
Returns email cadence display for the given email cadence.
"""
return cls.EMAIL_CADENCE_CHOICES_DICT.get(email_cadence, None)
5 changes: 4 additions & 1 deletion openedx/core/djangoapps/notifications/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ def notification_preference_update_event(user, course_id, updated_preference):
"""
context = contexts.course_context_from_course_id(course_id)
with tracker.get_tracker().context(NOTIFICATION_PREFERENCES_UPDATED, context):
value = updated_preference.get('value', '')
if updated_preference.get('notification_channel', '') == 'email_cadence':
value = updated_preference.get('email_cadence', '')
tracker.emit(
NOTIFICATION_PREFERENCES_UPDATED,
{
Expand All @@ -136,7 +139,7 @@ def notification_preference_update_event(user, course_id, updated_preference):
'notification_app': updated_preference.get('notification_app', ''),
'notification_type': updated_preference.get('notification_type', ''),
'notification_channel': updated_preference.get('notification_channel', ''),
'value': updated_preference.get('value', ''),
'value': value
}
)

Expand Down
2 changes: 1 addition & 1 deletion openedx/core/djangoapps/notifications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
User = get_user_model()
log = logging.getLogger(__name__)

NOTIFICATION_CHANNELS = ['web', 'push', 'email']
NOTIFICATION_CHANNELS = ['web', 'push', 'email', 'email_cadence']

# Update this version when there is a change to any course specific notification type or app.
COURSE_NOTIFICATION_CONFIG_VERSION = 7
Expand Down
39 changes: 32 additions & 7 deletions openedx/core/djangoapps/notifications/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
Notification,
get_notification_channels
)
from .base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES
from .base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES, EmailCadence
from .utils import filter_course_wide_preferences, remove_preferences_with_no_access


Expand Down Expand Up @@ -90,9 +90,10 @@ class UserNotificationPreferenceUpdateSerializer(serializers.Serializer):
"""

notification_app = serializers.CharField()
value = serializers.BooleanField()
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):
"""
Expand All @@ -101,17 +102,24 @@ def validate(self, attrs):
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 notification_channel and not notification_type:
raise ValidationError(
'notification_type is required for notification_channel.'
)

if not notification_app_config.get(notification_app, None):
raise ValidationError(
Expand Down Expand Up @@ -141,13 +149,30 @@ def update(self, instance, validated_data):
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

if notification_type and notification_channel:
# 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 = 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

# Notification app update
else:
# Update the notification preference for notification_app
user_notification_preference_config[notification_app]['enabled'] = value
Expand Down
70 changes: 58 additions & 12 deletions openedx/core/djangoapps/notifications/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,30 +242,34 @@ def _expected_api_response(self, course=None):
'response_endorsed'
],
'notification_types': {
'core': {
'web': True,
'email': True,
'push': True,
'info': 'Notifications for responses and comments on your posts, and the ones you’re '
'following, including endorsements to your responses and on your posts.'
},
'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': True,
'info': ''
'info': '',
'email_cadence': 'Daily',
},
},
'non_editable': {
Expand All @@ -280,12 +284,14 @@ def _expected_api_response(self, course=None):
'web': True,
'email': True,
'push': True,
'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.'
}
},
Expand Down Expand Up @@ -372,6 +378,14 @@ def test_get_user_notification_preference_with_visibility_settings(self, role, m
('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),
)
Expand All @@ -395,6 +409,7 @@ def test_patch_user_notification_preference(

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()
Expand All @@ -409,6 +424,15 @@ def test_patch_user_notification_preference(
'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')
Expand Down Expand Up @@ -500,12 +524,31 @@ def _expected_api_response(self, course=None):
'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.'
},
'new_discussion_post': {'web': False, 'email': False, 'push': False, 'info': ''},
'new_question_post': {'web': False, 'email': False, 'push': False, 'info': ''},
'content_reported': {'web': True, 'email': True, 'push': True, 'info': ''},
'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': ''
},
'content_reported': {
'web': True,
'email': True,
'push': True,
'email_cadence': 'Daily',
'info': ''
},
},
'non_editable': {
'core': ['web']
Expand All @@ -521,12 +564,14 @@ def _expected_api_response(self, course=None):
'web': True,
'email': True,
'push': True,
'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.'
}
},
Expand Down Expand Up @@ -571,7 +616,8 @@ def test_patch_user_notification_preference(
expected_data = self._expected_api_response()
expected_app_prefs = expected_data['notification_preference_config'][notification_app]
for notification_type_name, notification_type_preferences in expected_app_prefs[
'notification_types'].items():
'notification_types'
].items():
non_editable_channels = expected_app_prefs['non_editable'].get(notification_type_name, [])
if notification_channel not in non_editable_channels:
expected_app_prefs['notification_types'][notification_type_name][notification_channel] = value
Expand Down
3 changes: 1 addition & 2 deletions openedx/core/djangoapps/notifications/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
NotificationCountView,
NotificationListAPIView,
NotificationReadAPIView,
UserNotificationPreferenceView,
UserNotificationChannelPreferenceView
UserNotificationPreferenceView, UserNotificationChannelPreferenceView,
)

router = routers.DefaultRouter()
Expand Down
Loading

0 comments on commit 9c00b13

Please sign in to comment.