feat: Added notification APIs (#32232)

* feat: added api to get notifications for users

* feat: added api to get count of notifications

* feat: added api to mark notifications as seen

* feat: added index on app_name in notification table

* refactor: updated api view parent and url

* refactor: resolved linter issue
This commit is contained in:
Ahtisham Shahid
2023-05-23 11:15:35 +05:00
committed by GitHub
parent 105260f642
commit 27b8d2f68d
6 changed files with 353 additions and 8 deletions

View File

@@ -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),
),
]

View File

@@ -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={})

View File

@@ -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',
)

View File

@@ -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)

View File

@@ -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/<app_name>/',
MarkNotificationsUnseenAPIView.as_view(),
name='mark-notifications-unseen'
),
]
urlpatterns += router.urls

View File

@@ -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)