diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index e27aeabb97..01bc8b26d8 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -25,6 +25,8 @@ from openedx.core.djangoapps.notifications.serializers import NotificationCourse from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +from ..base_notification import COURSE_NOTIFICATION_APPS + class CourseEnrollmentListViewTest(ModuleStoreTestCase): """ @@ -501,3 +503,103 @@ class MarkNotificationsUnseenAPIViewTestCase(APITestCase): # 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) + + +class NotificationReadAPIViewTestCase(APITestCase): + """ + Tests for the NotificationReadAPIView. + """ + + def setUp(self): + self.user = UserFactory() + self.url = reverse('notifications-read') + self.client.login(username=self.user.username, password='test') + + # Create some sample notifications for the user with already existing apps and with invalid app name + Notification.objects.create(user=self.user, app_name='app_name_2', notification_type='Type A') + for app_name in COURSE_NOTIFICATION_APPS: + Notification.objects.create(user=self.user, app_name=app_name, notification_type='Type A') + Notification.objects.create(user=self.user, app_name=app_name, notification_type='Type B') + + def test_mark_all_notifications_read_with_app_name(self): + # Create a PATCH request to mark all notifications as read for already existing app e.g 'discussion' + app_name = next(iter(COURSE_NOTIFICATION_APPS)) + data = {'app_name': app_name} + + response = self.client.patch(self.url, data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {'message': 'Notifications marked read.'}) + notifications = Notification.objects.filter(user=self.user, app_name=app_name, last_read__isnull=False) + self.assertEqual(notifications.count(), 2) + + def test_mark_all_notifications_read_with_invalid_app_name(self): + # Create a PATCH request to mark all notifications as read for 'app_name_1' + app_name = 'app_name_1' + data = {'app_name': app_name} + + response = self.client.patch(self.url, data) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {'error': 'Invalid app_name or notification_id.'}) + + def test_mark_notification_read_with_notification_id(self): + # Create a PATCH request to mark notification as read for notification_id: 2 + notification_id = 2 + data = {'notification_id': notification_id} + + response = self.client.patch(self.url, data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {'message': 'Notification marked read.'}) + notifications = Notification.objects.filter(user=self.user, id=notification_id, last_read__isnull=False) + self.assertEqual(notifications.count(), 1) + + def test_mark_notification_read_with_other_user_notification_id(self): + # Create a PATCH request to mark notification as read for notification_id: 2 through a different user + self.client.logout() + self.user = UserFactory() + self.client.login(username=self.user.username, password='test') + + notification_id = 2 + data = {'notification_id': notification_id} + response = self.client.patch(self.url, data) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + notifications = Notification.objects.filter(user=self.user, id=notification_id, last_read__isnull=False) + self.assertEqual(notifications.count(), 0) + + def test_mark_notification_read_with_invalid_notification_id(self): + # Create a PATCH request to mark notification as read for notification_id: 23345 + notification_id = 23345 + data = {'notification_id': notification_id} + + response = self.client.patch(self.url, data) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data["detail"], 'Not found.') + + def test_mark_notification_read_with_app_name_and_notification_id(self): + # Create a PATCH request to mark notification as read for existing app e.g 'discussion' and notification_id: 2 + # notification_id has higher priority than app_name in this case app_name is ignored + app_name = next(iter(COURSE_NOTIFICATION_APPS)) + notification_id = 2 + data = {'app_name': app_name, 'notification_id': notification_id} + + response = self.client.patch(self.url, data) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {'message': 'Notification marked read.'}) + notifications = Notification.objects.filter( + user=self.user, + id=notification_id, + last_read__isnull=False + ) + self.assertEqual(notifications.count(), 1) + + def test_mark_notification_read_without_app_name_and_notification_id(self): + # Create a PATCH request to mark notification as read without app_name and notification_id + response = self.client.patch(self.url, {}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {'error': 'Invalid app_name or notification_id.'}) diff --git a/openedx/core/djangoapps/notifications/urls.py b/openedx/core/djangoapps/notifications/urls.py index cfda7ff05d..f4638ea7de 100644 --- a/openedx/core/djangoapps/notifications/urls.py +++ b/openedx/core/djangoapps/notifications/urls.py @@ -10,6 +10,7 @@ from .views import ( MarkNotificationsUnseenAPIView, NotificationCountView, NotificationListAPIView, + NotificationReadAPIView, UserNotificationPreferenceView ) @@ -30,6 +31,7 @@ urlpatterns = [ MarkNotificationsUnseenAPIView.as_view(), name='mark-notifications-unseen' ), + path('read/', NotificationReadAPIView.as_view(), name='notifications-read'), ] diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py index ba664fbd59..e4811e51dc 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -5,6 +5,8 @@ from datetime import datetime, timedelta from django.conf import settings from django.db.models import Count +from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext as _ from opaque_keys.edx.keys import CourseKey from pytz import UTC from rest_framework import generics, permissions, status @@ -18,6 +20,7 @@ from openedx.core.djangoapps.notifications.models import ( get_course_notification_preference_config_version ) +from .base_notification import COURSE_NOTIFICATION_APPS from .config.waffle import ENABLE_NOTIFICATIONS, SHOW_NOTIFICATIONS_TRAY from .models import Notification from .serializers import ( @@ -174,7 +177,7 @@ class UserNotificationPreferenceView(APIView): ) 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.'}, + {'error': _('The notification preference config version is not up to date.')}, status=status.HTTP_409_CONFLICT, ) @@ -323,7 +326,7 @@ class MarkNotificationsUnseenAPIView(UpdateAPIView): app_name = self.kwargs.get('app_name') if not app_name: - return Response({'message': 'Invalid app name.'}, status=400) + return Response({'error': _('Invalid app name.')}, status=400) notifications = Notification.objects.filter( user=request.user, @@ -333,4 +336,55 @@ class MarkNotificationsUnseenAPIView(UpdateAPIView): notifications.update(last_seen=datetime.now()) - return Response({'message': 'Notifications marked unseen.'}, status=200) + return Response({'message': _('Notifications marked unseen.')}, status=200) + + +class NotificationReadAPIView(APIView): + """ + API view for marking user notifications as read, either all notifications or a single notification + """ + + permission_classes = (permissions.IsAuthenticated,) + + def patch(self, request, *args, **kwargs): + """ + Marks all notifications or single notification read for the given + app name or notification id for the authenticated user. + + Requests: + PATCH /api/notifications/read/ + + Parameters: + request (Request): The request object containing the app name or notification id. + { + "app_name": (str) app_name, + "notification_id": (int) notification_id + } + + Returns: + - 200: OK status code if the notification or notifications were successfully marked read. + - 400: Bad Request status code if the app name is invalid. + - 403: Forbidden status code if the user is not authenticated. + - 404: Not Found status code if the notification was not found. + """ + notification_id = request.data.get('notification_id', None) + read_at = datetime.now(UTC) + + if notification_id: + notification = get_object_or_404(Notification, pk=notification_id, user=request.user) + notification.last_read = read_at + notification.save() + return Response({'message': _('Notification marked read.')}, status=status.HTTP_200_OK) + + app_name = request.data.get('app_name', '') + + if app_name and app_name in COURSE_NOTIFICATION_APPS: + notifications = Notification.objects.filter( + user=request.user, + app_name=app_name, + last_read__isnull=True, + ) + notifications.update(last_read=read_at) + return Response({'message': _('Notifications marked read.')}, status=status.HTTP_200_OK) + + return Response({'error': _('Invalid app_name or notification_id.')}, status=status.HTTP_400_BAD_REQUEST)