Skip to content

Commit 8fac3bc

Browse files
feat: Add notify all learners option for discussion post (#36922)
* feat: Add notify all learners option for discussion post * fix: Remove waffle flag from default notification dict
1 parent bc102d8 commit 8fac3bc

File tree

14 files changed

+158
-35
lines changed

14 files changed

+158
-35
lines changed

lms/djangoapps/discussion/rest_api/api.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,8 @@
127127
get_usernames_for_course,
128128
get_usernames_from_search_string,
129129
set_attribute,
130-
is_posting_allowed
130+
is_posting_allowed,
131+
can_user_notify_all_learners
131132
)
132133

133134
User = get_user_model()
@@ -333,6 +334,8 @@ def _format_datetime(dt):
333334
course.get_discussion_blackout_datetimes()
334335
)
335336
discussion_tab = CourseTabList.get_tab_by_type(course.tabs, 'discussion')
337+
is_course_staff = CourseStaffRole(course_key).has_user(request.user)
338+
is_course_admin = CourseInstructorRole(course_key).has_user(request.user)
336339
return {
337340
"id": str(course_key),
338341
"is_posting_enabled": is_posting_enabled,
@@ -358,8 +361,8 @@ def _format_datetime(dt):
358361
}),
359362
"is_group_ta": bool(user_roles & {FORUM_ROLE_GROUP_MODERATOR}),
360363
"is_user_admin": request.user.is_staff,
361-
"is_course_staff": CourseStaffRole(course_key).has_user(request.user),
362-
"is_course_admin": CourseInstructorRole(course_key).has_user(request.user),
364+
"is_course_staff": is_course_staff,
365+
"is_course_admin": is_course_admin,
363366
"provider": course_config.provider_type,
364367
"enable_in_context": course_config.enable_in_context,
365368
"group_at_subsection": course_config.plugin_configuration.get("group_at_subsection", False),
@@ -372,6 +375,9 @@ def _format_datetime(dt):
372375
for (reason_code, label) in CLOSE_REASON_CODES.items()
373376
],
374377
'show_discussions': bool(discussion_tab and discussion_tab.is_enabled(course, request.user)),
378+
'is_notify_all_learners_enabled': can_user_notify_all_learners(
379+
course_key, user_roles, is_course_staff, is_course_admin
380+
),
375381
}
376382

377383

@@ -1469,6 +1475,8 @@ def create_thread(request, thread_data):
14691475
if not discussion_open_for_user(course, user):
14701476
raise DiscussionBlackOutException
14711477

1478+
notify_all_learners = thread_data.pop("notify_all_learners", False)
1479+
14721480
context = get_context(course, request)
14731481
_check_initializable_thread_fields(thread_data, context)
14741482
discussion_settings = CourseDiscussionSettings.get(course_key)
@@ -1484,7 +1492,7 @@ def create_thread(request, thread_data):
14841492
raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items())))
14851493
serializer.save()
14861494
cc_thread = serializer.instance
1487-
thread_created.send(sender=None, user=user, post=cc_thread)
1495+
thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners)
14881496
api_thread = serializer.data
14891497
_do_extra_actions(api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request)
14901498

lms/djangoapps/discussion/rest_api/discussions_notifications.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -318,17 +318,18 @@ def send_response_endorsed_notification(self):
318318
self._populate_context_with_ids_for_mobile(context, notification_type)
319319
self._send_notification([self.creator.id], notification_type, extra_context=context)
320320

321-
def send_new_thread_created_notification(self):
321+
def send_new_thread_created_notification(self, notify_all_learners=False):
322322
"""
323323
Send notification based on notification_type
324324
"""
325325
thread_type = self.thread.attributes['thread_type']
326-
notification_type = (
326+
327+
notification_type = "new_instructor_all_learners_post" if notify_all_learners else (
327328
"new_question_post"
328329
if thread_type == "question"
329330
else ("new_discussion_post" if thread_type == "discussion" else "")
330331
)
331-
if notification_type not in ['new_discussion_post', 'new_question_post']:
332+
if notification_type not in ['new_discussion_post', 'new_question_post', 'new_instructor_all_learners_post']:
332333
raise ValueError(f'Invalid notification type {notification_type}')
333334

334335
audience_filters = self._create_cohort_course_audience()

lms/djangoapps/discussion/rest_api/tasks.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@
66
from edx_django_utils.monitoring import set_code_owner_attribute
77
from opaque_keys.edx.locator import CourseKey
88

9+
from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole
910
from lms.djangoapps.courseware.courses import get_course_with_access
11+
from lms.djangoapps.discussion.django_comment_client.utils import get_user_role_names
1012
from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender
13+
from lms.djangoapps.discussion.rest_api.utils import can_user_notify_all_learners
1114
from openedx.core.djangoapps.django_comment_common.comment_client import Comment
1215
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread
1316
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
@@ -17,7 +20,7 @@
1720

1821
@shared_task
1922
@set_code_owner_attribute
20-
def send_thread_created_notification(thread_id, course_key_str, user_id):
23+
def send_thread_created_notification(thread_id, course_key_str, user_id, notify_all_learners=False):
2124
"""
2225
Send notification when a new thread is created
2326
"""
@@ -26,9 +29,17 @@ def send_thread_created_notification(thread_id, course_key_str, user_id):
2629
return
2730
thread = Thread(id=thread_id).retrieve()
2831
user = User.objects.get(id=user_id)
32+
33+
if notify_all_learners:
34+
is_course_staff = CourseStaffRole(course_key).has_user(user)
35+
is_course_admin = CourseInstructorRole(course_key).has_user(user)
36+
user_roles = get_user_role_names(user, course_key)
37+
if not can_user_notify_all_learners(course_key, user_roles, is_course_staff, is_course_admin):
38+
return
39+
2940
course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True)
3041
notification_sender = DiscussionNotificationSender(thread, course, user)
31-
notification_sender.send_new_thread_created_notification()
42+
notification_sender.send_new_thread_created_notification(notify_all_learners)
3243

