From 5c798f42376e85441cbe65ecce5f971b9fb37273 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 8 Aug 2026 19:11:46 +1000 Subject: [PATCH] [bug] Fix part duplicate checks (#12574) * [bug] Fix part duplicate checks * Additional unit tests --- src/backend/InvenTree/part/models.py | 19 ++++++++++---- src/backend/InvenTree/part/test_api.py | 30 ++++++++++++++++++++++ src/backend/InvenTree/part/test_part.py | 34 +++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 1591984b2e..13a1eba816 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -1053,11 +1053,20 @@ class Part( raise ValidationError(_('Duplicate part revision already exists.')) # Ensure unique across (Name, revision, IPN) (as specified) - if (self.revision or self.IPN) and ( - Part.objects - .exclude(pk=self.pk) - .filter(name=self.name, revision=self.revision, IPN=self.IPN) - .exists() + # Note: the 'unique_part' database constraint only rejects a row when + # *all three* fields are non-null (Postgres treats NULL as distinct from + # NULL), so mirror that here rather than skipping whenever either field + # is merely blank - an empty string ('') still collides with another + # empty string at the database level, unlike None/NULL. + if ( + self.IPN is not None + and self.revision is not None + and ( + Part.objects + .exclude(pk=self.pk) + .filter(name=self.name, revision=self.revision, IPN=self.IPN) + .exists() + ) ): raise ValidationError( _('Part with this Name, IPN and Revision already exists.') diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 32c42db2cd..ed2e5fff42 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -1638,6 +1638,36 @@ class PartCreationTests(PartAPITestBase): self.assertFalse(response.data['active']) self.assertFalse(response.data['purchaseable']) + def test_create_duplicate_no_ipn_revision(self): + """Test that creating a duplicate part (same name, no IPN/revision) returns a 400. + + Regression test for a bug where the duplicate-name check was skipped + whenever IPN and revision were both blank, letting the request fall + through to an unhandled database IntegrityError (500) instead of a + proper validation error (400). + """ + url = reverse('api-part-list') + + data = { + 'name': 'TEST1', + 'category': 1, + 'assembly': True, + 'component': True, + 'consumable': False, + 'is_template': False, + 'purchaseable': False, + 'salable': False, + 'testable': False, + 'trackable': False, + 'virtual': False, + } + + self.post(url, data, expected_code=201) + + # Attempting to create the exact same part again must be rejected cleanly + response = self.post(url, data, expected_code=400) + self.assertIn('non_field_errors', response.data) + def test_notes_on_create(self): """Test that notes can be set when creating a Part.""" list_url = reverse('api-part-list') diff --git a/src/backend/InvenTree/part/test_part.py b/src/backend/InvenTree/part/test_part.py index a30bddb864..57b21dc0cb 100644 --- a/src/backend/InvenTree/part/test_part.py +++ b/src/backend/InvenTree/part/test_part.py @@ -219,6 +219,40 @@ class PartTest(TestCase): with self.assertRaises(ValidationError): part_2.validate_unique() + def test_duplicate_no_ipn_revision(self): + """Test that we cannot create a duplicate Part when IPN and revision are both blank strings. + + Regression test: the underlying 'unique_part' database constraint treats + blank IPN/revision values (empty strings, as sent by the API when these + fields are omitted) as equal, so validate_unique() must catch this case + too, rather than raising an unhandled IntegrityError. Note that this is + distinct from *unset* (None/NULL) values, which the database constraint + does *not* treat as colliding (see test_revisions). + """ + cat = PartCategory.objects.get(pk=1) + + Part.objects.create( + category=cat, + name='dupe_test', + description='description', + IPN='', + revision='', + ) + + part = Part( + category=cat, + name='dupe_test', + description='description', + IPN='', + revision='', + ) + + with self.assertRaises(ValidationError): + part.validate_unique() + + with self.assertRaises(ValidationError): + part.save() + def test_attributes(self): """Test Part attributes.""" self.assertEqual(self.r1.name, 'R_2K2_0805')