feat: integrating notification preference API

This commit is contained in:
SaadYousaf
2023-05-22 16:56:24 +05:00
committed by Saad Yousaf
parent 7293b3e85a
commit 0339784e4e
5 changed files with 236 additions and 90 deletions

View File

@@ -0,0 +1,24 @@
# 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

@@ -1,23 +1,62 @@
"""
Models for notifications
"""
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.contrib.auth import get_user_model
from django.db import models
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField
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": {
"new_post": {
"web": False,
"push": False,
"email": False,
'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.
CONFIG_VERSION = 1
NOTIFICATION_CONFIG_VERSION = 1
def get_notification_preference_config():
"""
Returns the notification preference config.
"""
return NOTIFICATION_PREFERENCE_CONFIG
def get_notification_preference_config_version():
"""
Returns the notification preference config version.
"""
return NOTIFICATION_CONFIG_VERSION
def get_notification_channels():
"""
Returns the notification channels.
"""
return NOTIFICATION_CHANNELS
class NotificationApplication(models.TextChoices):
@@ -51,7 +90,7 @@ class Notification(TimeStampedModel):
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)
content_context = models.JSONField(default={})
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)
@@ -59,24 +98,6 @@ class Notification(TimeStampedModel):
def __str__(self):
return f'{self.user.username} - {self.app_name} - {self.notification_type} - {self.content}'
def get_content(self):
return self.content
def get_content_url(self):
return self.content_url
def get_notification_type(self):
return self.notification_type
def get_app_name(self):
return self.app_name
def get_content_context(self):
return self.content_context
def get_user(self):
return self.user
class NotificationPreference(TimeStampedModel):
"""
@@ -86,25 +107,10 @@ class NotificationPreference(TimeStampedModel):
"""
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=NOTIFICATION_PREFERENCE_CONFIG)
notification_preference_config = models.JSONField(default=get_notification_preference_config)
# This version indicates the current version of this notification preference.
config_version = models.IntegerField(blank=True, default=1)
is_active = models.BooleanField(default=True)
def __str__(self):
return f'{self.user.username} - {self.course_id} - {self.notification_preference_config}'
def get_user(self):
return self.user
def get_course_id(self):
return self.course_id
def get_notification_preference_config(self):
return self.notification_preference_config
def get_config_version(self):
return self.config_version
def get_is_active(self):
return self.is_active

View File

@@ -1,11 +1,16 @@
"""
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 Notification, NotificationPreference
from openedx.core.djangoapps.notifications.models import (
get_notification_channels,
Notification,
NotificationPreference,
)
class CourseOverviewSerializer(serializers.ModelSerializer):
@@ -47,9 +52,75 @@ class UserNotificationPreferenceSerializer(serializers.ModelSerializer):
"""
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()
notification_type = serializers.CharField(required=False)
notification_channel = 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_app_config = self.instance.notification_preference_config
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(
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():
raise ValidationError(
f'{notification_channel} is not a valid notification channel.'
)
return attrs
def update(self, instance, validated_data):
for key, val in validated_data.items():
setattr(instance, key, val)
"""
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')
user_notification_preference_config = instance.notification_preference_config
if 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
else:
# Update the notification preference for notification_app
user_notification_preference_config[notification_app]['enabled'] = value
instance.save()
return instance

View File

@@ -3,6 +3,7 @@ Tests for the views in the notifications app.
"""
import json
import ddt
from django.dispatch import Signal
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_flag
@@ -13,7 +14,8 @@ 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
from openedx.core.djangoapps.notifications.models import Notification, NotificationPreference, \
get_notification_preference_config
from openedx.core.djangoapps.notifications.serializers import NotificationCourseEnrollmentSerializer
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -129,6 +131,7 @@ class CourseEnrollmentPostSaveTest(ModuleStoreTestCase):
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
@ddt.ddt
class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
"""
Test for user notification preference API.
@@ -158,27 +161,16 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
created=True
)
def _expected_api_response(self, overrides=None):
def _expected_api_response(self):
"""
Helper method to return expected API response.
"""
expected_response = {
return {
'id': 1,
'course_name': 'course-v1:testorg+testcourse+testrun Course',
'course_id': 'course-v1:testorg+testcourse+testrun',
'notification_preference_config': {
'discussion': {
'new_post': {
'web': False,
'push': False,
'email': False
}
}
}
'notification_preference_config': get_notification_preference_config(),
}
if overrides:
expected_response.update(overrides)
return expected_response
def test_get_user_notification_preference_without_login(self):
"""
@@ -196,28 +188,50 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, self._expected_api_response())
def test_patch_user_notification_preference(self):
@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', 'new_post', 'web', True, status.HTTP_200_OK, 'type_update'),
('discussion', 'new_post', '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),
)
@ddt.unpack
def test_patch_user_notification_preference(
self, notification_app, notification_type, notification_channel, value, expected_status, update_type,
):
"""
Test update of user notification preference.
"""
self.client.login(username=self.user.username, password='test')
updated_notification_config_data = {
"notification_preference_config": {
"discussion": {
"new_post": {
"web": True,
"push": False,
"email": False,
},
},
},
payload = {
'notification_app': notification_app,
'value': value,
}
response = self.client.patch(
self.path, json.dumps(updated_notification_config_data), content_type='application/json'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_data = self._expected_api_response(overrides=updated_notification_config_data)
self.assertEqual(response.data, expected_data)
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)
if update_type == 'app_update':
expected_data = self._expected_api_response()
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['notification_preference_config'][notification_app][
'notification_types'][notification_type][notification_channel] = value
self.assertEqual(response.data, expected_data)
class NotificationListAPIViewTest(APITestCase):

View File

@@ -12,14 +12,15 @@ 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
from openedx.core.djangoapps.notifications.models import NotificationPreference, \
get_notification_preference_config_version
from .config.waffle import ENABLE_NOTIFICATIONS
from .models import Notification
from .serializers import (
NotificationCourseEnrollmentSerializer,
NotificationSerializer,
UserNotificationPreferenceSerializer
UserNotificationPreferenceSerializer, UserNotificationPreferenceUpdateSerializer
)
User = get_user_model()
@@ -83,12 +84,23 @@ class UserNotificationPreferenceView(APIView):
'course_id': 'course-v1:testorg+testcourse+testrun',
'notification_preference_config': {
'discussion': {
'new_post': {
'enabled': False,
'core': {
'info': '',
'web': False,
'push': False,
'email': False,
}
}
},
'notification_types': {
'new_post': {
'info': '',
'web': False,
'push': False,
'email': False,
},
},
'not_editable': {},
},
}
}
"""
@@ -109,12 +121,23 @@ class UserNotificationPreferenceView(APIView):
'course_id': 'course-v1:testorg+testcourse+testrun',
'notification_preference_config': {
'discussion': {
'new_post': {
'enabled': False,
'core': {
'info': '',
'web': False,
'push': False,
'email': False,
}
}
},
'notification_types': {
'new_post': {
'info': '',
'web': False,
'push': False,
'email': False,
},
},
'not_editable': {},
},
}
}
"""
@@ -147,11 +170,19 @@ class UserNotificationPreferenceView(APIView):
course_id=course_id,
is_active=True,
)
serializer = UserNotificationPreferenceSerializer(user_notification_preference, data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
if user_notification_preference.config_version != get_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
)
preference_update_serializer.is_valid(raise_exception=True)
updated_notification_preferences = preference_update_serializer.save()
serializer = UserNotificationPreferenceSerializer(updated_notification_preferences)
return Response(serializer.data, status=status.HTTP_200_OK)
class NotificationListAPIView(generics.ListAPIView):