feat: make notification emails translatable (#36775)

* feat: make notification emails translatable

* fix: fixed failing tests

* fix: fixed xss quality check
This commit is contained in:
Muhammad Adeel Tajamul
2025-05-26 13:44:01 +05:00
committed by GitHub
parent 76f872cc1e
commit 6a67719592
14 changed files with 144 additions and 64 deletions

View File

@@ -5,6 +5,7 @@ from bs4 import BeautifulSoup
from celery import shared_task
from celery.utils.log import get_task_logger
from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _, override as translation_override
from edx_ace import ace
from edx_ace.recipient import Recipient
from edx_django_utils.monitoring import set_code_owner_attribute
@@ -24,10 +25,11 @@ from .utils import (
create_email_template_context,
filter_notification_with_email_enabled_preferences,
get_course_info,
get_language_preference_for_users,
get_start_end_date,
get_text_for_notification_type,
get_unique_course_ids,
is_email_notification_flag_enabled
is_email_notification_flag_enabled,
)
@@ -74,7 +76,7 @@ def get_user_preferences_for_courses(course_ids, user):
return new_preferences
def send_digest_email_to_user(user, cadence_type, start_date, end_date, course_language='en', courses_data=None):
def send_digest_email_to_user(user, cadence_type, start_date, end_date, user_language='en', courses_data=None):
"""
Send [cadence_type] email to user.
Cadence Type can be EmailCadence.DAILY or EmailCadence.WEEKLY
@@ -95,24 +97,26 @@ def send_digest_email_to_user(user, cadence_type, start_date, end_date, course_l
if not notifications:
logger.info(f'<Email Cadence> No notification for {user.username} ==Temp Log==')
return
course_ids = get_unique_course_ids(notifications)
preferences = get_user_preferences_for_courses(course_ids, user)
notifications = filter_notification_with_email_enabled_preferences(notifications, preferences, cadence_type)
if not notifications:
logger.info(f'<Email Cadence> No filtered notification for {user.username} ==Temp Log==')
return
apps_dict = create_app_notifications_dict(notifications)
message_context = create_email_digest_context(apps_dict, user.username, start_date, end_date,
cadence_type, courses_data=courses_data)
recipient = Recipient(user.id, user.email)
message = EmailNotificationMessageType(
app_label="notifications", name="email_digest"
).personalize(recipient, course_language, message_context)
message = add_headers_to_email_message(message, message_context)
message.options['skip_disable_user_policy'] = True
ace.send(message)
send_user_email_digest_sent_event(user, cadence_type, notifications, message_context)
logger.info(f'<Email Cadence> Email sent to {user.username} ==Temp Log==')
with translation_override(user_language):
course_ids = get_unique_course_ids(notifications)
preferences = get_user_preferences_for_courses(course_ids, user)
notifications = filter_notification_with_email_enabled_preferences(notifications, preferences, cadence_type)
if not notifications:
logger.info(f'<Email Cadence> No filtered notification for {user.username} ==Temp Log==')
return
apps_dict = create_app_notifications_dict(notifications)
message_context = create_email_digest_context(apps_dict, user.username, start_date, end_date,
cadence_type, courses_data=courses_data)
recipient = Recipient(user.id, user.email)
message = EmailNotificationMessageType(
app_label="notifications", name="email_digest"
).personalize(recipient, user_language, message_context)
message = add_headers_to_email_message(message, message_context)
message.options['skip_disable_user_policy'] = True
ace.send(message)
send_user_email_digest_sent_event(user, cadence_type, notifications, message_context)
logger.info(f'<Email Cadence> Email sent to {user.username} ==Temp Log==')
@shared_task(ignore_result=True)
@@ -123,11 +127,14 @@ def send_digest_email_to_all_users(cadence_type):
"""
logger.info(f'<Email Cadence> Sending cadence email of type {cadence_type}')
users = get_audience_for_cadence_email(cadence_type)
language_prefs = get_language_preference_for_users([user.id for user in users])
courses_data = {}
start_date, end_date = get_start_end_date(cadence_type)
logger.info(f'<Email Cadence> Email Cadence Audience {len(users)}')
for user in users:
send_digest_email_to_user(user, cadence_type, start_date, end_date, courses_data=courses_data)
user_language = language_prefs.get(user.id, 'en')
send_digest_email_to_user(user, cadence_type, start_date, end_date, user_language=user_language,
courses_data=courses_data)
def send_immediate_cadence_email(email_notification_mapping, course_key):
@@ -141,6 +148,7 @@ def send_immediate_cadence_email(email_notification_mapping, course_key):
return
user_list = email_notification_mapping.keys()
users = User.objects.filter(id__in=user_list)
language_prefs = get_language_preference_for_users(user_list)
course_name = get_course_info(course_key).get("name", course_key)
for user in users.iterator(chunk_size=100):
if not user.has_usable_password():
@@ -153,21 +161,25 @@ def send_immediate_cadence_email(email_notification_mapping, course_key):
if not notification:
logger.info(f'<Immediate Email> No notification for {user.username}')
continue
soup = BeautifulSoup(notification.content, "html.parser")
title = "New Course Update" if notification.notification_type == "course_updates" else soup.get_text()
message_context = create_email_template_context(user.username)
message_context.update({
"course_id": course_key,
"course_name": course_name,
"content_url": notification.content_url,
"content_title": title,
"footer_email_reason": "You are receiving this email because you are enrolled in the edX course "
f"{course_name}",
"content": notification.content_context.get("email_content", notification.content),
"view_text": get_text_for_notification_type(notification.notification_type),
})
message = EmailNotificationMessageType(
app_label="notifications", name="immediate_email"
).personalize(Recipient(user.id, user.email), 'en', message_context)
message = add_headers_to_email_message(message, message_context)
ace.send(message)
language = language_prefs.get(user.id, 'en')
with translation_override(language):
soup = BeautifulSoup(notification.content, "html.parser")
title = _("New Course Update") if notification.notification_type == "course_updates" else soup.get_text()
message_context = create_email_template_context(user.username)
message_context.update({
"course_id": course_key,
"course_name": course_name,
"content_url": notification.content_url,
"content_title": title,
"footer_email_reason": _(
"You are receiving this email because you are enrolled in the edX course "
) + str(course_name),
"content": notification.content_context.get("email_content", notification.content),
"view_text": get_text_for_notification_type(notification.notification_type),
})
message = EmailNotificationMessageType(
app_label="notifications", name="immediate_email"
).personalize(Recipient(user.id, user.email), language, message_context)
message = add_headers_to_email_message(message, message_context)
ace.send(message)

View File

@@ -147,19 +147,21 @@ class TestContextFunctions(ModuleStoreTestCase):
context = create_email_digest_context(**params)
expected_start_date = 'Sunday, Mar 24' if digest_frequency == 'Daily' else 'Monday, Mar 18'
expected_digest_updates = [
{'title': 'Total Notifications', 'count': 2},
{'title': 'Discussion', 'count': 1},
{'title': 'Updates', 'count': 1},
{'title': 'Total Notifications', 'translated_title': 'Total Notifications', 'count': 2},
{'title': 'Discussion', 'translated_title': 'Discussion', 'count': 1},
{'title': 'Updates', 'translated_title': 'Updates', 'count': 1},
]
expected_email_content = [
{
'title': 'Discussion', 'help_text': '', 'help_text_url': '',
'translated_title': 'Discussion',
'notifications': [discussion_notification],
'total': 1, 'show_remaining_count': False, 'remaining_count': 0,
'url': 'http://learner-home-mfe/?showNotifications=true&app=discussion'
},
{
'title': 'Updates', 'help_text': '', 'help_text_url': '',
'translated_title': 'Updates',
'notifications': [update_notification],
'total': 1, 'show_remaining_count': False, 'remaining_count': 0,
'url': 'http://learner-home-mfe/?showNotifications=true&app=updates'

View File

@@ -8,12 +8,14 @@ from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext as _
from pytz import utc
from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import
from common.djangoapps.student.models import CourseEnrollment
from lms.djangoapps.branding.api import get_logo_url_for_email
from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_EMAIL_NOTIFICATIONS
from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY
@@ -28,6 +30,7 @@ from xmodule.modulestore.django import modulestore
from .notification_icons import NotificationTypeIcons
User = get_user_model()
@@ -119,12 +122,14 @@ def create_email_digest_context(app_notifications_dict, username, start_date, en
end_date_str = create_datetime_string(end_date if end_date else start_date)
email_digest_updates = [{
'title': 'Total Notifications',
'translated_title': _('Total Notifications'),
'count': sum(value['count'] for value in app_notifications_dict.values())
}]
email_digest_updates.extend([
{
'title': value['title'],
'count': value['count'],
'translated_title': value.get('translated_title', value['title']),
}
for key, value in app_notifications_dict.items()
])
@@ -135,6 +140,7 @@ def create_email_digest_context(app_notifications_dict, username, start_date, en
total = value['count']
app_content = {
'title': value['title'],
'translated_title': value.get('translated_title', value['title']),
'help_text': value.get('help_text', ''),
'help_text_url': value.get('help_text_url', ''),
'notifications': add_additional_attributes_to_notifications(
@@ -200,7 +206,7 @@ def get_time_ago(datetime_obj):
current_date = utc.localize(datetime.datetime.today())
days_diff = (current_date - datetime_obj).days
if days_diff == 0:
return "Today"
return _("Today")
if days_diff >= 7:
return f"{int(days_diff / 7)}w"
return f"{days_diff}d"
@@ -252,6 +258,7 @@ def create_app_notifications_dict(notifications):
name: {
'count': 0,
'title': name.title(),
'translated_title': get_translated_app_title(name),
'notifications': []
}
for name in app_names
@@ -433,16 +440,36 @@ def is_notification_type_channel_editable(app_name, notification_type, channel):
return channel not in COURSE_NOTIFICATION_TYPES[notification_type]['non_editable']
def get_translated_app_title(name):
"""
Returns translated string from notification app_name key
"""
mapping = {
'discussion': _('Discussion'),
'updates': _('Updates'),
'grading': _('Grading'),
}
return mapping.get(name, '')
def get_language_preference_for_users(user_ids):
"""
Returns mapping of user_id and language preference for users
"""
prefs = UserPreference.get_preference_for_users(user_ids, LANGUAGE_KEY)
return {pref.user_id: pref.value for pref in prefs}
def get_text_for_notification_type(notification_type):
"""
Returns text for notification type
"""
app_name = COURSE_NOTIFICATION_APPS.get(notification_type, {}).get('notification_app')
app_name = COURSE_NOTIFICATION_TYPES.get(notification_type, {}).get('notification_app')
if not app_name:
return ""
mapping = {
'discussion': 'post',
'updates': 'update',
'grading': 'assessment',
'discussion': _('post'),
'updates': _('update'),
'grading': _('assessment'),
}
return mapping.get(app_name, "")

View File

@@ -1,6 +1,7 @@
{% load i18n %}
{% for notification_app in email_content %}
<h3 style="font-size: 22px; font-weight:700; line-height:28px; margin: 0.75rem 0 0;">
{{ notification_app.title }}
{{ notification_app.translated_title }}
</h3>
{% if notification_app.help_text %}
<p style="margin: 0; height: 1.5rem; font-weight: 400; font-size: 14px; line-height: 24px">
@@ -10,7 +11,7 @@
{% if notification_app.help_text_url %}
<span style="float:right; margin-right: 0.25rem">
<a href="{{notification_app.help_text_url}}" style="text-decoration: none; color: #00688D">
View all
{% trans "View all" as tmsg %}{{ tmsg | force_escape }}
</a>
</span>
{% endif %}
@@ -46,7 +47,7 @@
</span>
<span style="float: right">
<a href="{{notification.content_url}}" style="text-decoration: none; color: #00688D">
View
{% trans "View" as tmsg %}{{ tmsg | force_escape }}
</a>
</span>
</blockquote>
@@ -60,7 +61,7 @@
{% if notification_app.show_remaining_count %}
<p style="margin: 0; height: 0.75rem; font-weight: 400; font-size: 14px; line-height: 24px;">
<a href="{{notification_app.url}}" style="color: #00688d; margin: 0; float:right; text-decoration: none;">
+ {{ notification_app.remaining_count }} more
+ {{ notification_app.remaining_count }} {% trans "more" as tmsg %}{{ tmsg | force_escape }}
</a>
</p>
{% endif %}

View File

@@ -1,3 +1,4 @@
{% load i18n %}
<table cellpadding="0" cellspacing="0" style="color:black; font-weight:400; font-size:12px;line-height:20px" width="100%">
<tbody>
<tr>
@@ -31,18 +32,22 @@
<tr>
<td>
<p style="margin: 1.5rem 0 0 0;">
{{ footer_email_reason|default:"You are receiving this email because you have subscribed to email digest" }}
{% if footer_email_reason %}
{{ footer_email_reason }}
{% else %}
{% trans "You are receiving this email because you have subscribed to email digest" as tmsg %}{{ tmsg | force_escape }}
{% endif %}
</p>
<p style="margin: 0.625rem 0">
<a href="{{notification_settings_url}}" rel="noopener noreferrer" target="_blank" style="color: black">
Notification Settings
{% trans "Notification Settings" as tmsg %}{{ tmsg | force_escape }}
</a>
<a href="{{unsubscribe_url}}" rel="noopener noreferrer" target="_blank" style="color: black; margin-left: 1rem">
Unsubscribe from email digest for learning activity
{% trans "Unsubscribe from email digest for learning activity" as tmsg %}{{ tmsg | force_escape }}
</a>
</p>
<p>
&copy; {% now "Y" %} {{ platform_name }}. All Rights Reserved <br/>
&copy; {% now "Y" %} {{ platform_name }}. {% trans "All rights reserved" as tmsg %}{{ tmsg | force_escape }} <br/>
{{ mailing_address }}
</p>
</td>

View File

@@ -1,3 +1,4 @@
{% load i18n %}
<table
border="0"
cellpadding="0"
@@ -8,7 +9,7 @@
<tr align="right">
<td>
<a href="{{unsubscribe_url}}" rel="noopener noreferrer" target="_blank" style="color: white; text-decoration: none; font-size: 12px; line-height: 10px">
Unsubscribe
{% trans "Unsubscribe" as tmsg %}{{ tmsg | force_escape }}
</a>
</td>
</tr>
@@ -20,7 +21,11 @@
<tr style="height: 20px"></tr>
<tr align="center">
<td style="font-family: Inter, Arial, Verdana, sans-serif; font-size: 32px; font-style: normal; font-weight: 700; line-height: 36px">
{{ digest_frequency }} email digest
{% if digest_frequency == "Weekly" %}
{% trans "Weekly email digest" as tmsg %}{{ tmsg | force_escape }}
{% else %}
{% trans "Daily email digest" as tmsg %}{{ tmsg | force_escape }}
{% endif %}
</td>
</tr>
<tr style="height: 10px"></tr>
@@ -55,7 +60,7 @@
</tr>
<tr align="center">
<td style="font-weight: 600; font-size: 14px; line-height: 20px; padding: 0">
{{update.title}}
{{update.translated_title}}
</td>
</tr>
</tbody>

View File

@@ -1,3 +1,4 @@
{% load i18n %}
<head>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap'" rel="stylesheet" type="text/css">
</head>

View File

@@ -1 +1,7 @@
{{ digest_frequency }} Notifications Digest for {% if digest_frequency == "Weekly" %}the Week of {% endif %}{{ start_date }}
{% load i18n %}
{% if digest_frequency == "Weekly" %}
{% trans "Weekly Notifications Digest for the Week of" %} {{ start_date }}
{% else %}
{% trans "Daily Notifications Digest for" %} {{ start_date }}
{% endif %}

View File

@@ -1,3 +1,5 @@
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title lang="en">{{ platform_name }}</title>
<title lang="{{ LANGUAGE_CODE|default:'en' }}">{{ platform_name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>

View File

@@ -1 +1,7 @@
{{ digest_frequency }} Notifications Digest for {% if digest_frequency == "Weekly" %}the Week of {% endif %}{{ start_date }}
{% load i18n %}
{% if digest_frequency == "Weekly" %}
{% trans "Weekly Notifications Digest for the Week of" %} {{ start_date }}
{% else %}
{% trans "Daily Notifications Digest for" %} {{ start_date }}
{% endif %}

View File

@@ -1,3 +1,4 @@
{% load i18n %}
<head>
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap'" rel="stylesheet" type="text/css">
</head>

View File

@@ -1,3 +1,5 @@
{% load i18n %}
{% get_current_language as LANGUAGE_CODE %}
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title lang="en">{{ platform_name }}</title>
<title lang="{{ LANGUAGE_CODE|default:'en' }}">{{ platform_name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>

View File

@@ -1,9 +1,12 @@
{% load i18n %}
<blockquote style="margin: 0; height: 64px; padding: 24px 24px 8px">
<span style="float: left">
<img src="{{ logo_url }}" style="width: auto;" height="40" alt="Logo"/>
</span>
<span style="float: right">
<a href="{{ unsubscribe_url }}" style="color: #00262B; text-decoration: none;">Unsubscribe</a>
<a href="{{ unsubscribe_url }}" style="color: #00262B; text-decoration: none;">
{% trans "Unsubscribe" as tmsg %}{{ tmsg | force_escape }}
</a>
</span>
</blockquote>
<blockquote style="margin: 0; padding: 24px;">
@@ -21,7 +24,7 @@
<blockquote style="margin: 0; padding: 10px 0">
<p style="margin: 0; padding: 10px 16px; background-color: #D63328; width: fit-content;">
<a href="{{ content_url }}" style="font-weight: 500; font-size: 18px; line-height: 24px; color: #FFFFFF; text-decoration: none">
View {{ view_text|default:""}}
{% trans "View" as tmsg %}{{ tmsg | force_escape }} {{ view_text|default:""}}
</a>
</p>
</blockquote>

View File

@@ -105,6 +105,13 @@ class UserPreference(models.Model):
"""
return cls.objects.filter(user=user, key=preference_key).exists()
@classmethod
def get_preference_for_users(cls, user_ids, preference_key):
"""
Returns preference for list of users
"""
return cls.objects.filter(user__in=user_ids, key=preference_key)
@receiver(pre_save, sender=UserPreference)
def pre_save_callback(sender, **kwargs):