From 4fb0226ecc5ae37fce5dabf195089e30def6c36f Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 2 Nov 2026 12:09:09 +1100 Subject: [PATCH] [bug] Settings fix (#13156) * Fix error when generating default settings - Prevent database error on conflicting rows - Can be caused by concurrent user requests * ignore conflicts for TipResult --- src/backend/InvenTree/common/models.py | 2 ++ src/backend/InvenTree/common/tests.py | 26 ++++++++++++++++++++++++++ src/backend/InvenTree/users/models.py | 8 +++++--- src/backend/InvenTree/users/tests.py | 22 +++++++++++++++++++++- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 8d60456de3..ebb0f0d206 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -292,6 +292,8 @@ class BaseInvenTreeSetting(UpdatedUserMixin, models.Model): if not key.startswith('_') ], batch_size=250, + # Concurrent requests may race to create the same keys + ignore_conflicts=True, ) except Exception as exc: logger.exception( diff --git a/src/backend/InvenTree/common/tests.py b/src/backend/InvenTree/common/tests.py index f215321666..998e56a71a 100644 --- a/src/backend/InvenTree/common/tests.py +++ b/src/backend/InvenTree/common/tests.py @@ -866,6 +866,32 @@ class UserSettingsApiTest(InvenTreeAPITestCase): self.get(url, expected_code=200) + def test_build_default_values_race(self): + """Test that building default values tolerates keys created concurrently.""" + key = 'DISPLAY_HISTORY_TAB' + + InvenTreeUserSetting.objects.create(key=key, value='True', user=self.user) + + # Simulate another request creating the key after the existing keys are checked + mock_qs = mock.Mock() + mock_qs.values_list.return_value = [] + + with ( + mock.patch.object( + InvenTreeUserSetting.objects, 'filter', return_value=mock_qs + ), + self.assertNoLogs(logger='inventree', level='ERROR'), + ): + InvenTreeUserSetting.build_default_values(user=self.user) + + settings = InvenTreeUserSetting.objects.filter(user=self.user) + + self.assertEqual(settings.filter(key=key).count(), 1) + self.assertEqual( + set(settings.values_list('key', flat=True)), + {k for k in InvenTreeUserSetting.SETTINGS if not k.startswith('_')}, + ) + def test_user_setting_invalid(self): """Test a user setting with an invalid key.""" url = reverse('api-user-setting-detail', kwargs={'key': 'DONKEY'}) diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index 33c13e3ca1..421cf2afee 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -878,9 +878,11 @@ class TipResult(InvenTree.models.MetadataMixin): # bulk create missing_ids = set(known_ids) - existing_ids if missing_ids: - cls.objects.bulk_create([ - cls(user=user, tip_id=tip_id) for tip_id in missing_ids - ]) + cls.objects.bulk_create( + [cls(user=user, tip_id=tip_id) for tip_id in missing_ids], + # Concurrent requests may race to create the same records + ignore_conflicts=True, + ) # Validate groups diff --git a/src/backend/InvenTree/users/tests.py b/src/backend/InvenTree/users/tests.py index b25ef8bacb..3a2782b1b8 100644 --- a/src/backend/InvenTree/users/tests.py +++ b/src/backend/InvenTree/users/tests.py @@ -1,6 +1,7 @@ """Unit tests for the 'users' app.""" import datetime +from unittest import mock from django.apps import apps from django.contrib.auth.models import Group @@ -10,7 +11,7 @@ from django.urls import reverse from common.settings import set_global_setting from InvenTree.helpers_mfa import get_codes from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase -from users.models import ApiToken, Owner +from users.models import ApiToken, Owner, TipResult from users.oauth2_scopes import _roles from users.ruleset import ( RULESET_CHOICES, @@ -18,6 +19,7 @@ from users.ruleset import ( get_ruleset_ignore, get_ruleset_models, ) +from web.tips import KNOWN_IDS G_RULESETS = get_ruleset_models() G_RULESETS_IG = get_ruleset_ignore() @@ -697,6 +699,24 @@ class UserProfileTest(InvenTreeAPITestCase): profile.refresh_from_db() self.assertIsNone(profile.primary_group) + def test_tip_backfill_race(self): + """Test that backfilling tip results tolerates records created concurrently.""" + tip_id = KNOWN_IDS[0] + + TipResult.objects.create(user=self.user, tip_id=tip_id) + + # Simulate another request creating the record after the existing IDs are checked + mock_qs = mock.Mock() + mock_qs.values_list.return_value = [] + + with mock.patch.object(TipResult.objects, 'filter', return_value=mock_qs): + TipResult.backfill(self.user) + + results = TipResult.objects.filter(user=self.user) + + self.assertEqual(results.filter(tip_id=tip_id).count(), 1) + self.assertEqual(set(results.values_list('tip_id', flat=True)), set(KNOWN_IDS)) + class UserProfileMetadataPermissionTests(InvenTreeAPITestCase): """Tests for the generic metadata endpoint against the UserProfile model."""