From 65349eeefbbdb8392c5507acfb51ef2f7ca1b0b7 Mon Sep 17 00:00:00 2001 From: xyz-is-best Date: Tue, 29 Sep 2026 08:57:41 +0800 Subject: [PATCH] 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 --- src/backend/InvenTree/common/notifications.py | 33 +++ .../InvenTree/common/test_notifications.py | 204 ++++++++++++++++++ .../builtin/integration/core_notifications.py | 79 ++++--- 3 files changed, 291 insertions(+), 25 deletions(-) create mode 100644 src/backend/InvenTree/common/test_notifications.py diff --git a/src/backend/InvenTree/common/notifications.py b/src/backend/InvenTree/common/notifications.py index a332e89a23..f4a1696583 100644 --- a/src/backend/InvenTree/common/notifications.py +++ b/src/backend/InvenTree/common/notifications.py @@ -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): """Send out a notification. diff --git a/src/backend/InvenTree/common/test_notifications.py b/src/backend/InvenTree/common/test_notifications.py new file mode 100644 index 0000000000..f7a41bb98c --- /dev/null +++ b/src/backend/InvenTree/common/test_notifications.py @@ -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'}} + ) + ) diff --git a/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py b/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py index 0b732bcb64..0ba840bab4 100644 --- a/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py +++ b/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py @@ -1,15 +1,19 @@ """Core set of Notifications as a Plugin.""" +from collections import defaultdict + from django.conf import settings from django.contrib.auth.models import User from django.db.models import Model from django.template.loader import render_to_string +from django.utils.translation import gettext, override from django.utils.translation import gettext_lazy as _ import requests import structlog import InvenTree.helpers_email +from common.notifications import get_user_language from common.settings import get_global_setting from plugin import InvenTreePlugin from plugin.mixins import NotificationMixin, SettingsMixin @@ -43,16 +47,26 @@ class InvenTreeUINotifications(NotificationMixin, InvenTreePlugin): if not target: 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: + 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( NotificationMessage( target_object=target, source_object=user, user=user, category=category, - name=ctx.get('name'), - message=ctx.get('message'), + name=name, + message=message, link=ctx.get('link'), ) ) @@ -89,16 +103,8 @@ class InvenTreeEmailNotifications(NotificationMixin, SettingsMixin, InvenTreePlu if not context.get('template'): return False - html_message = render_to_string(context['template']['html'], context) - - # 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 = [] + # Group target users by their preferred language + language_groups: dict[str, list[str]] = defaultdict(list) for user in users: # Skip if the user does not want to receive email notifications @@ -106,20 +112,43 @@ class InvenTreeEmailNotifications(NotificationMixin, SettingsMixin, InvenTreePlu continue 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 recipients: - InvenTree.helpers_email.send_email( - subject, - '', - recipients, - html_message=html_message, - force_async=not settings.TESTING, - ) - return True + if not language_groups: + # No recipients found, so we cannot send the email + return False - # 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) + + InvenTree.helpers_email.send_email( + subject, + '', + recipients, + html_message=html_message, + force_async=not settings.TESTING, + ) + + return True class InvenTreeSlackNotifications(NotificationMixin, SettingsMixin, InvenTreePlugin):