From fed784a66453b4ebc90bb998857324919949f1b1 Mon Sep 17 00:00:00 2001 From: SaadYousaf Date: Tue, 7 Nov 2023 14:01:43 +0500 Subject: [PATCH] feat: add course wide notification event for notifications having wider audience --- .../notifications/audience_filters.py | 73 +++++++ .../core/djangoapps/notifications/handlers.py | 55 +++++- .../notifications/tests/test_filters.py | 181 +++++++++++++++++- 3 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 openedx/core/djangoapps/notifications/audience_filters.py diff --git a/openedx/core/djangoapps/notifications/audience_filters.py b/openedx/core/djangoapps/notifications/audience_filters.py new file mode 100644 index 000000000000..77e0f25d56e9 --- /dev/null +++ b/openedx/core/djangoapps/notifications/audience_filters.py @@ -0,0 +1,73 @@ +""" +Audience based filters for notifications +""" + +from abc import abstractmethod + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.models import CourseEnrollment +from lms.djangoapps.discussion.django_comment_client.utils import get_users_with_roles +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_GROUP_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, +) + + +class NotificationAudienceFilterBase: + """ + Base class for notification audience filters + """ + def __init__(self, course_key): + self.course_key = course_key + + allowed_filters = [] + + def is_valid_filter(self, values): + return all(value in self.allowed_filters for value in values) + + @abstractmethod + def filter(self, values): + pass + + +class RoleAudienceFilter(NotificationAudienceFilterBase): + """ + Filter class for roles + """ + # TODO: Add course roles to this. We currently support only forum roles + allowed_filters = [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_GROUP_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ] + + def filter(self, roles): + """ + Filter users based on their roles + """ + if not self.is_valid_filter(roles): + raise ValueError(f'Invalid roles {roles} passed to RoleAudienceFilter') + return [user.id for user in get_users_with_roles(roles, self.course_key)] + + +class EnrollmentAudienceFilter(NotificationAudienceFilterBase): + """ + Filter class for enrollment modes + """ + allowed_filters = CourseMode.ALL_MODES + + def filter(self, enrollment_modes): + """ + Filter users based on their course enrollment modes + """ + if not self.is_valid_filter(enrollment_modes): + raise ValueError(f'Invalid enrollment modes {enrollment_modes} passed to EnrollmentAudienceFilter') + return CourseEnrollment.objects.filter( + course_id=self.course_key, + mode__in=enrollment_modes, + ).values_list('user_id', flat=True) diff --git a/openedx/core/djangoapps/notifications/handlers.py b/openedx/core/djangoapps/notifications/handlers.py index c0b85d7c8da8..6c9ee8155077 100644 --- a/openedx/core/djangoapps/notifications/handlers.py +++ b/openedx/core/djangoapps/notifications/handlers.py @@ -9,14 +9,24 @@ from openedx_events.learning.signals import ( COURSE_ENROLLMENT_CREATED, COURSE_UNENROLLMENT_COMPLETED, - USER_NOTIFICATION_REQUESTED + USER_NOTIFICATION_REQUESTED, + COURSE_NOTIFICATION_REQUESTED, ) +from common.djangoapps.student.models import CourseEnrollment +from openedx.core.djangoapps.notifications.audience_filters import RoleAudienceFilter, EnrollmentAudienceFilter from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS from openedx.core.djangoapps.notifications.models import CourseNotificationPreference log = logging.getLogger(__name__) +AUDIENCE_FILTER_TYPES = ['role', 'enrollment'] + +AUDIENCE_FILTER_CLASSES = { + 'role': RoleAudienceFilter, + 'enrollment': EnrollmentAudienceFilter, +} + @receiver(COURSE_ENROLLMENT_CREATED) def course_enrollment_post_save(signal, sender, enrollment, metadata, **kwargs): @@ -53,9 +63,50 @@ def on_user_course_unenrollment(enrollment, **kwargs): @receiver(USER_NOTIFICATION_REQUESTED) def generate_user_notifications(signal, sender, notification_data, metadata, **kwargs): """ - Watches for USER_NOTIFICATION_REQUESTED signal and calls send_web_notifications task + Watches for USER_NOTIFICATION_REQUESTED signal and calls send_web_notifications task + """ + from openedx.core.djangoapps.notifications.tasks import send_notifications + notification_data = notification_data.__dict__ + notification_data['course_key'] = str(notification_data['course_key']) + send_notifications.delay(**notification_data) + + +def calculate_course_wide_notification_audience(course_key, audience_filters): + """ + Calculate the audience for a course-wide notification based on the audience filters + """ + if not audience_filters: + return CourseEnrollment.objects.filter(course_id=course_key, is_active=True).values_list('user_id', flat=True) + + audience_user_ids = [] + for filter_type, filter_values in audience_filters.items(): + if filter_type in AUDIENCE_FILTER_TYPES: + filter_class = AUDIENCE_FILTER_CLASSES.get(filter_type) + if filter_class: + filter_instance = filter_class(course_key) + filtered_users = filter_instance.filter(filter_values) + audience_user_ids.extend(filtered_users) + else: + raise ValueError(f"Invalid audience filter type: {filter_type}") + + return list(set(audience_user_ids)) + + +@receiver(COURSE_NOTIFICATION_REQUESTED) +def generate_course_notifications(signal, sender, notification_data, metadata, **kwargs): + """ + Watches for COURSE_NOTIFICATION_REQUESTED signal and calls send_notifications task """ from openedx.core.djangoapps.notifications.tasks import send_notifications notification_data = notification_data.__dict__ notification_data['course_key'] = str(notification_data['course_key']) + + audience_filters = notification_data.pop('audience_filters') + user_ids = calculate_course_wide_notification_audience( + notification_data['course_key'], + audience_filters, + ) + notification_data['user_ids'] = user_ids + notification_data['context'] = notification_data.pop('content_context') + send_notifications.delay(**notification_data) diff --git a/openedx/core/djangoapps/notifications/tests/test_filters.py b/openedx/core/djangoapps/notifications/tests/test_filters.py index ed2aa2f87b6b..9b92ee9cb665 100644 --- a/openedx/core/djangoapps/notifications/tests/test_filters.py +++ b/openedx/core/djangoapps/notifications/tests/test_filters.py @@ -10,16 +10,22 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import CourseInstructorRole -from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.tests.factories import UserFactory, CourseEnrollmentFactory from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_MODERATOR, - Role + Role, +) +from openedx.core.djangoapps.notifications.audience_filters import ( + EnrollmentAudienceFilter, + RoleAudienceFilter, ) from openedx.core.djangoapps.notifications.filters import NotificationFilter +from openedx.core.djangoapps.notifications.handlers import calculate_course_wide_notification_audience from openedx.features.course_duration_limits.models import CourseDurationLimitConfig from openedx.features.course_experience.tests.views.helpers import add_course_mode from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -156,3 +162,174 @@ def test_audit_expired_for_forum_roles( self.course, ) self.assertEqual([self.user.id, self.user_1.id], result) + + +def assign_enrollment_mode_to_users(course_id, users, mode): + """ + Helper function to create an enrollment with the given mode. + """ + for user in users: + enrollment = CourseEnrollmentFactory.create(user=user, course_id=course_id) + enrollment.mode = mode + enrollment.save() + + +def assign_role_to_users(course_id, users, role_name): + """ + Helper function to assign a role to a user. + """ + role = Role.objects.create(name=role_name, course_id=course_id) + role.users.set(users) + role.save() + + +@ddt.ddt +class TestEnrollmentAudienceFilter(ModuleStoreTestCase): + """ + Tests for the EnrollmentAudienceFilter. + """ + def setUp(self): + super().setUp() # lint-amnesty, pylint: disable=super-with-arguments + self.course = CourseFactory() + self.students = [UserFactory() for _ in range(30)] + + # Create 10 audit enrollments + assign_enrollment_mode_to_users(self.course.id, self.students[:10], CourseMode.AUDIT) + + # Create 10 honor enrollments + assign_enrollment_mode_to_users(self.course.id, self.students[10:20], CourseMode.HONOR) + + # Create 10 verified enrollments + assign_enrollment_mode_to_users(self.course.id, self.students[20:], CourseMode.VERIFIED) + + @ddt.data( + (["audit"], 10), + (["audit", "honor"], 20), + (["audit", "honor", "verified"], 30), + (["honor"], 10), + (["honor", "verified"], 20), + (["verified"], 10), + ) + @ddt.unpack + def test_valid_enrollment_filter(self, enrollment_modes, expected_count): + enrollment_filter = EnrollmentAudienceFilter(self.course.id) + filtered_users = enrollment_filter.filter(enrollment_modes) + self.assertEqual(len(filtered_users), expected_count) + + def test_invalid_enrollment_filter(self): + enrollment_filter = EnrollmentAudienceFilter(self.course.id) + enrollment_modes = ["INVALID_MODE"] + with self.assertRaises(ValueError): + enrollment_filter.filter(enrollment_modes) + + +@ddt.ddt +class TestRoleAudienceFilter(ModuleStoreTestCase): + """ + Tests for the RoleAudienceFilter. + """ + def setUp(self): + super().setUp() # lint-amnesty, pylint: disable=super-with-arguments + self.course = CourseFactory() + self.students = [UserFactory() for _ in range(25)] + + # Assign 5 users with administrator role + assign_role_to_users(self.course.id, self.students[:5], FORUM_ROLE_ADMINISTRATOR) + + # Assign 5 users with moderator role + assign_role_to_users(self.course.id, self.students[5:10], FORUM_ROLE_MODERATOR) + + # Assign 5 users with student role + assign_role_to_users(self.course.id, self.students[10:15], FORUM_ROLE_STUDENT) + + # Assign 5 users with community TA role + assign_role_to_users(self.course.id, self.students[15:20], FORUM_ROLE_COMMUNITY_TA) + + # Assign 5 users with group moderator role + assign_role_to_users(self.course.id, self.students[20:25], FORUM_ROLE_GROUP_MODERATOR) + + @ddt.data( + (["Administrator"], 5), + (["Moderator"], 5), + (["Student"], 5), + (["Community TA"], 5), + (["Group Moderator"], 5), + (["Administrator", "Moderator"], 10), + (["Administrator", "Moderator", "Student"], 15), + (["Moderator", "Student", "Community TA"], 15), + (["Student", "Community TA", "Group Moderator"], 15), + (["Community TA", "Group Moderator"], 10), + (["Administrator", "Moderator", "Student", "Community TA", "Group Moderator"], 25), + ) + @ddt.unpack + def test_valid_role_filter(self, role_names, expected_count): + role_filter = RoleAudienceFilter(self.course.id) + filtered_users = role_filter.filter(role_names) + self.assertEqual(len(filtered_users), expected_count) + + def test_invalid_role_filter(self): + role_filter = RoleAudienceFilter(self.course.id) + role_names = ["INVALID_MODE"] + with self.assertRaises(ValueError): + role_filter.filter(role_names) + + +@ddt.ddt +class TestAudienceFilter(ModuleStoreTestCase): + """ + Tests for audience filtration based on different filters. + """ + def setUp(self): + super().setUp() # lint-amnesty, pylint: disable=super-with-arguments + self.course = CourseFactory() + self.students = [UserFactory() for _ in range(30)] + + # Create 10 audit enrollments + assign_enrollment_mode_to_users(self.course.id, self.students[:10], CourseMode.AUDIT) + + # Create 10 honor enrollments + assign_enrollment_mode_to_users(self.course.id, self.students[10:20], CourseMode.HONOR) + + # Create 10 verified enrollments + assign_enrollment_mode_to_users(self.course.id, self.students[20:], CourseMode.VERIFIED) + + # Assign 5 users with administrator role + assign_role_to_users(self.course.id, self.students[:5], FORUM_ROLE_ADMINISTRATOR) + + # Assign 5 users with moderator role + assign_role_to_users(self.course.id, self.students[5:10], FORUM_ROLE_MODERATOR) + + # Assign 5 users with student role + assign_role_to_users(self.course.id, self.students[10:15], FORUM_ROLE_STUDENT) + + # Assign 5 users with community TA role + assign_role_to_users(self.course.id, self.students[15:20], FORUM_ROLE_COMMUNITY_TA) + + # Assign 5 users with group moderator role + assign_role_to_users(self.course.id, self.students[20:25], FORUM_ROLE_GROUP_MODERATOR) + + @ddt.data( + ({ + "enrollment": ["verified"], + "role": ["Moderator"], + }, 15), + ({ + "enrollment": ["audit", "verified"], + "role": ["Administrator", "Student"], + }, 30), + ({ + "enrollment": ["audit", "honor", "verified"], + "role": ["Administrator", "Moderator", "Student", "Community TA"], + }, 30), + ) + @ddt.unpack + def test_combination_of_audience_filters(self, audience_filters, expected_count): + user_ids = calculate_course_wide_notification_audience(self.course.id, audience_filters) + self.assertEqual(len(user_ids), expected_count) + + def test_invalid_audience_filter(self): + audience_filters = { + "invalid_filter": ["invalid_filter_type"], + } + with self.assertRaises(ValueError): + calculate_course_wide_notification_audience(self.course.id, audience_filters)