3344

3445
@shared_task

lms/djangoapps/discussion/rest_api/tests/test_api.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ def test_basic(self):
214214
'edit_reasons': [{'code': 'test-edit-reason', 'label': 'Test Edit Reason'}],
215215
'post_close_reasons': [{'code': 'test-close-reason', 'label': 'Test Close Reason'}],
216216
'show_discussions': True,
217+
'is_notify_all_learners_enabled': False
217218
}
218219

219220
@ddt.data(
@@ -1379,7 +1380,9 @@ def test_basic(self, mock_emit):
13791380
"read": True,
13801381
})
13811382
self.register_post_thread_response(cs_thread)
1382-
with self.assert_signal_sent(api, 'thread_created', sender=None, user=self.user, exclude_args=('post',)):
1383+
with self.assert_signal_sent(
1384+
api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners')
1385+
):
13831386
actual = create_thread(self.request, self.minimal_data)
13841387
expected = self.expected_thread_data({
13851388
"id": "test_id",
@@ -1445,7 +1448,9 @@ def test_basic_in_blackout_period_with_user_access(self, mock_emit):
14451448

14461449
_assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_MODERATOR)
14471450

1448-
with self.assert_signal_sent(api, 'thread_created', sender=None, user=self.user, exclude_args=('post',)):
1451+
with self.assert_signal_sent(
1452+
api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners')
1453+
):
14491454
actual = create_thread(self.request, self.minimal_data)
14501455
expected = self.expected_thread_data({
14511456
"author_label": "Moderator",
@@ -1517,7 +1522,9 @@ def test_title_truncation(self, mock_emit):
15171522
"read": True,
15181523
})
15191524
self.register_post_thread_response(cs_thread)
1520-
with self.assert_signal_sent(api, 'thread_created', sender=None, user=self.user, exclude_args=('post',)):
1525+
with self.assert_signal_sent(
1526+
api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners')
1527+
):
15211528
create_thread(self.request, data)
15221529
event_name, event_data = mock_emit.call_args[0]
15231530
assert event_name == 'edx.forum.thread.created'

lms/djangoapps/discussion/rest_api/tests/test_tasks.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
FORUM_ROLE_STUDENT,
3030
CourseDiscussionSettings
3131
)
32-
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
32+
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFY_ALL_LEARNERS
3333
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
3434
from xmodule.modulestore.tests.factories import CourseFactory
3535

