From 3ddf792a59334775a1187307605a4e2596555995 Mon Sep 17 00:00:00 2001 From: Leonid Zaliubovskyi Date: Mon, 20 Jul 2026 06:33:25 +0200 Subject: [PATCH] Fix update notification deduplication and release link (#12424) --- src/backend/InvenTree/InvenTree/tasks.py | 3 + src/backend/InvenTree/InvenTree/test_tasks.py | 60 +++++++++++++++---- .../0048_notificationmessage_link.py | 24 ++++++++ src/backend/InvenTree/common/models.py | 7 +++ src/backend/InvenTree/common/notifications.py | 4 ++ src/backend/InvenTree/common/serializers.py | 4 +- .../builtin/integration/core_notifications.py | 1 + 7 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 src/backend/InvenTree/common/migrations/0048_notificationmessage_link.py diff --git a/src/backend/InvenTree/InvenTree/tasks.py b/src/backend/InvenTree/InvenTree/tasks.py index b876b15e38..f16832c531 100644 --- a/src/backend/InvenTree/InvenTree/tasks.py +++ b/src/backend/InvenTree/InvenTree/tasks.py @@ -856,6 +856,7 @@ def check_for_updates(): data = json.loads(response.text) tag = data.get('tag_name', None) + release_url = data.get('html_url') if not tag: raise ValueError("'tag_name' missing from GitHub response") # pragma: no cover @@ -885,11 +886,13 @@ def check_for_updates(): trigger_notification( None, 'update_available', + notification_uid=0, targets=get_user_model().objects.filter(is_superuser=True), delivery_methods={InvenTreeUINotifications}, context={ 'name': _('Update Available'), 'message': _('An update for InvenTree is available'), + 'link': release_url, }, ) diff --git a/src/backend/InvenTree/InvenTree/test_tasks.py b/src/backend/InvenTree/InvenTree/test_tasks.py index c059e79254..9224b784cf 100644 --- a/src/backend/InvenTree/InvenTree/test_tasks.py +++ b/src/backend/InvenTree/InvenTree/test_tasks.py @@ -2,6 +2,7 @@ import os from datetime import timedelta +from unittest.mock import patch from django.conf import settings from django.contrib.auth.models import User @@ -11,6 +12,7 @@ from django.db.utils import NotSupportedError from django.test import TestCase from django.utils import timezone +import requests_mock from django_q.models import OrmQ, Schedule, Task from error_report.models import Error @@ -168,19 +170,57 @@ class InvenTreeTaskTests(PluginRegistryMixin, TestCase): errors = Error.objects.filter(when__lte=threshold) self.assertEqual(len(errors), 0) - def test_task_check_for_updates(self): - """Test the task check_for_updates.""" - # Check that setting should be empty + @requests_mock.Mocker() + def test_task_check_for_updates(self, requests_mocker): + """Test update notification creation and deduplication.""" + from common.models import NotificationEntry, NotificationMessage + from common.serializers import NotificationMessageSerializer + + self.ensurePluginsLoaded(force=True) + + user = User.objects.create_superuser( + username='update_admin', + email='update@example.com', + password='test-password', + ) + + release_url = 'https://github.com/InvenTree/InvenTree/releases/tag/99.99.99' + + requests_mocker.get( + 'https://api.github.com/repos/inventree/inventree/releases/latest', + json={'tag_name': '99.99.99', 'html_url': release_url}, + ) + InvenTreeSetting.set_setting('_INVENTREE_LATEST_VERSION', '') - self.assertEqual(InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION'), '') - # Get new version - InvenTree.tasks.offload_task(InvenTree.tasks.check_for_updates) + with ( + patch( + 'InvenTree.tasks.isInvenTreeUpToDate', return_value=False + ) as mock_up_to_date, + patch('InvenTree.tasks.check_daily_holdoff', return_value=True), + ): + InvenTree.tasks.check_for_updates() - # Check that setting is not empty - response = InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION') - self.assertNotEqual(response, '') - self.assertTrue(bool(response)) + self.assertEqual( + InvenTreeSetting.get_setting('_INVENTREE_LATEST_VERSION'), '99.99.99' + ) + self.assertEqual(NotificationMessage.objects.count(), 1) + self.assertEqual(NotificationEntry.objects.count(), 1) + + message = NotificationMessage.objects.get(user=user) + entry = NotificationEntry.objects.get() + + self.assertEqual(message.link, release_url) + self.assertEqual(entry.uid, 0) + + serialized = NotificationMessageSerializer(message).data + self.assertEqual(serialized['target']['link'], release_url) + + InvenTree.tasks.check_for_updates() + + self.assertEqual(NotificationMessage.objects.count(), 1) + self.assertEqual(NotificationEntry.objects.count(), 1) + mock_up_to_date.assert_called() def test_task_check_for_migrations(self): """Test the task check_for_migrations.""" diff --git a/src/backend/InvenTree/common/migrations/0048_notificationmessage_link.py b/src/backend/InvenTree/common/migrations/0048_notificationmessage_link.py new file mode 100644 index 0000000000..577f5f4340 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0048_notificationmessage_link.py @@ -0,0 +1,24 @@ +"""Add link field to notification messages.""" + +# Generated by Django 5.2.15 on 2026-07-20 01:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Add the optional notification link field.""" + + dependencies = [('common', '0047_parametertemplate_unique')] + + operations = [ + migrations.AddField( + model_name='notificationmessage', + name='link', + field=models.URLField( + blank=True, + help_text='Optional explicit URL associated with this notification', + max_length=500, + null=True, + ), + ) + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 50253059ac..d03628897f 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -1723,6 +1723,13 @@ class NotificationMessage(models.Model): message = models.CharField(max_length=250, blank=True, null=True) + link = models.URLField( + max_length=500, + blank=True, + null=True, + help_text=_('Optional explicit URL associated with this notification'), + ) + creation = models.DateTimeField(auto_now_add=True) read = models.BooleanField(default=False) diff --git a/src/backend/InvenTree/common/notifications.py b/src/backend/InvenTree/common/notifications.py index 880b5ba64e..a332e89a23 100644 --- a/src/backend/InvenTree/common/notifications.py +++ b/src/backend/InvenTree/common/notifications.py @@ -91,6 +91,7 @@ def trigger_notification(obj: Model, category: str = '', obj_ref: str = 'pk', ** obj: The object (model instance) that is triggering the notification category: The category (label) for the notification obj_ref: The reference to the object that should be used for the notification + notification_uid: Explicit deduplication identifier for notifications without a model instance kwargs: Additional arguments to pass to the notification method """ # Check if data is importing currently @@ -105,6 +106,7 @@ def trigger_notification(obj: Model, category: str = '', obj_ref: str = 'pk', ** context = kwargs.get('context', {}) delivery_methods = kwargs.get('delivery_methods') check_recent = kwargs.get('check_recent', True) + notification_uid = kwargs.get('notification_uid') # Resolve object reference refs = [obj_ref, 'pk', 'id', 'uid'] @@ -122,6 +124,8 @@ def trigger_notification(obj: Model, category: str = '', obj_ref: str = 'pk', ** raise KeyError( f"Could not resolve an object reference for '{obj!s}' with {','.join(set(refs))}" ) + elif notification_uid is not None: + obj_ref_value = notification_uid # Check if we have notified recently... delta = timedelta(days=1) diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 6e96c70f0b..135539b7d8 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -318,7 +318,9 @@ class NotificationMessageSerializer(InvenTreeModelSerializer): """Function to resolve generic object reference to target.""" target = get_objectreference(obj, 'target_content_type', 'target_object_id') - if target and 'link' not in target: + if target and obj.link: + target['link'] = obj.link + elif target and 'link' not in target: # Check if object has an absolute_url function if hasattr(obj.target_object, 'get_absolute_url'): target['link'] = obj.target_object.get_absolute_url() diff --git a/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py b/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py index 2f7d7b11ca..6d9e8a5099 100644 --- a/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py +++ b/src/backend/InvenTree/plugin/builtin/integration/core_notifications.py @@ -52,6 +52,7 @@ class InvenTreeUINotifications(NotificationMixin, InvenTreePlugin): category=category, name=ctx.get('name'), message=ctx.get('message'), + link=ctx.get('link'), ) )