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/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index d5b9c9b908..f3be374e34 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -950,16 +950,27 @@ def strip_html_tags(value: str, raise_error=True, field_name=None): return cleaned -def remove_non_printable_characters(value: str, remove_ascii=True, remove_unicode=True): +def remove_non_printable_characters(value: str, remove_newline=True, remove_ascii=True, remove_unicode=True): """Remove non-printable / control characters from the provided string""" + cleaned = value + if remove_ascii: # Remove ASCII control characters - cleaned = regex.sub(u'[\x01-\x1F]+', '', value) + # Note that we do not sub out 0x0A (\n) here, it is done separately below + cleaned = regex.sub(u'[\x01-\x09]+', '', cleaned) + cleaned = regex.sub(u'[\x0b-\x1F]+', '', cleaned) + + if remove_newline: + cleaned = regex.sub(u'[\x0a]+', '', cleaned) if remove_unicode: # Remove Unicode control characters - cleaned = regex.sub(u'[^\P{C}]+', '', value) + if remove_newline: + cleaned = regex.sub(u'[^\P{C}]+', '', cleaned) + else: + # Use 'negative-lookahead' to exclude newline character + cleaned = regex.sub(u'(?![\x0A])[^\P{C}]+', '', cleaned) return cleaned diff --git a/InvenTree/InvenTree/mixins.py b/InvenTree/InvenTree/mixins.py index d503d2dc39..e484c76f5b 100644 --- a/InvenTree/InvenTree/mixins.py +++ b/InvenTree/InvenTree/mixins.py @@ -1,8 +1,11 @@ """Mixins for (API) views in the whole project.""" +from django.core.exceptions import FieldDoesNotExist + from rest_framework import generics, mixins, status from rest_framework.response import Response +from InvenTree.fields import InvenTreeNotesField from InvenTree.helpers import remove_non_printable_characters, strip_html_tags @@ -44,10 +47,35 @@ class CleanMixin(): Nominally, the only thing that will be "cleaned" will be HTML tags Ref: https://github.com/mozilla/bleach/issues/192 + """ cleaned = strip_html_tags(data, field_name=field) - cleaned = remove_non_printable_characters(cleaned) + + # By default, newline characters are removed + remove_newline = True + + try: + if hasattr(self, 'serializer_class'): + model = self.serializer_class.Meta.model + field = model._meta.get_field(field) + + # The following field types allow newline characters + allow_newline = [ + InvenTreeNotesField, + ] + + for field_type in allow_newline: + if issubclass(type(field), field_type): + remove_newline = False + break + + except AttributeError: + pass + except FieldDoesNotExist: + pass + + cleaned = remove_non_printable_characters(cleaned, remove_newline=remove_newline) return cleaned 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..173e2fd2af 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. @@ -1544,8 +1598,24 @@ class PartDetailTests(InvenTreeAPITestCase): self.assertFalse('hello' in part.metadata) self.assertEqual(part.metadata['x'], 'y') - def test_part_notes(self): - """Unit tests for the part 'notes' field""" + +class PartNotesTests(InvenTreeAPITestCase): + """Tests for the 'notes' field (markdown field)""" + + fixtures = [ + 'category', + 'part', + 'location', + 'company', + ] + + roles = [ + 'part.change', + 'part.add', + ] + + def test_long_notes(self): + """Test that very long notes field is rejected""" # Ensure that we cannot upload a very long piece of text url = reverse('api-part-detail', kwargs={'pk': 1}) @@ -1560,6 +1630,36 @@ class PartDetailTests(InvenTreeAPITestCase): self.assertIn('Ensure this field has no more than 50000 characters', str(response.data['notes'])) + def test_multiline_formatting(self): + """Ensure that markdown formatting is retained""" + + url = reverse('api-part-detail', kwargs={'pk': 1}) + + notes = """ + ### Title + + 1. Numbered list + 2. Another item + 3. Another item again + + [A link](http://link.com.go) + + """ + + response = self.patch( + url, + { + 'notes': notes, + }, + expected_code=200 + ) + + # Ensure that newline chars have not been removed + self.assertIn('\n', response.data['notes']) + + # Entire notes field should match original value + self.assertEqual(response.data['notes'], notes.strip()) + class PartPricingDetailTests(InvenTreeAPITestCase): """Tests for the part pricing API endpoint""" 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" %} <a href="https://fontawesome.com/v5/search?s=solid" target="_blank" rel="noopener noreferrer">Font Awesome</a>.`, placeholder: 'fas fa-tag',