mirror of
https://github.com/inventree/InvenTree.git
synced 2026-10-02 00:20:44 +00:00
fix(notifications): translate notification emails and messages into recipient user locale (#11408) (#12944)
* fix(notifications): translate notification emails and messages into recipient user locale (#11408) * add a codespell ignore for the translated assertation --------- Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
1 parent
7642d409b6
commit
65349eeefb
3 files changed
+284
-18
No files matched your search
@@ -84,6 +84,39 @@ class InvenTreeNotificationBodies:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_language(user) -> str:
|
||||||
|
"""Return the preferred language code for a user, or default system language."""
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return getattr(settings, 'LANGUAGE_CODE', 'en-us')
|
||||||
|
|
||||||
|
# 1. Try user.profile.language (UserProfile model relation)
|
||||||
|
if profile := getattr(user, 'profile', None):
|
||||||
|
if lang := getattr(profile, 'language', None):
|
||||||
|
return lang
|
||||||
|
|
||||||
|
# 2. Try user.user_profile.language (alias if present)
|
||||||
|
if profile := getattr(user, 'user_profile', None):
|
||||||
|
if lang := getattr(profile, 'language', None):
|
||||||
|
return lang
|
||||||
|
|
||||||
|
# 3. Try InvenTreeUserSetting for 'LANGUAGE'
|
||||||
|
try:
|
||||||
|
from common.models import InvenTreeUserSetting
|
||||||
|
|
||||||
|
lang = InvenTreeUserSetting.get_setting(
|
||||||
|
'LANGUAGE', user=user, backup_value=None
|
||||||
|
)
|
||||||
|
if lang:
|
||||||
|
return lang
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 4. Fallback to settings.LANGUAGE_CODE
|
||||||
|
return getattr(settings, 'LANGUAGE_CODE', 'en-us')
|
||||||
|
|
||||||
|
|
||||||
def trigger_notification(obj: Model, category: str = '', obj_ref: str = 'pk', **kwargs):
|
def trigger_notification(obj: Model, category: str = '', obj_ref: str = 'pk', **kwargs):
|
||||||
"""Send out a notification.
|
"""Send out a notification.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Unit tests for notification locale translations (Issue #11408)."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from common.models import InvenTreeUserSetting, NotificationMessage
|
||||||
|
from common.notifications import get_user_language
|
||||||
|
from InvenTree.unit_test import InvenTreeTestCase
|
||||||
|
from plugin.builtin.integration.core_notifications import (
|
||||||
|
InvenTreeEmailNotifications,
|
||||||
|
InvenTreeUINotifications,
|
||||||
|
)
|
||||||
|
from plugin.models import PluginConfig
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationLocaleTest(InvenTreeTestCase):
|
||||||
|
"""Unit tests for notification locale translations."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
"""Set up test data for notification locale testing."""
|
||||||
|
super().setUpTestData()
|
||||||
|
|
||||||
|
# Ensure the email plugin config exists and is active
|
||||||
|
PluginConfig.objects.get_or_create(
|
||||||
|
key='inventree-email-notification',
|
||||||
|
defaults={'name': 'InvenTreeEmailNotifications', 'active': True},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create English user
|
||||||
|
cls.user_en = User.objects.create_user(
|
||||||
|
username='user_en', email='user.en@example.com', password='password123'
|
||||||
|
)
|
||||||
|
cls.user_en.profile.language = 'en'
|
||||||
|
cls.user_en.profile.save()
|
||||||
|
|
||||||
|
# Create Italian user
|
||||||
|
cls.user_it = User.objects.create_user(
|
||||||
|
username='user_it', email='user.it@example.com', password='password123'
|
||||||
|
)
|
||||||
|
cls.user_it.profile.language = 'it'
|
||||||
|
cls.user_it.profile.save()
|
||||||
|
|
||||||
|
# Create another Italian user for batching tests
|
||||||
|
cls.user_it2 = User.objects.create_user(
|
||||||
|
username='user_it2', email='user.it2@example.com', password='password123'
|
||||||
|
)
|
||||||
|
cls.user_it2.profile.language = 'it'
|
||||||
|
cls.user_it2.profile.save()
|
||||||
|
|
||||||
|
def test_get_user_language(self):
|
||||||
|
"""Test resolving user language from profile, settings, and defaults."""
|
||||||
|
self.assertEqual(get_user_language(self.user_en), 'en')
|
||||||
|
self.assertEqual(get_user_language(self.user_it), 'it')
|
||||||
|
|
||||||
|
# Test user without language set falls back to system default
|
||||||
|
user_no_lang = User.objects.create_user(
|
||||||
|
username='user_no_lang', email='no.lang@example.com', password='password123'
|
||||||
|
)
|
||||||
|
user_no_lang.profile.language = None
|
||||||
|
user_no_lang.profile.save()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
get_user_language(user_no_lang), getattr(settings, 'LANGUAGE_CODE', 'en-us')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test user with InvenTreeUserSetting LANGUAGE override
|
||||||
|
with patch.object(InvenTreeUserSetting, 'get_setting', return_value='de'):
|
||||||
|
self.assertEqual(get_user_language(user_no_lang), 'de')
|
||||||
|
|
||||||
|
@patch('InvenTree.helpers_email.send_email')
|
||||||
|
def test_email_notifications_locale_translation(self, mock_send_email):
|
||||||
|
"""Test that notification emails are translated according to recipient user locale."""
|
||||||
|
email_plugin = InvenTreeEmailNotifications()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'name': _('Items Received'),
|
||||||
|
'message': _('Items have been received against a purchase order'),
|
||||||
|
'template': {
|
||||||
|
'html': 'email/purchase_order_received.html',
|
||||||
|
'subject': _('Items Received'),
|
||||||
|
},
|
||||||
|
'link': 'http://localhost:8000/order/purchase-order/1/',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send notification to both English and Italian recipients
|
||||||
|
result = email_plugin.send_notification(
|
||||||
|
None, 'test', [self.user_en, self.user_it], context
|
||||||
|
)
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
# Expect two distinct email dispatch calls (one per locale group)
|
||||||
|
self.assertEqual(mock_send_email.call_count, 2)
|
||||||
|
|
||||||
|
calls = mock_send_email.call_args_list
|
||||||
|
|
||||||
|
# Map dispatched emails by recipient email
|
||||||
|
dispatched_by_recipient = {}
|
||||||
|
for call in calls:
|
||||||
|
args, kwargs = call
|
||||||
|
subject = args[0]
|
||||||
|
recipients = args[2]
|
||||||
|
html_message = kwargs.get('html_message', '')
|
||||||
|
for r in recipients:
|
||||||
|
dispatched_by_recipient[r] = (subject, html_message)
|
||||||
|
|
||||||
|
# Assert English recipient received English text
|
||||||
|
self.assertIn(self.user_en.email, dispatched_by_recipient)
|
||||||
|
subject_en, html_en = dispatched_by_recipient[self.user_en.email]
|
||||||
|
self.assertIn('Items Received', subject_en)
|
||||||
|
self.assertIn('Items have been received against a purchase order', html_en)
|
||||||
|
self.assertIn('Click on the following link to view this order', html_en)
|
||||||
|
|
||||||
|
# Assert Italian recipient received translated Italian text
|
||||||
|
self.assertIn(self.user_it.email, dispatched_by_recipient)
|
||||||
|
subject_it, html_it = dispatched_by_recipient[self.user_it.email]
|
||||||
|
self.assertIn('Elemento ricevuto', subject_it)
|
||||||
|
self.assertIn(
|
||||||
|
'Gli elementi sono stati ricevuti a fronte di un ordine di acquisto', # codespell:ignore
|
||||||
|
html_it,
|
||||||
|
)
|
||||||
|
self.assertIn("Clicca il seguente link per visualizzare quest'ordine", html_it)
|
||||||
|
|
||||||
|
@patch('InvenTree.helpers_email.send_email')
|
||||||
|
def test_email_notifications_single_locale_batching(self, mock_send_email):
|
||||||
|
"""Test that recipients sharing the same locale are batched into a single email."""
|
||||||
|
email_plugin = InvenTreeEmailNotifications()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'template': {
|
||||||
|
'html': 'email/purchase_order_received.html',
|
||||||
|
'subject': _('Items Received'),
|
||||||
|
},
|
||||||
|
'message': _('Items have been received against a purchase order'),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = email_plugin.send_notification(
|
||||||
|
None, 'test', [self.user_it, self.user_it2], context
|
||||||
|
)
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
# Since both users are Italian, only a single email should be sent
|
||||||
|
self.assertEqual(mock_send_email.call_count, 1)
|
||||||
|
|
||||||
|
args, _kwargs = mock_send_email.call_args
|
||||||
|
recipients = args[2]
|
||||||
|
self.assertEqual(len(recipients), 2)
|
||||||
|
self.assertIn(self.user_it.email, recipients)
|
||||||
|
self.assertIn(self.user_it2.email, recipients)
|
||||||
|
|
||||||
|
def test_ui_notifications_locale_translation(self):
|
||||||
|
"""Test that UI notification messages are saved with user-specific translations."""
|
||||||
|
ui_plugin = InvenTreeUINotifications()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'name': _('Items Received'),
|
||||||
|
'message': _('Items have been received against a purchase order'),
|
||||||
|
'link': 'http://localhost:8000/order/purchase-order/1/',
|
||||||
|
}
|
||||||
|
|
||||||
|
result = ui_plugin.send_notification(
|
||||||
|
self.user_en, 'test', [self.user_en, self.user_it], context
|
||||||
|
)
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
# Verify English UI notification
|
||||||
|
msg_en = NotificationMessage.objects.filter(
|
||||||
|
user=self.user_en, category='test'
|
||||||
|
).latest('pk')
|
||||||
|
self.assertEqual(msg_en.name, 'Items Received')
|
||||||
|
self.assertEqual(
|
||||||
|
msg_en.message, 'Items have been received against a purchase order'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify Italian UI notification
|
||||||
|
msg_it = NotificationMessage.objects.filter(
|
||||||
|
user=self.user_it, category='test'
|
||||||
|
).latest('pk')
|
||||||
|
self.assertEqual(msg_it.name, 'Elemento ricevuto')
|
||||||
|
self.assertEqual(
|
||||||
|
msg_it.message,
|
||||||
|
'Gli elementi sono stati ricevuti a fronte di un ordine di acquisto', # codespell:ignore
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_email_notifications_no_recipients(self):
|
||||||
|
"""Test that email notification returns False when no valid recipients exist."""
|
||||||
|
email_plugin = InvenTreeEmailNotifications()
|
||||||
|
|
||||||
|
# No template in context
|
||||||
|
self.assertFalse(
|
||||||
|
email_plugin.send_notification(None, 'test', [self.user_en], {})
|
||||||
|
)
|
||||||
|
|
||||||
|
# Empty users list
|
||||||
|
self.assertFalse(
|
||||||
|
email_plugin.send_notification(
|
||||||
|
None, 'test', [], {'template': {'html': 'email/test_email.html'}}
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
"""Core set of Notifications as a Plugin."""
|
"""Core set of Notifications as a Plugin."""
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import Model
|
from django.db.models import Model
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.translation import gettext, override
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
import InvenTree.helpers_email
|
import InvenTree.helpers_email
|
||||||
|
from common.notifications import get_user_language
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
from plugin import InvenTreePlugin
|
from plugin import InvenTreePlugin
|
||||||
from plugin.mixins import NotificationMixin, SettingsMixin
|
from plugin.mixins import NotificationMixin, SettingsMixin
|
||||||
@@ -43,16 +47,26 @@ class InvenTreeUINotifications(NotificationMixin, InvenTreePlugin):
|
|||||||
if not target:
|
if not target:
|
||||||
target = self.plugin_config()
|
target = self.plugin_config()
|
||||||
|
|
||||||
# Bulk create notification messages for all provided users
|
# Bulk create notification messages for all provided users in their respective language context
|
||||||
for user in users:
|
for user in users:
|
||||||
|
user_language = get_user_language(user)
|
||||||
|
with override(user_language):
|
||||||
|
name = ctx.get('name')
|
||||||
|
if name is not None:
|
||||||
|
name = gettext(str(name))
|
||||||
|
|
||||||
|
message = ctx.get('message')
|
||||||
|
if message is not None:
|
||||||
|
message = gettext(str(message))
|
||||||
|
|
||||||
entries.append(
|
entries.append(
|
||||||
NotificationMessage(
|
NotificationMessage(
|
||||||
target_object=target,
|
target_object=target,
|
||||||
source_object=user,
|
source_object=user,
|
||||||
user=user,
|
user=user,
|
||||||
category=category,
|
category=category,
|
||||||
name=ctx.get('name'),
|
name=name,
|
||||||
message=ctx.get('message'),
|
message=message,
|
||||||
link=ctx.get('link'),
|
link=ctx.get('link'),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -89,16 +103,8 @@ class InvenTreeEmailNotifications(NotificationMixin, SettingsMixin, InvenTreePlu
|
|||||||
if not context.get('template'):
|
if not context.get('template'):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
html_message = render_to_string(context['template']['html'], context)
|
# Group target users by their preferred language
|
||||||
|
language_groups: dict[str, list[str]] = defaultdict(list)
|
||||||
# Prefix the 'instance title' to the email subject
|
|
||||||
instance_title = get_global_setting('INVENTREE_INSTANCE')
|
|
||||||
subject = context['template'].get('subject', '')
|
|
||||||
|
|
||||||
if instance_title:
|
|
||||||
subject = f'[{instance_title}] {subject}'
|
|
||||||
|
|
||||||
recipients = []
|
|
||||||
|
|
||||||
for user in users:
|
for user in users:
|
||||||
# Skip if the user does not want to receive email notifications
|
# Skip if the user does not want to receive email notifications
|
||||||
@@ -106,9 +112,34 @@ class InvenTreeEmailNotifications(NotificationMixin, SettingsMixin, InvenTreePlu
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if email := InvenTree.helpers_email.get_email_for_user(user):
|
if email := InvenTree.helpers_email.get_email_for_user(user):
|
||||||
recipients.append(email)
|
user_language = get_user_language(user)
|
||||||
|
language_groups[user_language].append(email)
|
||||||
|
|
||||||
|
if not language_groups:
|
||||||
|
# No recipients found, so we cannot send the email
|
||||||
|
return False
|
||||||
|
|
||||||
|
instance_title = get_global_setting('INVENTREE_INSTANCE')
|
||||||
|
raw_subject = context['template'].get('subject', '')
|
||||||
|
template_name = context['template']['html']
|
||||||
|
|
||||||
|
for user_language, recipients in language_groups.items():
|
||||||
|
with override(user_language):
|
||||||
|
# Translate subject
|
||||||
|
subject = gettext(str(raw_subject))
|
||||||
|
if instance_title:
|
||||||
|
subject = f'[{instance_title}] {subject}'
|
||||||
|
|
||||||
|
# Build localized context copy so template rendering evaluates strings in user locale
|
||||||
|
ctx = dict(context)
|
||||||
|
if 'name' in ctx and ctx['name'] is not None:
|
||||||
|
ctx['name'] = gettext(str(ctx['name']))
|
||||||
|
if 'message' in ctx and ctx['message'] is not None:
|
||||||
|
ctx['message'] = gettext(str(ctx['message']))
|
||||||
|
|
||||||
|
# Re-render html_message with translated strings and templates
|
||||||
|
html_message = render_to_string(template_name, ctx)
|
||||||
|
|
||||||
if recipients:
|
|
||||||
InvenTree.helpers_email.send_email(
|
InvenTree.helpers_email.send_email(
|
||||||
subject,
|
subject,
|
||||||
'',
|
'',
|
||||||
@@ -116,10 +147,8 @@ class InvenTreeEmailNotifications(NotificationMixin, SettingsMixin, InvenTreePlu
|
|||||||
html_message=html_message,
|
html_message=html_message,
|
||||||
force_async=not settings.TESTING,
|
force_async=not settings.TESTING,
|
||||||
)
|
)
|
||||||
return True
|
|
||||||
|
|
||||||
# No recipients found, so we cannot send the email
|
return True
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeSlackNotifications(NotificationMixin, SettingsMixin, InvenTreePlugin):
|
class InvenTreeSlackNotifications(NotificationMixin, SettingsMixin, InvenTreePlugin):
|
||||||
|
|||||||
Reference in new issue
Block a user