diff --git a/openedx/core/djangoapps/notifications/migrations/0004_auto_20230526_1213.py b/openedx/core/djangoapps/notifications/migrations/0004_auto_20230526_1213.py new file mode 100644 index 0000000000..41ac15a8c7 --- /dev/null +++ b/openedx/core/djangoapps/notifications/migrations/0004_auto_20230526_1213.py @@ -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), + ), + ] diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index 78f2d2897c..55c0bbde4a 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -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 diff --git a/openedx/core/djangoapps/notifications/serializers.py b/openedx/core/djangoapps/notifications/serializers.py index becbac4b63..1285fdcb0e 100644 --- a/openedx/core/djangoapps/notifications/serializers.py +++ b/openedx/core/djangoapps/notifications/serializers.py @@ -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 diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 634c24b3d8..2c8524e75f 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -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): diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py index 31a2c7ad54..b370aa7318 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -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):