feat: integrating notification preference API
This commit is contained in:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user