feat: implementing basic functionality to incorporate new notification types

This commit is contained in:
SaadYousaf
2023-05-31 14:16:21 +05:00
committed by Saad Yousaf
parent 0339784e4e
commit 2da176ce32
9 changed files with 374 additions and 118 deletions

View File

@@ -4,15 +4,24 @@ Django Admin for Notifications
from django.contrib import admin
from .models import Notification, NotificationPreference
from .models import Notification, CourseNotificationPreference
class NotificationAdmin(admin.ModelAdmin):
pass
class NotificationPreferenceAdmin(admin.ModelAdmin):
pass
class CourseNotificationPreferenceAdmin(admin.ModelAdmin):
"""
Admin for Course Notification Preferences
"""
model = CourseNotificationPreference
list_display = ['get_username', 'course_id', 'notification_preference_config']
@admin.display(description='Username', ordering='user__username')
def get_username(self, obj):
return obj.user.username
admin.site.register(Notification, NotificationAdmin)
admin.site.register(NotificationPreference, NotificationPreferenceAdmin)
admin.site.register(CourseNotificationPreference, CourseNotificationPreferenceAdmin)

View File

@@ -0,0 +1,180 @@
"""
Base setup for Notification Apps and Types.
"""
from typing import Dict
COURSE_NOTIFICATION_TYPES = {
'new_comment_on_response': {
'notification_app': 'discussion',
'name': 'new_comment_on_response',
'is_core': True,
'info': 'Comment on response',
'content_template': '<p><strong>{replier_name}</strong> replied on your response in '
'<strong>{post_title}</strong></p>',
'content_context': {
'post_title': 'Post title',
'replier_name': 'replier name',
},
'email_template': '',
},
'new_comment': {
'notification_app': 'discussion',
'name': 'new_comment',
'is_core': False,
'web': True,
'email': True,
'push': True,
'info': 'Comment on post',
'non-editable': ['web', 'email'],
'content_template': '<p><strong>{replier_name}</strong> replied on <strong>{author_name}</strong> response '
'to your post <strong>{post_title}</strong></p>',
'content_context': {
'post_title': 'Post title',
'author_name': 'author name',
'replier_name': 'replier name',
},
'email_template': '',
},
'new_response': {
'notification_app': 'discussion',
'name': 'new_response',
'is_core': False,
'web': True,
'email': True,
'push': True,
'info': 'Response on post',
'non-editable': [],
'content_template': '<p><strong>{replier_name}</strong> responded to your '
'post <strong>{post_title}</strong></p>',
'content_context': {
'post_title': 'Post title',
'replier_name': 'replier name',
},
'email_template': '',
},
}
COURSE_NOTIFICATION_APPS = {
'discussion': {
'enabled': True,
'core_info': '',
'core_web': True,
'core_email': True,
'core_push': True,
}
}
class NotificationTypeManager:
"""
Manager for notification types
"""
notification_types: Dict = {}
def __init__(self):
self.notification_types = COURSE_NOTIFICATION_TYPES
def get_notification_types_by_app(self, notification_app):
"""
Returns notification types for the given notification app.
"""
return [
notification_type for _, notification_type in self.notification_types.items()
if notification_type.get('notification_app', None) == notification_app
]
def get_core_and_non_core_notification_types(self, notification_app):
"""
Returns core notification types for the given app name.
"""
notification_types = self.get_notification_types_by_app(notification_app)
core_notification_types = []
non_core_notification_types = []
for notification_type in notification_types:
if notification_type.get('is_core', None):
core_notification_types.append(notification_type)
else:
non_core_notification_types.append(notification_type)
return core_notification_types, non_core_notification_types
@staticmethod
def get_non_editable_notification_channels(notification_types):
"""
Returns non-editable notification channels for the given notification types.
"""
non_editable_notification_channels = {}
for notification_type in notification_types:
if notification_type.get('non-editable', None):
non_editable_notification_channels[notification_type.get('name')] = \
notification_type.get('non-editable')
return non_editable_notification_channels
@staticmethod
def get_non_core_notification_type_preferences(non_core_notification_types):
"""
Returns non-core notification type preferences for the given notification types.
"""
non_core_notification_type_preferences = {}
for notification_type in non_core_notification_types:
non_core_notification_type_preferences[notification_type.get('name')] = {
'web': notification_type.get('web', False),
'email': notification_type.get('email', False),
'push': notification_type.get('push', False),
'info': notification_type.get('info', ''),
}
return non_core_notification_type_preferences
def get_notification_app_preference(self, notification_app):
"""
Returns notification app preferences for the given notification app.
"""
core_notification_types, non_core_notification_types = self.get_core_and_non_core_notification_types(
notification_app,
)
non_core_notification_types_preferences = self.get_non_core_notification_type_preferences(
non_core_notification_types,
)
non_editable_notification_channels = self.get_non_editable_notification_channels(non_core_notification_types)
core_notification_types_name = [notification_type.get('name') for notification_type in core_notification_types]
return non_core_notification_types_preferences, core_notification_types_name, non_editable_notification_channels
class NotificationAppManager:
"""
Notification app manager
"""
notification_apps: Dict = {}
def __init__(self):
self.notification_apps = COURSE_NOTIFICATION_APPS
def add_core_notification_preference(self, notification_app_attrs, notification_types):
"""
Adds core notification preference for the given notification app.
"""
notification_types['core'] = {
'web': notification_app_attrs.get('core_web', False),
'email': notification_app_attrs.get('core_email', False),
'push': notification_app_attrs.get('core_push', False),
'info': notification_app_attrs.get('core_info', ''),
}
def get_notification_app_preferences(self):
"""
Returns notification app preferences for the given name.
"""
course_notification_preference_config = {}
for notification_app_key, notification_app_attrs in COURSE_NOTIFICATION_APPS.items():
notification_app_preferences = {}
notification_types, core_notifications, \
non_editable_channels = NotificationTypeManager().get_notification_app_preference(notification_app_key)
self.add_core_notification_preference(notification_app_attrs, notification_types)
notification_app_preferences['enabled'] = notification_app_attrs.get('enabled', False)
notification_app_preferences['core_notification_types'] = core_notifications
notification_app_preferences['notification_types'] = notification_types
notification_app_preferences['non_editable'] = non_editable_channels
course_notification_preference_config[notification_app_key] = notification_app_preferences
return course_notification_preference_config
return None

