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:
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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']
|
||||||
|
@ -160,7 +160,8 @@ class CategoryList(APIDownloadMixin, ListCreateAPI):
|
|||||||
|
|
||||||
filterset_fields = [
|
filterset_fields = [
|
||||||
'name',
|
'name',
|
||||||
'description'
|
'description',
|
||||||
|
'structural'
|
||||||
]
|
]
|
||||||
|
|
||||||
ordering_fields = [
|
ordering_fields = [
|
||||||
|
19
InvenTree/part/migrations/0090_auto_20221115_0816.py
Normal file
19
InvenTree/part/migrations/0090_auto_20221115_0816.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
@ -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
|
||||||
|
@ -75,6 +75,7 @@ class CategorySerializer(InvenTreeModelSerializer):
|
|||||||
'pathstring',
|
'pathstring',
|
||||||
'starred',
|
'starred',
|
||||||
'url',
|
'url',
|
||||||
|
'structural',
|
||||||
'icon',
|
'icon',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -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"""
|
||||||
|
@ -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',
|
||||||
|
Reference in New Issue
Block a user