feat: add course wide notification event for notifications having wider audience
This commit is contained in:
73
openedx/core/djangoapps/notifications/audience_filters.py
Normal file
73
openedx/core/djangoapps/notifications/audience_filters.py
Normal file
@@ -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)
|
||||
@@ -9,14 +9,24 @@ from django.dispatch import receiver
|
||||
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)
|
||||
|
||||
@@ -10,16 +10,22 @@ from django.utils.timezone import now
|
||||
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 @@ class CourseExpirationTestCase(ModuleStoreTestCase):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user