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:
committed by
GitHub
parent
76f872cc1e
commit
6a67719592
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
© {% now "Y" %} {{ platform_name }}. All Rights Reserved <br/>
|
||||
© {% now "Y" %} {{ platform_name }}. {% trans "All rights reserved" as tmsg %}{{ tmsg | force_escape }} <br/>
|
||||
{{ mailing_address }}
|
||||
</p>
|
||||
</td>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user