View File

@@ -8,7 +8,7 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
from openedx.core.djangoapps.notifications.models import NotificationPreference
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference
log = logging.getLogger(__name__)
@@ -17,10 +17,11 @@ log = logging.getLogger(__name__)
def course_enrollment_post_save(sender, instance, created, **kwargs):
"""
Watches for post_save signal for creates on the CourseEnrollment table.
Generate a NotificationPreference if new Enrollment is created
Generate a CourseNotificationPreference if new Enrollment is created
"""
if created and ENABLE_NOTIFICATIONS.is_enabled(instance.course_id):
try:
NotificationPreference.objects.create(user=instance.user, course_id=instance.course_id)
CourseNotificationPreference.objects.create(user=instance.user, course_id=instance.course_id)
except IntegrityError:
log.info(f'NotificationPreference already exists for user {instance.user} and course {instance.course_id}')
log.info(f'CourseNotificationPreference already exists for user {instance.user} '
f'and course {instance.course_id}')

View File

@@ -1,24 +0,0 @@
# Generated by Django 3.2.19 on 2023-05-26 12:13
from django.db import migrations, models
import openedx.core.djangoapps.notifications.models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0003_alter_notification_app_name'),
]
operations = [
migrations.AlterField(
model_name='notification',
name='content_context',
field=models.JSONField(default=dict),
),
migrations.AlterField(
model_name='notificationpreference',
name='notification_preference_config',
field=models.JSONField(default=openedx.core.djangoapps.notifications.models.get_notification_preference_config),
),
]

View File

