diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index d48999189b..bf9711ebe3 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 81 +INVENTREE_API_VERSION = 82 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v82 -> 2022-11-16 : https://github.com/inventree/InvenTree/pull/3931 + - Add support for structural Part categories + v81 -> 2022-11-08 : https://github.com/inventree/InvenTree/pull/3710 - Adds cached pricing information to Part API - Adds cached pricing information to BomItem API diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index b22902bd65..ea64cb800c 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -702,7 +702,7 @@ class BuildAllocationItemSerializer(serializers.Serializer): ] def validate(self, data): - """Perfofrm data validation for this item""" + """Perform data validation for this item""" super().validate(data) build = self.context['build'] diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 0c65203574..eed93d57d5 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -160,7 +160,8 @@ class CategoryList(APIDownloadMixin, ListCreateAPI): filterset_fields = [ 'name', - 'description' + 'description', + 'structural' ] ordering_fields = [ diff --git a/InvenTree/part/migrations/0090_auto_20221115_0816.py b/InvenTree/part/migrations/0090_auto_20221115_0816.py new file mode 100644 index 0000000000..4965f36d69 --- /dev/null +++ b/InvenTree/part/migrations/0090_auto_20221115_0816.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.16 on 2022-11-15 08:16 + +import common.settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0089_auto_20221112_0128'), + ] + + operations = [ + migrations.AddField( + model_name='partcategory', + name='structural', + field=models.BooleanField(default=False, help_text="Parts may not be directly assigned to a structural category, but may be assigned to it's child categories.", verbose_name='Structural'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 8e5d42add0..6111696f71 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -117,6 +117,14 @@ class PartCategory(MetadataMixin, InvenTreeTree): help_text=_('Default location for parts in this category') ) + structural = models.BooleanField( + default=False, + verbose_name=_('Structural'), + help_text=_( + 'Parts may not be directly assigned to a structural category, ' + 'but may be assigned to it\'s child categories.'), + ) + default_keywords = models.CharField(null=True, blank=True, max_length=250, verbose_name=_('Default keywords'), help_text=_('Default keywords for parts in this category')) icon = models.CharField( @@ -135,6 +143,17 @@ class PartCategory(MetadataMixin, InvenTreeTree): """Return the web URL associated with the detail view for this PartCategory instance""" return reverse('category-detail', kwargs={'pk': self.id}) + def clean(self): + """Custom clean action for the PartCategory model: + + - Ensure that the structural parameter cannot get set if products already assigned to the category + """ + if self.pk and self.structural and self.item_count > 0: + raise ValidationError( + _("You cannot make this part category structural because some parts " + "are already assigned to it!")) + super().clean() + class Meta: """Metaclass defines extra model properties""" verbose_name = _("Part Category") @@ -424,6 +443,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): If not, it is considered "orphaned" and will be deleted. """ # Get category templates settings + add_category_templates = kwargs.pop('add_category_templates', False) if self.pk: @@ -754,11 +774,17 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel): def clean(self): """Perform cleaning operations for the Part model. - Update trackable status: + - Check if the PartCategory is not structural + + - Update trackable status: If this part is trackable, and it is used in the BOM for a parent part which is *not* trackable, then we will force the parent part to be trackable. """ + if self.category is not None and self.category.structural: + raise ValidationError( + {'category': _("Parts cannot be assigned to structural part categories!")}) + super().clean() # Strip IPN field diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 6f0707df46..45a0a5e55e 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -75,6 +75,7 @@ class CategorySerializer(InvenTreeModelSerializer): 'pathstring', 'starred', 'url', + 'structural', 'icon', ] diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 9b5cbb0649..87e155311a 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -4,6 +4,7 @@ from decimal import Decimal from enum import IntEnum from random import randint +from django.core.exceptions import ValidationError from django.urls import reverse import PIL @@ -403,6 +404,59 @@ class PartCategoryAPITest(InvenTreeAPITestCase): child.refresh_from_db() self.assertEqual(child.parent, parent_category) + def test_structural(self): + """Test the effectiveness of structural categories + + Make sure: + - Parts cannot be created in structural categories + - Parts cannot be assigned to structural categories + """ + + # Create our structural part category + structural_category = PartCategory.objects.create( + name='Structural category', + description='This is the structural category', + parent=None, + structural=True + ) + + part_count_before = Part.objects.count() + + # Make sure that we get an error if we try to create part in the structural category + with self.assertRaises(ValidationError): + part = Part.objects.create( + name="Part which shall not be created", + description="-", + category=structural_category + ) + + # Ensure that the part really did not get created in the structural category + self.assertEqual(part_count_before, Part.objects.count()) + + # Create a non structural category for test part category change + non_structural_category = PartCategory.objects.create( + name='Non-structural category', + description='This is a non-structural category', + parent=None, + structural=False + ) + + # Create the test part assigned to a non-structural category + part = Part.objects.create( + name="Part which category will be changed to structural", + description="-", + category=non_structural_category + ) + + # Assign the test part to a structural category and make sure it gives an error + part.category = structural_category + with self.assertRaises(ValidationError): + part.save() + + # Ensure that the part did not get saved to the DB + part.refresh_from_db() + self.assertEqual(part.category.pk, non_structural_category.pk) + class PartOptionsAPITest(InvenTreeAPITestCase): """Tests for the various OPTIONS endpoints in the /part/ API. diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 8af3b0bc04..1896cd7ee7 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -81,6 +81,9 @@ function partFields(options={}) { return fields; } + }, + filters: { + structural: false, } }, name: {}, @@ -298,6 +301,7 @@ function categoryFields() { default_keywords: { icon: 'fa-key', }, + structural: {}, icon: { help_text: `{% trans "Icon (optional) - Explore all available icons on" %} Font Awesome.`, placeholder: 'fas fa-tag',