2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-15 19:45:46 +00:00

Merge branch 'form-clean-fix'

This commit is contained in:
Oliver Walters
2022-11-18 21:09:34 +11:00
10 changed files with 203 additions and 10 deletions

View File

@ -2,11 +2,14 @@
# InvenTree API version # 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 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 v81 -> 2022-11-08 : https://github.com/inventree/InvenTree/pull/3710
- Adds cached pricing information to Part API - Adds cached pricing information to Part API
- Adds cached pricing information to BomItem API - Adds cached pricing information to BomItem API

View File

@ -950,16 +950,27 @@ def strip_html_tags(value: str, raise_error=True, field_name=None):
return cleaned 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""" """Remove non-printable / control characters from the provided string"""
cleaned = value
if remove_ascii: if remove_ascii:
# Remove ASCII control characters # 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: if remove_unicode:
# Remove Unicode control characters # 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 return cleaned

View File

@ -1,8 +1,11 @@
"""Mixins for (API) views in the whole project.""" """Mixins for (API) views in the whole project."""
from django.core.exceptions import FieldDoesNotExist
from rest_framework import generics, mixins, status from rest_framework import generics, mixins, status
from rest_framework.response import Response from rest_framework.response import Response
from InvenTree.fields import InvenTreeNotesField
from InvenTree.helpers import remove_non_printable_characters, strip_html_tags 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 Nominally, the only thing that will be "cleaned" will be HTML tags
Ref: https://github.com/mozilla/bleach/issues/192 Ref: https://github.com/mozilla/bleach/issues/192
""" """
cleaned = strip_html_tags(data, field_name=field) 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 return cleaned

View File

@ -702,7 +702,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
] ]
def validate(self, data): def validate(self, data):
"""Perfofrm data validation for this item""" """Perform data validation for this item"""
super().validate(data) super().validate(data)
build = self.context['build'] build = self.context['build']

View File

@ -160,7 +160,8 @@ class CategoryList(APIDownloadMixin, ListCreateAPI):
filterset_fields = [ filterset_fields = [
'name', 'name',
'description' 'description',
'structural'
] ]
ordering_fields = [ ordering_fields = [

View File

@ -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'),
),
]

View File

@ -117,6 +117,14 @@ class PartCategory(MetadataMixin, InvenTreeTree):
help_text=_('Default location for parts in this category') 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')) 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( 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 the web URL associated with the detail view for this PartCategory instance"""
return reverse('category-detail', kwargs={'pk': self.id}) 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: class Meta:
"""Metaclass defines extra model properties""" """Metaclass defines extra model properties"""
verbose_name = _("Part Category") verbose_name = _("Part Category")
@ -424,6 +443,7 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
If not, it is considered "orphaned" and will be deleted. If not, it is considered "orphaned" and will be deleted.
""" """
# Get category templates settings # Get category templates settings
add_category_templates = kwargs.pop('add_category_templates', False) add_category_templates = kwargs.pop('add_category_templates', False)
if self.pk: if self.pk:
@ -754,11 +774,17 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
def clean(self): def clean(self):
"""Perform cleaning operations for the Part model. """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 If this part is trackable, and it is used in the BOM
for a parent part which is *not* trackable, for a parent part which is *not* trackable,
then we will force the parent part to be 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() super().clean()
# Strip IPN field # Strip IPN field

View File

@ -75,6 +75,7 @@ class CategorySerializer(InvenTreeModelSerializer):
'pathstring', 'pathstring',
'starred', 'starred',
'url', 'url',
'structural',
'icon', 'icon',
] ]

View File

@ -4,6 +4,7 @@ from decimal import Decimal
from enum import IntEnum from enum import IntEnum
from random import randint from random import randint
from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
import PIL import PIL
@ -403,6 +404,59 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
child.refresh_from_db() child.refresh_from_db()
self.assertEqual(child.parent, parent_category) 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): class PartOptionsAPITest(InvenTreeAPITestCase):
"""Tests for the various OPTIONS endpoints in the /part/ API. """Tests for the various OPTIONS endpoints in the /part/ API.
@ -1544,8 +1598,24 @@ class PartDetailTests(InvenTreeAPITestCase):
self.assertFalse('hello' in part.metadata) self.assertFalse('hello' in part.metadata)
self.assertEqual(part.metadata['x'], 'y') 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 # Ensure that we cannot upload a very long piece of text
url = reverse('api-part-detail', kwargs={'pk': 1}) 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'])) 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): class PartPricingDetailTests(InvenTreeAPITestCase):
"""Tests for the part pricing API endpoint""" """Tests for the part pricing API endpoint"""

View File

@ -81,6 +81,9 @@ function partFields(options={}) {
return fields; return fields;
} }
},
filters: {
structural: false,
} }
}, },
name: {}, name: {},
@ -298,6 +301,7 @@ function categoryFields() {
default_keywords: { default_keywords: {
icon: 'fa-key', icon: 'fa-key',
}, },
structural: {},
icon: { 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>.`, 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', placeholder: 'fas fa-tag',