diff --git a/openedx/core/djangoapps/notifications/migrations/0003_alter_notification_app_name.py b/openedx/core/djangoapps/notifications/migrations/0003_alter_notification_app_name.py new file mode 100644 index 0000000000..9b3cc9c4f6 --- /dev/null +++ b/openedx/core/djangoapps/notifications/migrations/0003_alter_notification_app_name.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2023-05-12 14:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0002_notificationpreference'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='app_name', + field=models.CharField(choices=[('DISCUSSION', 'Discussion')], db_index=True, max_length=64), + ), + ] diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index 0e1a83b19b..78f2d2897c 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -48,7 +48,7 @@ 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) + 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={}) diff --git a/openedx/core/djangoapps/notifications/serializers.py b/openedx/core/djangoapps/notifications/serializers.py index 9581982c92..2c5ae0194b 100644 --- a/openedx/core/djangoapps/notifications/serializers.py +++ b/openedx/core/djangoapps/notifications/serializers.py @@ -5,7 +5,7 @@ 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 NotificationPreference +from openedx.core.djangoapps.notifications.models import Notification, NotificationPreference class CourseOverviewSerializer(serializers.ModelSerializer): @@ -52,3 +52,22 @@ class UserNotificationPreferenceSerializer(serializers.ModelSerializer): setattr(instance, key, val) instance.save() return instance + + +class NotificationSerializer(serializers.ModelSerializer): + """ + Serializer for the Notification model. + """ + + class Meta: + model = Notification + fields = ( + 'id', + 'app_name', + 'notification_type', + 'content', + 'content_context', + 'content_url', + 'last_read', + 'last_seen', + ) diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index b110613037..dcc622d657 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -7,13 +7,13 @@ from django.dispatch import Signal from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag from rest_framework import status -from rest_framework.test import APIClient +from rest_framework.test import APIClient, APITestCase 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 NotificationPreference +from openedx.core.djangoapps.notifications.models import Notification, NotificationPreference from openedx.core.djangoapps.notifications.serializers import NotificationCourseEnrollmentSerializer from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -217,3 +217,159 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): 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) + + +class NotificationListAPIViewTest(APITestCase): + """ + Tests suit for the NotificationListAPIView. + """ + + def setUp(self): + self.user = self.user = UserFactory() + self.url = reverse('notifications-list') + + def test_list_notifications(self): + """ + Test that the view can list notifications. + """ + # Create a notification for the user. + Notification.objects.create( + user=self.user, + app_name='app1', + notification_type='info', + content='This is a notification.', + ) + self.client.login(username=self.user.username, password='test') + + # Make a request to the view. + response = self.client.get(self.url) + + # Assert that the response is successful. + + self.assertEqual(response.status_code, 200) + data = response.data['results'] + # Assert that the response contains the notification. + 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): + """ + Test that the view can filter notifications by app name. + """ + # Create two notifications for the user, one for each app name. + Notification.objects.create( + 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') + + # Make a request to the view with the app_name query parameter set to 'app1'. + response = self.client.get(self.url + "?app_name=app1") + + # Assert that the response is successful. + self.assertEqual(response.status_code, 200) + + # Assert that the response contains only the notification for app1. + data = response.data['results'] + 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): + """ + Test that the view returns 403 if the user is not authenticated. + """ + # Make a request to the view without authenticating. + response = self.client.get(self.url) + + # Assert that the response is unauthorized. + self.assertEqual(response.status_code, 403) + + +class NotificationCountViewSetTestCase(APITestCase): + """ + Tests for the NotificationCountViewSet. + """ + + def setUp(self): + # Create a user. + self.user = UserFactory() + self.url = reverse('notifications-count') + # Create some notifications for the user. + Notification.objects.create(user=self.user, app_name='App Name 1', notification_type='Type A') + Notification.objects.create(user=self.user, app_name='App Name 1', notification_type='Type B') + Notification.objects.create(user=self.user, app_name='App Name 2', notification_type='Type A') + Notification.objects.create(user=self.user, app_name='App Name 3', notification_type='Type C') + + def test_get_unseen_notifications_count(self): + """ + Test that the endpoint returns the correct count of unseen notifications. + """ + self.client.login(username=self.user.username, password='test') + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 4) + self.assertEqual(response.data['count_by_app_name'], {'App Name 1': 2, 'App Name 2': 1, 'App Name 3': 1}) + + def test_get_unseen_notifications_count_for_unauthenticated_user(self): + """ + Test that the endpoint returns 403 for an unauthenticated user. + """ + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_get_unseen_notifications_count_for_user_with_no_notifications(self): + """ + Test that the endpoint returns 0 for a user with no notifications. + """ + # Create a user with no notifications. + user = UserFactory() + self.client.login(username=user.username, password='test') + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['count'], 0) + self.assertEqual(response.data['count_by_app_name'], {}) + + +class MarkNotificationsUnseenAPIViewTestCase(APITestCase): + """ + Tests for the MarkNotificationsUnseenAPIView. + """ + def setUp(self): + self.user = UserFactory() + + # Create some sample notifications for the user + Notification.objects.create(user=self.user, app_name='App Name 1', notification_type='Type A') + Notification.objects.create(user=self.user, app_name='App Name 1', notification_type='Type B') + Notification.objects.create(user=self.user, app_name='App Name 2', notification_type='Type A') + Notification.objects.create(user=self.user, app_name='App Name 3', notification_type='Type C') + + def test_mark_notifications_unseen(self): + # Create a POST request to mark notifications as unseen for 'App Name 1' + app_name = 'App Name 1' + url = reverse('mark-notifications-unseen', kwargs={'app_name': app_name}) + self.client.login(username=self.user.username, password='test') + response = self.client.put(url) + # Assert the response status code is 200 (OK) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Assert the response data contains the expected message + expected_data = {'message': 'Notifications marked unseen.'} + self.assertEqual(response.data, expected_data) + + # Assert the notifications for 'App Name 1' are marked as unseen for the user + notifications = Notification.objects.filter(user=self.user, app_name=app_name, last_seen__isnull=False) + self.assertEqual(notifications.count(), 2) diff --git a/openedx/core/djangoapps/notifications/urls.py b/openedx/core/djangoapps/notifications/urls.py index e7b72d1750..cfda7ff05d 100644 --- a/openedx/core/djangoapps/notifications/urls.py +++ b/openedx/core/djangoapps/notifications/urls.py @@ -1,11 +1,17 @@ """ URLs for the notifications API. """ -from django.urls import path -from django.urls import re_path from django.conf import settings +from django.urls import path, re_path from rest_framework import routers -from .views import CourseEnrollmentListView, UserNotificationPreferenceView + +from .views import ( + CourseEnrollmentListView, + MarkNotificationsUnseenAPIView, + NotificationCountView, + NotificationListAPIView, + UserNotificationPreferenceView +) router = routers.DefaultRouter() @@ -17,6 +23,14 @@ urlpatterns = [ UserNotificationPreferenceView.as_view(), name='notification-preferences' ), + path('', NotificationListAPIView.as_view(), name='notifications-list'), + path('count/', NotificationCountView.as_view(), name='notifications-count'), + path( + 'mark-notifications-unseen//', + MarkNotificationsUnseenAPIView.as_view(), + name='mark-notifications-unseen' + ), + ] urlpatterns += router.urls diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py index e901a888e5..6cf22d37c9 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -1,16 +1,25 @@ """ Views for the notifications API. """ +from datetime import datetime + from django.contrib.auth import get_user_model +from django.db.models import Count from opaque_keys.edx.keys import CourseKey from rest_framework import generics, permissions, status +from rest_framework.generics import UpdateAPIView 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 .serializers import NotificationCourseEnrollmentSerializer, UserNotificationPreferenceSerializer +from .models import Notification +from .serializers import ( + NotificationCourseEnrollmentSerializer, + NotificationSerializer, + UserNotificationPreferenceSerializer +) User = get_user_model() @@ -130,3 +139,132 @@ class UserNotificationPreferenceView(APIView): serializer.save() return Response(serializer.data) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class NotificationListAPIView(generics.ListAPIView): + """ + API view for listing notifications for a user. + + **Permissions**: User must be authenticated. + **Response Format** (paginated): + + { + "results" : [ + { + "id": (int) notification_id, + "app_name": (str) app_name, + "notification_type": (str) notification_type, + "content": (str) content, + "content_context": (dict) content_context, + "content_url": (str) content_url, + "last_read": (datetime) last_read, + "last_seen": (datetime) last_seen + }, + ... + ], + "count": (int) total_number_of_notifications, + "next": (str) url_to_next_page_of_notifications, + "previous": (str) url_to_previous_page_of_notifications, + "page_size": (int) number_of_notifications_per_page, + + } + + Response Error Codes: + - 403: The requester cannot access resource. + """ + + serializer_class = NotificationSerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_queryset(self): + """ + Override the get_queryset method to filter the queryset by app name and request.user. + """ + app_name = self.request.query_params.get('app_name') + if app_name: + return Notification.objects.filter(user=self.request.user, app_name=app_name) + else: + return Notification.objects.filter(user=self.request.user) + + +class NotificationCountView(APIView): + """ + API view for getting the unseen notifications count for a user. + """ + + permission_classes = (permissions.IsAuthenticated,) + + def get(self, request): + """ + Get the unseen notifications count for a user. + + **Permissions**: User must be authenticated. + **Response Format**: + ```json + { + "count": (int) total_number_of_unseen_notifications, + "count_by_app_name": { + (str) app_name: (int) number_of_unseen_notifications, + ... + } + } + ``` + **Response Error Codes**: + - 403: The requester cannot access resource. + """ + # Get the unseen notifications count for each app name. + count_by_app_name = ( + Notification.objects + .filter(user_id=request.user, last_seen__isnull=True) + .values('app_name') + .annotate(count=Count('*')) + ) + count_total = 0 + count_by_app_name_dict = {} + + for item in count_by_app_name: + app_name = item['app_name'] + count = item['count'] + + count_total += count + count_by_app_name_dict[app_name] = count + # Return the unseen notifications count for the user and the unseen notifications count for each app name. + + return Response({ + "count": count_total, + "count_by_app_name": count_by_app_name_dict, + }) + + +class MarkNotificationsUnseenAPIView(UpdateAPIView): + """ + API view for marking user's all notifications unseen for a provided app_name. + """ + + permission_classes = (permissions.IsAuthenticated,) + + def update(self, request, *args, **kwargs): + """ + Marks all notifications for the given app name unseen for the authenticated user. + + **Args:** + app_name: The name of the app to mark notifications unseen for. + **Response Format:** + A `Response` object with a 200 OK status code if the notifications were successfully marked unseen. + **Response Error Codes**: + - 400: Bad Request status code if the app name is invalid. + """ + app_name = self.kwargs.get('app_name') + + if not app_name: + return Response({'message': 'Invalid app name.'}, status=400) + + notifications = Notification.objects.filter( + user=request.user, + app_name=app_name, + last_seen__isnull=True, + ) + + notifications.update(last_seen=datetime.now()) + + return Response({'message': 'Notifications marked unseen.'}, status=200)