diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index c9df5c1fb6..c71bf4557a 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -126,6 +126,7 @@ "openedx/core/djangoapps/lang_pref/", "openedx/core/djangoapps/models/", "openedx/core/djangoapps/monkey_patch/", + "openedx/core/djangoapps/notifications/", "openedx/core/djangoapps/oauth_dispatch/", "openedx/core/djangoapps/olx_rest_api/", "openedx/core/djangoapps/password_policy/", diff --git a/cms/urls.py b/cms/urls.py index eb34f241fd..cbda922723 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -332,3 +332,6 @@ urlpatterns.extend(get_plugin_url_patterns(ProjectType.CMS)) urlpatterns += [ path('api/contentstore/', include('cms.djangoapps.contentstore.rest_api.urls')) ] +urlpatterns += [ + path('api/notifications/', include('openedx.core.djangoapps.notifications.urls')), +] diff --git a/lms/urls.py b/lms/urls.py index 26f05a4000..ddb1d79435 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -1049,3 +1049,7 @@ urlpatterns += [ urlpatterns += [ path('api/mfe_config/v1', include(('lms.djangoapps.mfe_config_api.urls', 'lms.djangoapps.mfe_config_api'), namespace='mfe_config_api')) ] + +urlpatterns += [ + path('api/notifications/', include('openedx.core.djangoapps.notifications.urls')), +] diff --git a/openedx/core/djangoapps/notifications/apps.py b/openedx/core/djangoapps/notifications/apps.py new file mode 100644 index 0000000000..63d1101911 --- /dev/null +++ b/openedx/core/djangoapps/notifications/apps.py @@ -0,0 +1,20 @@ +""" +Config for notifications app +""" + +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + """ + Config for notifications app + """ + name = 'openedx.core.djangoapps.notifications' + verbose_name = 'Notifications' + + def ready(self): + """ + Import signals + """ + # pylint: disable=unused-import + from . import handlers diff --git a/openedx/core/djangoapps/notifications/config/__init__.py b/openedx/core/djangoapps/notifications/config/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/notifications/config/waffle.py b/openedx/core/djangoapps/notifications/config/waffle.py new file mode 100644 index 0000000000..cb53dc0e20 --- /dev/null +++ b/openedx/core/djangoapps/notifications/config/waffle.py @@ -0,0 +1,19 @@ +""" +This module contains various configuration settings via +waffle switches for the notifications app. +""" + +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag + +WAFFLE_NAMESPACE = 'notifications' + +# .. toggle_name: notifications.enable_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable the Notifications feature +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2023-05-05 +# .. toggle_target_removal_date: 2023-11-05 +# .. toggle_warning: When the flag is ON, Notifications feature is enabled. +# .. toggle_tickets: INF-866 +ENABLE_NOTIFICATIONS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_notifications', __name__) diff --git a/openedx/core/djangoapps/notifications/handlers.py b/openedx/core/djangoapps/notifications/handlers.py new file mode 100644 index 0000000000..e76622dc26 --- /dev/null +++ b/openedx/core/djangoapps/notifications/handlers.py @@ -0,0 +1,26 @@ +""" +Handlers for notifications +""" +import logging + +from django.db import IntegrityError +from django.db.models.signals import post_save +from django.dispatch import receiver + +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.models import NotificationPreference + +log = logging.getLogger(__name__) + + +@receiver(post_save, sender='student.CourseEnrollment') +def course_enrollment_post_save(sender, instance, created, **kwargs): + """ + Watches for post_save signal for creates on the CourseEnrollment table. + Generate a NotificationPreference if new Enrollment is created + """ + if created and ENABLE_NOTIFICATIONS.is_enabled(instance.course_id): + try: + NotificationPreference.objects.create(user=instance.user, course_id=instance.course_id) + except IntegrityError: + log.info(f'NotificationPreference already exists for user {instance.user} and course {instance.course_id}') diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index 8d8d3fa2cd..0e1a83b19b 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -4,10 +4,8 @@ Models for notifications from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db import models from model_utils.models import TimeStampedModel - from opaque_keys.edx.django.models import CourseKeyField - # When notification preferences are updated, we need to update the CONFIG_VERSION. NOTIFICATION_PREFERENCE_CONFIG = { "discussion": { diff --git a/openedx/core/djangoapps/notifications/serializers.py b/openedx/core/djangoapps/notifications/serializers.py new file mode 100644 index 0000000000..573111a0e7 --- /dev/null +++ b/openedx/core/djangoapps/notifications/serializers.py @@ -0,0 +1,28 @@ +""" +Serializers for the notifications API. +""" +from rest_framework import serializers + +from common.djangoapps.student.models import CourseEnrollment +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + +class CourseOverviewSerializer(serializers.ModelSerializer): + """ + Serializer for CourseOverview model. + """ + + class Meta: + model = CourseOverview + fields = ('id', 'display_name') + + +class NotificationCourseEnrollmentSerializer(serializers.ModelSerializer): + """ + Serializer for CourseEnrollment model. + """ + course = CourseOverviewSerializer() + + class Meta: + model = CourseEnrollment + fields = ('course', 'is_active', 'mode') diff --git a/openedx/core/djangoapps/notifications/tests/__init__.py b/openedx/core/djangoapps/notifications/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py new file mode 100644 index 0000000000..8b293b5bf1 --- /dev/null +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -0,0 +1,125 @@ +""" +Tests for the views in the notifications app. +""" +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 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.serializers import NotificationCourseEnrollmentSerializer +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class CourseEnrollmentListViewTest(ModuleStoreTestCase): + """ + Tests for the CourseEnrollmentListView. + """ + + def setUp(self): + """ + Set up the test. + """ + super().setUp() + self.client = APIClient() + self.user = UserFactory() + # self.client.force_authenticate(user=self.user) + course_1 = CourseFactory.create( + org='testorg', + number='testcourse', + run='testrun' + ) + course_2 = CourseFactory.create( + org='testorg', + number='testcourse_two', + run='testrun' + ) + course_overview_1 = CourseOverviewFactory.create(id=course_1.id, org='AwesomeOrg') + course_overview_2 = CourseOverviewFactory.create(id=course_2.id, org='AwesomeOrg') + + self.enrollment1 = CourseEnrollment.objects.create( + user=self.user, + course=course_overview_1, + is_active=True, + mode='audit' + ) + self.enrollment2 = CourseEnrollment.objects.create( + user=self.user, + course=course_overview_2, + is_active=False, + mode='honor' + ) + + def test_course_enrollment_list_view(self): + """ + Test the CourseEnrollmentListView. + """ + self.client.login(username=self.user.username, password='test') + url = reverse('enrollment-list') + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + enrollments = CourseEnrollment.objects.filter(user=self.user, is_active=True) + expected_data = NotificationCourseEnrollmentSerializer(enrollments, many=True).data + self.assertEqual(response.data, expected_data) + + def test_course_enrollment_api_permission(self): + """ + Calls api without login. + Check is 403 is returned + """ + url = reverse('enrollment-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +class CourseEnrollmentPostSaveTest(ModuleStoreTestCase): + """ + Tests for the post_save signal for CourseEnrollment. + """ + + def setUp(self): + """ + Set up the test. + """ + super().setUp() + self.user = UserFactory() + self.course = CourseFactory.create( + org='testorg', + number='testcourse', + run='testrun' + ) + + course_overview = CourseOverviewFactory.create(id=self.course.id, org='AwesomeOrg') + self.course_enrollment = CourseEnrollment.objects.create( + user=self.user, + course=course_overview, + is_active=True, + mode='audit' + ) + self.post_save_signal = Signal() + + def test_course_enrollment_post_save(self): + """ + Test the post_save signal for CourseEnrollment. + """ + # Emit post_save signal + + self.post_save_signal.send( + sender=self.course_enrollment.__class__, + instance=self.course_enrollment, + created=True + ) + + # Assert that NotificationPreference object was created with correct attributes + notification_preferences = NotificationPreference.objects.all() + + self.assertEqual(notification_preferences.count(), 1) + self.assertEqual(notification_preferences[0].user, self.user) diff --git a/openedx/core/djangoapps/notifications/urls.py b/openedx/core/djangoapps/notifications/urls.py new file mode 100644 index 0000000000..eaa85ec148 --- /dev/null +++ b/openedx/core/djangoapps/notifications/urls.py @@ -0,0 +1,15 @@ +""" +URLs for the notifications API. +""" +from django.urls import path +from rest_framework import routers + +from .views import CourseEnrollmentListView + +router = routers.DefaultRouter() + +urlpatterns = [ + path('enrollments/', CourseEnrollmentListView.as_view(), name='enrollment-list'), +] + +urlpatterns += router.urls diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py new file mode 100644 index 0000000000..9aaa89550f --- /dev/null +++ b/openedx/core/djangoapps/notifications/views.py @@ -0,0 +1,38 @@ +""" +Views for the notifications API. +""" +from rest_framework import generics, permissions + +from common.djangoapps.student.models import CourseEnrollment + +from .serializers import NotificationCourseEnrollmentSerializer + + +class CourseEnrollmentListView(generics.ListAPIView): + """ + API endpoint to get active CourseEnrollments for requester. + + **Permissions**: User must be authenticated. + + **Response Format**: + [ + { + "course": { + "id": (int) course_id, + "display_name": (str) course_display_name + }, + "is_active": (bool) is_enrollment_active, + "mode": (str) enrollment_mode + }, + ... + ] + **Response Error Codes**: + - 403: The requester cannot access resource. + """ + serializer_class = NotificationCourseEnrollmentSerializer + permission_classes = [permissions.IsAuthenticated] + pagination_class = None + + def get_queryset(self): + user = self.request.user + return CourseEnrollment.objects.filter(user=user, is_active=True)