@@ -0,0 +1,63 @@
# Generated by Django 3.2.19 on 2023-06-07 07:57
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
import opaque_keys.edx.django.models
import openedx.core.djangoapps.notifications.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('notifications', '0003_alter_notification_app_name'),
]
operations = [
migrations.CreateModel(
name='CourseNotificationPreference',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('course_id', opaque_keys.edx.django.models.CourseKeyField(max_length=255)),
('notification_preference_config', models.JSONField(default=openedx.core.djangoapps.notifications.models.get_course_notification_preference_config)),
('config_version', models.IntegerField(default=openedx.core.djangoapps.notifications.models.get_course_notification_preference_config_version)),
('is_active', models.BooleanField(default=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preferences', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'course_id')},
},
),
migrations.RemoveField(
model_name='notification',
name='content',
),
migrations.AddField(
model_name='notification',
name='course_id',
field=opaque_keys.edx.django.models.CourseKeyField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='notification',
name='app_name',
field=models.CharField(db_index=True, max_length=64),
),
migrations.AlterField(
model_name='notification',
name='content_context',
field=models.JSONField(default=dict),
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(max_length=64),
),
migrations.DeleteModel(
name='NotificationPreference',
),
]

View File

@@ -6,50 +6,67 @@ from django.db import models
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField
from openedx.core.djangoapps.notifications.base_notification import NotificationAppManager
User = get_user_model()
NOTIFICATION_CHANNELS = ['web', 'push', 'email']
# When notification preferences are updated, we need to update the CONFIG_VERSION.
NOTIFICATION_PREFERENCE_CONFIG = {
'discussion': {
'enabled': False,
'notification_types': {
'new_post': {
'info': '',
'web': False,
'push': False,
'email': False,
},
'core': {
'info': '',
'web': False,
'push': False,
'email': False,
},
},
# This is a list of notification channels for notification type that are not editable by the user.
# e.g. 'new_post' web notification is not editable by user i.e. 'not_editable': {'new_post': ['web']}
'not_editable': {},
},
}
# Update this version when NOTIFICATION_PREFERENCE_CONFIG is updated.
NOTIFICATION_CONFIG_VERSION = 1
# Update this version when there is a change to any course specific notification type or app.
COURSE_NOTIFICATION_CONFIG_VERSION = 1
def get_notification_preference_config():
def get_course_notification_preference_config():
"""
Returns the notification preference config.
Returns the course specific notification preference config.
Sample Response:
{
'discussion': {
'enabled': True,
'not_editable': {
'new_comment_on_post': ['push'],
'new_response_on_post': ['web'],
'new_response_on_comment': ['web', 'push']
},
'notification_types': {
'new_comment_on_post': {
'email': True,
'push': True,
'web': True,
'info': 'Comment on post'
},
'new_response_on_comment': {
'email': True,
'push': True,
'web': True,
'info': 'Response on comment'
},
'new_response_on_post': {
'email': True,
'push': True,
'web': True,
'info': 'New Response on Post'
},
'core': {
'email': True,
'push': True,
'web': True,
'info': 'comment on post and response on comment'
}
},
'core_notification_types': []
}
}
"""
return NOTIFICATION_PREFERENCE_CONFIG
return NotificationAppManager().get_notification_app_preferences()
def get_notification_preference_config_version():
def get_course_notification_preference_config_version():
"""
Returns the notification preference config version.
"""
return NOTIFICATION_CONFIG_VERSION
return COURSE_NOTIFICATION_CONFIG_VERSION
def get_notification_channels():
@@ -59,27 +76,6 @@ def get_notification_channels():
return NOTIFICATION_CHANNELS
class NotificationApplication(models.TextChoices):
"""
Application choices where notifications are generated from
"""
DISCUSSION = 'DISCUSSION'
class NotificationType(models.TextChoices):
"""
Notification type choices
"""
NEW_CONTRIBUTION = 'NEW_CONTRIBUTION'
class NotificationTypeContent:
"""
Notification type content
"""
NEW_CONTRIBUTION_NOTIFICATION_CONTENT = 'There is a new contribution. {new_contribution}'
class Notification(TimeStampedModel):
"""
Model to store notifications for users
@@ -87,30 +83,33 @@ class Notification(TimeStampedModel):
.. no_pii:
"""
user = models.ForeignKey(User, related_name="notifications", on_delete=models.CASCADE)
app_name = models.CharField(max_length=64, choices=NotificationApplication.choices, db_index=True)
notification_type = models.CharField(max_length=64, choices=NotificationType.choices)
content = models.CharField(max_length=1024)
course_id = CourseKeyField(max_length=255, null=True, blank=True)
app_name = models.CharField(max_length=64, db_index=True)
notification_type = models.CharField(max_length=64)
content_context = models.JSONField(default=dict)
content_url = models.URLField(null=True, blank=True)
last_read = models.DateTimeField(null=True, blank=True)
last_seen = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f'{self.user.username} - {self.app_name} - {self.notification_type} - {self.content}'
return f'{self.user.username} - {self.course_id} - {self.app_name} - {self.notification_type}'
class NotificationPreference(TimeStampedModel):
class CourseNotificationPreference(TimeStampedModel):
"""
Model to store notification preferences for users
.. no_pii:
"""
user = models.ForeignKey(User, related_name="notification_preferences", on_delete=models.CASCADE)
course_id = CourseKeyField(max_length=255, blank=True, default=None)
notification_preference_config = models.JSONField(default=get_notification_preference_config)
course_id = CourseKeyField(max_length=255, null=False, blank=False)
notification_preference_config = models.JSONField(default=get_course_notification_preference_config)
# This version indicates the current version of this notification preference.
config_version = models.IntegerField(blank=True, default=1)
config_version = models.IntegerField(default=get_course_notification_preference_config_version)
is_active = models.BooleanField(default=True)
class Meta:
unique_together = ('user', 'course_id')
def __str__(self):
return f'{self.user.username} - {self.course_id} - {self.notification_preference_config}'

View File

@@ -9,7 +9,7 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi
from openedx.core.djangoapps.notifications.models import (
get_notification_channels,
Notification,
NotificationPreference,
CourseNotificationPreference,
)
@@ -34,14 +34,14 @@ class NotificationCourseEnrollmentSerializer(serializers.ModelSerializer):
fields = ('course',)
class UserNotificationPreferenceSerializer(serializers.ModelSerializer):
class UserCourseNotificationPreferenceSerializer(serializers.ModelSerializer):
"""
Serializer for user notification preferences.
"""
course_name = serializers.SerializerMethodField(read_only=True)
class Meta:
model = NotificationPreference
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',)
@@ -136,7 +136,6 @@ class NotificationSerializer(serializers.ModelSerializer):
'id',
'app_name',
'notification_type',
'content',
'content_context',
'content_url',
'last_read',

View File

@@ -14,8 +14,10 @@ from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
from openedx.core.djangoapps.notifications.models import Notification, NotificationPreference, \
get_notification_preference_config
from openedx.core.djangoapps.notifications.models import (
Notification,
CourseNotificationPreference,
)
from openedx.core.djangoapps.notifications.serializers import NotificationCourseEnrollmentSerializer
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -33,7 +35,6 @@ class CourseEnrollmentListViewTest(ModuleStoreTestCase):
super().setUp()
self.client = APIClient()
self.user = UserFactory()
# self.client.force_authenticate(user=self.user)
course_1 = CourseFactory.create(
org='testorg',
number='testcourse',
@@ -123,8 +124,8 @@ class CourseEnrollmentPostSaveTest(ModuleStoreTestCase):
created=True
)
# Assert that NotificationPreference object was created with correct attributes
notification_preferences = NotificationPreference.objects.all()
# 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)
@@ -136,6 +137,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
"""
Test for user notification preference API.
"""
def setUp(self):
super().setUp()
self.user = UserFactory()
@@ -169,7 +171,35 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
'id': 1,
'course_name': 'course-v1:testorg+testcourse+testrun Course',
'course_id': 'course-v1:testorg+testcourse+testrun',
'notification_preference_config': get_notification_preference_config(),
'notification_preference_config': {
'discussion': {
'enabled': True,
'core_notification_types': ['new_comment_on_response'],
'notification_types': {
'new_comment': {
'web': True,
'email': True,
'push': True,
'info': 'Comment on post'
},
'new_response': {
'web': True,
'email': True,
'push': True,
'info': 'Response on post'
},
'core': {
'web': True,
'email': True,
'push': True,
'info': ''
}
},
'non_editable': {
'new_comment': ['web', 'email']
}
}
}
}
def test_get_user_notification_preference_without_login(self):
@@ -193,14 +223,14 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
('discussion', None, None, False, status.HTTP_200_OK, 'app_update'),
('invalid_notification_app', None, None, True, status.HTTP_400_BAD_REQUEST, None),
('discussion', 'new_post', 'web', True, status.HTTP_200_OK, 'type_update'),
('discussion', 'new_post', 'web', False, status.HTTP_200_OK, 'type_update'),
('discussion', 'new_comment', 'web', True, status.HTTP_200_OK, 'type_update'),
('discussion', 'new_response', 'web', False, status.HTTP_200_OK, 'type_update'),
('discussion', 'core', 'email', True, status.HTTP_200_OK, 'type_update'),
('discussion', 'core', 'email', False, status.HTTP_200_OK, 'type_update'),
('discussion', 'invalid_notification_type', 'email', True, status.HTTP_400_BAD_REQUEST, None),
('discussion', 'new_post', 'invalid_notification_channel', False, status.HTTP_400_BAD_REQUEST, None),
('discussion', 'new_comment', 'invalid_notification_channel', False, status.HTTP_400_BAD_REQUEST, None),
)
@ddt.unpack
def test_patch_user_notification_preference(
@@ -252,7 +282,6 @@ class NotificationListAPIViewTest(APITestCase):
user=self.user,
app_name='app1',
notification_type='info',
content='This is a notification.',
)
self.client.login(username=self.user.username, password='test')
@@ -267,7 +296,6 @@ class NotificationListAPIViewTest(APITestCase):
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['app_name'], 'app1')
self.assertEqual(data[0]['notification_type'], 'info')
self.assertEqual(data[0]['content'], 'This is a notification.')
def test_list_notifications_with_app_name_filter(self):
"""
@@ -278,13 +306,11 @@ class NotificationListAPIViewTest(APITestCase):
user=self.user,
app_name='app1',
notification_type='info',
content='This is a notification for app1.',
)
Notification.objects.create(
user=self.user,
app_name='app2',
notification_type='info',
content='This is a notification for app2.',
)
self.client.login(username=self.user.username, password='test')
@@ -299,7 +325,6 @@ class NotificationListAPIViewTest(APITestCase):
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['app_name'], 'app1')
self.assertEqual(data[0]['notification_type'], 'info')
self.assertEqual(data[0]['content'], 'This is a notification for app1.')
def test_list_notifications_without_authentication(self):
"""
@@ -363,6 +388,7 @@ class MarkNotificationsUnseenAPIViewTestCase(APITestCase):
"""
Tests for the MarkNotificationsUnseenAPIView.
"""
def setUp(self):
self.user = UserFactory()