@@ -190,29 +190,46 @@ def test_not_authenticated(self):
190190
"""
191191

192192
@ddt.data(
193-
('new_question_post',),
194-
('new_discussion_post',),
193+
('new_question_post', False, False),
194+
('new_discussion_post', False, False),
195+
('new_discussion_post', True, True),
196+
('new_discussion_post', True, False),
195197
)
196198
@ddt.unpack
197-
def test_notification_is_send_to_all_enrollments(self, notification_type):
199+
def test_notification_is_send_to_all_enrollments(
200+
self, notification_type, notify_all_learners, waffle_flag_enabled
201+
):
198202
"""
199203
Tests notification is sent to all users if course is not cohorted
200204
"""
201205
self._assign_enrollments()
202206
thread_type = (
203-
"discussion"
204-
if notification_type == "new_discussion_post"
205-
else ("question" if notification_type == "new_question_post" else "")
207+
"discussion" if notification_type == "new_discussion_post" else "question"
206208
)
207-
thread = self._create_thread(thread_type=thread_type)
208-
handler = mock.Mock()
209-
COURSE_NOTIFICATION_REQUESTED.connect(handler)
210-
send_thread_created_notification(thread['id'], str(self.course.id), self.author.id)
211-
self.assertEqual(handler.call_count, 1)
212-
course_notification_data = handler.call_args[1]['course_notification_data']
213-
assert notification_type == course_notification_data.notification_type
214-
notification_audience_filters = {}
215-
assert notification_audience_filters == course_notification_data.audience_filters
209+
210+
with override_waffle_flag(ENABLE_NOTIFY_ALL_LEARNERS, active=waffle_flag_enabled):
211+
thread = self._create_thread(thread_type=thread_type)
212+
handler = mock.Mock()
213+
COURSE_NOTIFICATION_REQUESTED.connect(handler)
214+
215+
send_thread_created_notification(
216+
thread['id'],
217+
str(self.course.id),
218+
self.author.id,
219+
notify_all_learners
220+
)
221+
expected_handler_calls = 0 if notify_all_learners and not waffle_flag_enabled else 1
222+
self.assertEqual(handler.call_count, expected_handler_calls)
223+
224+
if handler.call_count:
225+
course_notification_data = handler.call_args[1]['course_notification_data']
226+
expected_type = (
227+
'new_instructor_all_learners_post'
228+
if notify_all_learners and waffle_flag_enabled
229+
else notification_type
230+
)
231+
self.assertEqual(course_notification_data.notification_type, expected_type)
232+
self.assertEqual(course_notification_data.audience_filters, {})
216233

217234
@ddt.data(
218235
('cohort_1', 'new_question_post'),

lms/djangoapps/discussion/rest_api/tests/test_views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,7 @@ def test_basic(self):
557557
"edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}],
558558
"post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}],
559559
'show_discussions': True,
560+
'is_notify_all_learners_enabled': False
560561
}
561562
)
562563

lms/djangoapps/discussion/rest_api/utils.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
1313
from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges
14+
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFY_ALL_LEARNERS
1415
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, PostingRestriction
1516
from openedx.core.djangoapps.django_comment_common.models import (
1617
FORUM_ROLE_ADMINISTRATOR,
@@ -379,3 +380,25 @@ def is_posting_allowed(posting_restrictions: str, blackout_schedules: List):
379380
return not any(schedule["start"] <= now <= schedule["end"] for schedule in blackout_schedules)
380381
else:
381382
return False
383+
384+
385+
def can_user_notify_all_learners(course_key, user_roles, is_course_staff, is_course_admin):
386+
"""
387+
Check if user posting is allowed to notify all learners based on the given restrictions
388+
389+
Args:
390+
course_key (CourseKey): CourseKey for which user creating any discussion post.
391+
user_roles (Dict): Roles of the posting user
392+
is_course_staff (Boolean): Whether the user has a course staff access.
393+
is_course_admin (Boolean): Whether the user has a course admin access.
394+
395+
Returns:
396+
bool: True if posting for all learner is allowed to this user, False otherwise.
397+
"""
398+
is_staff_or_instructor = any([
399+
user_roles.intersection({FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}),
400+
is_course_staff,
401+
is_course_admin,
402+
])
403+
404+
return is_staff_or_instructor and ENABLE_NOTIFY_ALL_LEARNERS.is_enabled(course_key)

lms/djangoapps/discussion/signals/handlers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,10 @@ def create_thread_created_notification(*args, **kwargs):
166166
"""
167167
user = kwargs['user']
168168
post = kwargs['post']
169-
send_thread_created_notification.apply_async(args=[post.id, post.attributes['course_id'], user.id])
169+
notify_all_learners = kwargs.get('notify_all_learners', False)
170+
send_thread_created_notification.apply_async(
171+
args=[post.id, post.attributes['course_id'], user.id, notify_all_learners]
172+
)
170173

171174

172175
@receiver(signals.comment_created)

openedx/core/djangoapps/notifications/base_notification.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,24 @@
230230
'email_template': '',
231231
'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE],
232232
},
233+
'new_instructor_all_learners_post': {
234+
'notification_app': 'discussion',
235+
'name': 'new_instructor_all_learners_post',
236+
'is_core': False,
237+
'info': '',
238+
'web': True,
239+
'email': False,
240+
'email_cadence': EmailCadence.DAILY,
241+
'push': False,
242+
'non_editable': [],
243+
'content_template': _('<{p}>Your instructor posted <{strong}>{post_title}</{strong}></{p}>'),
244+
'grouped_content_template': '',
245+
'content_context': {
246+
'post_title': 'Post title',
247+
},
248+
'email_template': '',
249+
'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE]
250+
},
233251
}
234252

235253
COURSE_NOTIFICATION_APPS = {

openedx/core/djangoapps/notifications/config/waffle.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,13 @@
4949
# .. toggle_warning: When the flag is ON, Notifications Grouping feature is enabled.
5050
# .. toggle_tickets: INF-1472
5151
ENABLE_NOTIFICATION_GROUPING = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_notification_grouping', __name__)
52+
53+
# .. toggle_name: notifications.post_enable_notify_all_learners
54+
# .. toggle_implementation: CourseWaffleFlag
55+
# .. toggle_default: False
56+
# .. toggle_description: Waffle flag to enable the notify all learners on discussion post
57+
# .. toggle_use_cases: open_edx
58+
# .. toggle_creation_date: 2025-06-11
59+
# .. toggle_warning: When the flag is ON, notification to all learners feature is enabled on discussion post.
60+
# .. toggle_tickets: INF-1917
61+
ENABLE_NOTIFY_ALL_LEARNERS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_post_notify_all_learners', __name__)

0 commit comments

Comments
 (0)