Skip to content

Commit 98dfb12

Browse files
feat: added unsubsribe url for email notifications (#34967)
1 parent 6945bfa commit 98dfb12

7 files changed

Lines changed: 426 additions & 10 deletions

File tree

openedx/core/djangoapps/notifications/email/tasks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ def send_digest_email_to_user(user, cadence_type, course_language='en', courses_
9292
logger.info(f'<Email Cadence> No filtered notification for {user.username} ==Temp Log==')
9393
return
9494
apps_dict = create_app_notifications_dict(notifications)
95-
message_context = create_email_digest_context(apps_dict, start_date, end_date, cadence_type,
96-
courses_data=courses_data)
95+
message_context = create_email_digest_context(apps_dict, user.username, start_date, end_date,
96+
cadence_type, courses_data=courses_data)
9797
recipient = Recipient(user.id, user.email)
9898
message = EmailNotificationMessageType(
9999
app_label="notifications", name="email_digest"

openedx/core/djangoapps/notifications/email/tests/test_utils.py

Lines changed: 240 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,32 @@
44
import datetime
55
import ddt
66

7+
from itertools import product
78
from pytz import utc
89
from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import
910

1011
from common.djangoapps.student.tests.factories import UserFactory
12+
from openedx.core.djangoapps.notifications.base_notification import (
13+
COURSE_NOTIFICATION_APPS,
14+
COURSE_NOTIFICATION_TYPES,
15+
)
1116
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_EMAIL_NOTIFICATIONS
12-
from openedx.core.djangoapps.notifications.models import Notification
17+
from openedx.core.djangoapps.notifications.email_notifications import EmailCadence
18+
from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, Notification
1319
from openedx.core.djangoapps.notifications.email.utils import (
1420
add_additional_attributes_to_notifications,
1521
create_app_notifications_dict,
1622
create_datetime_string,
1723
create_email_digest_context,
1824
create_email_template_context,
25+
decrypt_object,
26+
decrypt_string,
27+
encrypt_object,
28+
encrypt_string,
1929
get_course_info,
2030
get_time_ago,
2131
is_email_notification_flag_enabled,
32+
update_user_preferences_from_patch,
2233
)
2334
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
2435
from xmodule.modulestore.tests.factories import CourseFactory
@@ -102,8 +113,9 @@ def test_email_template_context(self):
102113
"""
103114
Tests common header and footer context
104115
"""
105-
context = create_email_template_context()
106-
keys = ['platform_name', 'mailing_address', 'logo_url', 'social_media', 'notification_settings_url']
116+
context = create_email_template_context(self.user.username)
117+
keys = ['platform_name', 'mailing_address', 'logo_url', 'social_media',
118+
'notification_settings_url', 'unsubscribe_url']
107119
for key in keys:
108120
assert key in context
109121

@@ -121,6 +133,7 @@ def test_email_digest_context(self, digest_frequency):
121133
end_date = datetime.datetime(2024, 3, 24, 12, 0)
122134
params = {
123135
"app_notifications_dict": app_dict,
136+
"username": self.user.username,
124137
"start_date": end_date - datetime.timedelta(days=0 if digest_frequency == "Daily" else 6),
125138
"end_date": end_date,
126139
"digest_frequency": digest_frequency,
@@ -194,3 +207,227 @@ def test_waffle_flag_everyone_priority(self):
194207
assert is_email_notification_flag_enabled() is False
195208
assert is_email_notification_flag_enabled(self.user_1) is False
196209
assert is_email_notification_flag_enabled(self.user_2) is False
210+
211+
212+
class TestEncryption(ModuleStoreTestCase):
213+
"""
214+
Tests all encryption methods
215+
"""
216+
def test_string_encryption(self):
217+
"""
218+
Tests if decrypted string is equal original string
219+
"""
220+
string = "edx"
221+
encrypted = encrypt_string(string)
222+
decrypted = decrypt_string(encrypted)
223+
assert string == decrypted
224+
225+
def test_object_encryption(self):
226+
"""
227+
Tests if decrypted object is equal to original object
228+
"""
229+
obj = {
230+
'org': 'edx'
231+
}
232+
encrypted = encrypt_object(obj)
233+
decrypted = decrypt_object(encrypted)
234+
assert obj == decrypted
235+
236+
237+
@ddt.ddt
238+
class TestUpdatePreferenceFromPatch(ModuleStoreTestCase):
239+
"""
240+
Tests if preferences are update according to patch data
241+
"""
242+
def setUp(self):
243+
"""
244+
Setup test cases
245+
"""
246+
super().setUp()
247+
self.user = UserFactory()
248+
self.course_1 = CourseFactory.create(display_name='test course 1', run="Testing_course_1")
249+
self.course_2 = CourseFactory.create(display_name='test course 2', run="Testing_course_2")
250+
self.preference_1 = CourseNotificationPreference(course_id=self.course_1.id, user=self.user)
251+
self.preference_2 = CourseNotificationPreference(course_id=self.course_2.id, user=self.user)
252+
self.preference_1.save()
253+
self.preference_2.save()
254+
self.default_json = self.preference_1.notification_preference_config
255+
256+
def is_channel_editable(self, app_name, notification_type, channel):
257+
"""
258+
Returns if channel is editable
259+
"""
260+
if notification_type == 'core':
261+
return channel not in COURSE_NOTIFICATION_APPS[app_name]['non_editable']
262+
return channel not in COURSE_NOTIFICATION_TYPES[notification_type]['non_editable']
263+
264+
def get_default_cadence_value(self, app_name, notification_type):
265+
"""
266+
Returns default email cadence value
267+
"""
268+
if notification_type == 'core':
269+
return COURSE_NOTIFICATION_APPS[app_name]['core_email_cadence']
270+
return COURSE_NOTIFICATION_TYPES[notification_type]['email_cadence']
271+
272+
@ddt.data(True, False)
273+
def test_value_param(self, new_value):
274+
"""
275+
Tests if value is updated for all notification types and for all channels
276+
"""
277+
encrypted_username = encrypt_string(self.user.username)
278+
encrypted_patch = encrypt_object({
279+
'value': new_value
280+
})
281+
update_user_preferences_from_patch(encrypted_username, encrypted_patch)
282+
preference_1 = CourseNotificationPreference.objects.get(course_id=self.course_1.id, user=self.user)
283+
preference_2 = CourseNotificationPreference.objects.get(course_id=self.course_2.id, user=self.user)
284+
for preference in [preference_1, preference_2]:
285+
config = preference.notification_preference_config
286+
for app_name, app_prefs in config.items():
287+
for noti_type, type_prefs in app_prefs['notification_types'].items():
288+
for channel in ['web', 'email', 'push']:
289+
if self.is_channel_editable(app_name, noti_type, channel):
290+
assert type_prefs[channel] == new_value
291+
else:
292+
default_app_json = self.default_json[app_name]
293+
default_notification_type_json = default_app_json['notification_types'][noti_type]
294+
assert type_prefs[channel] == default_notification_type_json[channel]
295+
296+
@ddt.data(*product(['web', 'email', 'push'], [True, False]))
297+
@ddt.unpack
298+
def test_value_with_channel_param(self, param_channel, new_value):
299+
"""
300+
Tests if value is updated only for channel
301+
"""
302+
encrypted_username = encrypt_string(self.user.username)
303+
encrypted_patch = encrypt_object({
304+
'channel': param_channel,
305+
'value': new_value
306+
})
307+
update_user_preferences_from_patch(encrypted_username, encrypted_patch)
308+
preference_1 = CourseNotificationPreference.objects.get(course_id=self.course_1.id, user=self.user)
309+
preference_2 = CourseNotificationPreference.objects.get(course_id=self.course_2.id, user=self.user)
310+
# pylint: disable=too-many-nested-blocks
311+
for preference in [preference_1, preference_2]:
312+
config = preference.notification_preference_config
313+
for app_name, app_prefs in config.items():
314+
for noti_type, type_prefs in app_prefs['notification_types'].items():
315+
for channel in ['web', 'email', 'push']:
316+
if not self.is_channel_editable(app_name, noti_type, channel):
317+
continue
318+
if channel == param_channel:
319+
assert type_prefs[channel] == new_value
320+
if channel == 'email':
321+
cadence_value = EmailCadence.NEVER
322+
if new_value:
323+
cadence_value = self.get_default_cadence_value(app_name, noti_type)
324+
assert type_prefs['email_cadence'] == cadence_value
325+
else:
326+
default_app_json = self.default_json[app_name]
327+
default_notification_type_json = default_app_json['notification_types'][noti_type]
328+
assert type_prefs[channel] == default_notification_type_json[channel]
329+
330+
@ddt.data(True, False)
331+
def test_value_with_course_id_param(self, new_value):
332+
"""
333+
Tests if value is updated for a single course only
334+
"""
335+
encrypted_username = encrypt_string(self.user.username)
336+
encrypted_patch = encrypt_object({
337+
'value': new_value,
338+
'course_id': str(self.course_1.id),
339+
})
340+
update_user_preferences_from_patch(encrypted_username, encrypted_patch)
341+
342+
preference_2 = CourseNotificationPreference.objects.get(course_id=self.course_2.id, user=self.user)
343+
self.assertDictEqual(preference_2.notification_preference_config, self.default_json)
344+
345+
preference_1 = CourseNotificationPreference.objects.get(course_id=self.course_1.id, user=self.user)
346+
config = preference_1.notification_preference_config
347+
for app_name, app_prefs in config.items():
348+
for noti_type, type_prefs in app_prefs['notification_types'].items():
349+
for channel in ['web', 'email', 'push']:
350+
if self.is_channel_editable(app_name, noti_type, channel):
351+
assert type_prefs[channel] == new_value
352+
else:
353+
default_app_json = self.default_json[app_name]
354+
default_notification_type_json = default_app_json['notification_types'][noti_type]
355+
assert type_prefs[channel] == default_notification_type_json[channel]
356+
357+
@ddt.data(*product(['discussion', 'updates'], [True, False]))
358+
@ddt.unpack
359+
def test_value_with_app_name_param(self, param_app_name, new_value):
360+
"""
361+
Tests if value is updated only for channel
362+
"""
363+
encrypted_username = encrypt_string(self.user.username)
364+
encrypted_patch = encrypt_object({
365+
'app_name': param_app_name,
366+
'value': new_value
367+
})
368+
update_user_preferences_from_patch(encrypted_username, encrypted_patch)
369+
preference_1 = CourseNotificationPreference.objects.get(course_id=self.course_1.id, user=self.user)
370+
preference_2 = CourseNotificationPreference.objects.get(course_id=self.course_2.id, user=self.user)
371+
# pylint: disable=too-many-nested-blocks
372+
for preference in [preference_1, preference_2]:
373+
config = preference.notification_preference_config
374+
for app_name, app_prefs in config.items():
375+
for noti_type, type_prefs in app_prefs['notification_types'].items():
376+
for channel in ['web', 'email', 'push']:
377+
if not self.is_channel_editable(app_name, noti_type, channel):
378+
continue
379+
if app_name == param_app_name:
380+
assert type_prefs[channel] == new_value
381+
if channel == 'email':
382+
cadence_value = EmailCadence.NEVER
383+
if new_value:
384+
cadence_value = self.get_default_cadence_value(app_name, noti_type)
385+
assert type_prefs['email_cadence'] == cadence_value
386+
else:
387+
default_app_json = self.default_json[app_name]
388+
default_notification_type_json = default_app_json['notification_types'][noti_type]
389+
assert type_prefs[channel] == default_notification_type_json[channel]
390+
391+
@ddt.data(*product(['new_discussion_post', 'content_reported'], [True, False]))
392+
@ddt.unpack
393+
def test_value_with_notification_type_param(self, param_notification_type, new_value):
394+
"""
395+
Tests if value is updated only for channel
396+
"""
397+
encrypted_username = encrypt_string(self.user.username)
398+
encrypted_patch = encrypt_object({
399+
'notification_type': param_notification_type,
400+
'value': new_value
401+
})
402+
update_user_preferences_from_patch(encrypted_username, encrypted_patch)
403+
preference_1 = CourseNotificationPreference.objects.get(course_id=self.course_1.id, user=self.user)
404+
preference_2 = CourseNotificationPreference.objects.get(course_id=self.course_2.id, user=self.user)
405+
# pylint: disable=too-many-nested-blocks
406+
for preference in [preference_1, preference_2]:
407+
config = preference.notification_preference_config
408+
for app_name, app_prefs in config.items():
409+
for noti_type, type_prefs in app_prefs['notification_types'].items():
410+
for channel in ['web', 'email', 'push']:
411+
if not self.is_channel_editable(app_name, noti_type, channel):
412+
continue
413+
if noti_type == param_notification_type:
414+
assert type_prefs[channel] == new_value
415+
if channel == 'email':
416+
cadence_value = EmailCadence.NEVER
417+
if new_value:
418+
cadence_value = self.get_default_cadence_value(app_name, noti_type)
419+
assert type_prefs['email_cadence'] == cadence_value
420+
else:
421+
default_app_json = self.default_json[app_name]
422+
default_notification_type_json = default_app_json['notification_types'][noti_type]
423+
assert type_prefs[channel] == default_notification_type_json[channel]
424+
425+
def test_preference_not_updated_if_invalid_username(self):
426+
"""
427+
Tests if no preference is updated when username is not valid
428+
"""
429+
username = f"{self.user.username}-updated"
430+
enc_username = encrypt_string(username)
431+
enc_patch = encrypt_object({"value": True})
432+
with self.assertNumQueries(1):
433+
update_user_preferences_from_patch(enc_username, enc_patch)

0 commit comments

Comments
 (0)