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:
xyz-is-bestandMatthias Mair authored and GitHub committed 2026-09-29 10:57:41 +10:00
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):