View File

@@ -12,15 +12,18 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from common.djangoapps.student.models import CourseEnrollment
from openedx.core.djangoapps.notifications.models import NotificationPreference, \
get_notification_preference_config_version
from openedx.core.djangoapps.notifications.models import (
CourseNotificationPreference,
get_course_notification_preference_config_version
)
from .config.waffle import ENABLE_NOTIFICATIONS
from .models import Notification
from .serializers import (
NotificationCourseEnrollmentSerializer,
NotificationSerializer,
UserNotificationPreferenceSerializer, UserNotificationPreferenceUpdateSerializer
UserCourseNotificationPreferenceSerializer,
UserNotificationPreferenceUpdateSerializer
)
User = get_user_model()
@@ -142,12 +145,12 @@ class UserNotificationPreferenceView(APIView):
}
"""
course_id = CourseKey.from_string(course_key_string)
user_notification_preference, _ = NotificationPreference.objects.get_or_create(
user_notification_preference, _ = CourseNotificationPreference.objects.get_or_create(
user=request.user,
course_id=course_id,
is_active=True,
)
serializer = UserNotificationPreferenceSerializer(user_notification_preference)
serializer = UserCourseNotificationPreferenceSerializer(user_notification_preference)
return Response(serializer.data)
def patch(self, request, course_key_string):
@@ -165,23 +168,23 @@ class UserNotificationPreferenceView(APIView):
400: Validation error
"""
course_id = CourseKey.from_string(course_key_string)
user_notification_preference = NotificationPreference.objects.get(
user_course_notification_preference = CourseNotificationPreference.objects.get(
user=request.user,
course_id=course_id,
is_active=True,
)
if user_notification_preference.config_version != get_notification_preference_config_version():
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,
)
preference_update_serializer = UserNotificationPreferenceUpdateSerializer(
user_notification_preference, data=request.data, partial=True
user_course_notification_preference, data=request.data, partial=True
)
preference_update_serializer.is_valid(raise_exception=True)
updated_notification_preferences = preference_update_serializer.save()
serializer = UserNotificationPreferenceSerializer(updated_notification_preferences)
serializer = UserCourseNotificationPreferenceSerializer(updated_notification_preferences)
return Response(serializer.data, status=status.HTTP_200_OK)