Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-06-15 21:15:39 +10:00
commit 47f39079fd
24 changed files with 417 additions and 154 deletions

4
.github/release.yml vendored
View File

@ -9,6 +9,9 @@ changelog:
labels:
- Semver-Major
- breaking
- title: Security Patches
labels:
- security
- title: New Features
labels:
- Semver-Minor
@ -23,7 +26,6 @@ changelog:
- setup
- demo
- CI
- security
- title: Other Changes
labels:
- "*"

View File

@ -0,0 +1,33 @@
"""Admin classes"""
from import_export.resources import ModelResource
class InvenTreeResource(ModelResource):
"""Custom subclass of the ModelResource class provided by django-import-export"
Ensures that exported data are escaped to prevent malicious formula injection.
Ref: https://owasp.org/www-community/attacks/CSV_Injection
"""
def export_resource(self, obj):
"""Custom function to override default row export behaviour.
Specifically, strip illegal leading characters to prevent formula injection
"""
row = super().export_resource(obj)
illegal_start_vals = ['@', '=', '+', '-', '@', '\t', '\r', '\n']
for idx, val in enumerate(row):
if type(val) is str:
val = val.strip()
# If the value starts with certain 'suspicious' values, remove it!
while len(val) > 0 and val[0] in illegal_start_vals:
# Remove the first character
val = val[1:]
row[idx] = val
return row

View File

@ -2,11 +2,15 @@
# InvenTree API version
INVENTREE_API_VERSION = 60
INVENTREE_API_VERSION = 61
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v61 -> 2022-06-12 : https://github.com/inventree/InvenTree/pull/3183
- Migrate the "Convert Stock Item" form class to use the API
- There is now an API endpoint for converting a stock item to a valid variant
v60 -> 2022-06-08 : https://github.com/inventree/InvenTree/pull/3148
- Add availability data fields to the SupplierPart model

View File

@ -13,6 +13,7 @@
inventreeDocReady,
inventreeLoad,
inventreeSave,
sanitizeData,
*/
function attachClipboard(selector, containerselector, textElement) {
@ -273,6 +274,42 @@ function loadBrandIcon(element, name) {
}
}
/*
* Function to sanitize a (potentially nested) object.
* Iterates through all levels, and sanitizes each primitive string.
*
* Note that this function effectively provides a "deep copy" of the provided data,
* and the original data structure is unaltered.
*/
function sanitizeData(data) {
if (data == null) {
return null;
} else if (Array.isArray(data)) {
// Handle arrays
var arr = [];
data.forEach(function(val) {
arr.push(sanitizeData(val));
});
return arr;
} else if (typeof(data) === 'object') {
// Handle nested structures
var nested = {};
$.each(data, function(k, v) {
nested[k] = sanitizeData(v);
});
return nested;
} else if (typeof(data) === 'string') {
// Perform string replacement
return data.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;').replace(/`/g, '&#x60;');
} else {
return data;
}
}
// Convenience function to determine if an element exists
$.fn.exists = function() {
return this.length !== 0;

View File

@ -4,15 +4,14 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from import_export.resources import ModelResource
import import_export.widgets as widgets
from build.models import Build, BuildItem
from InvenTree.admin import InvenTreeResource
import part.models
class BuildResource(ModelResource):
class BuildResource(InvenTreeResource):
"""Class for managing import/export of Build data."""
# For some reason, we need to specify the fields individually for this ModelResource,
# but we don't for other ones.

View File

@ -5,8 +5,8 @@ from django.contrib import admin
import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from import_export.resources import ModelResource
from InvenTree.admin import InvenTreeResource
from part.models import Part
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
@ -14,7 +14,7 @@ from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
SupplierPriceBreak)
class CompanyResource(ModelResource):
class CompanyResource(InvenTreeResource):
"""Class for managing Company data import/export."""
class Meta:
@ -38,7 +38,7 @@ class CompanyAdmin(ImportExportModelAdmin):
]
class SupplierPartResource(ModelResource):
class SupplierPartResource(InvenTreeResource):
"""Class for managing SupplierPart data import/export."""
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
@ -74,7 +74,7 @@ class SupplierPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'supplier', 'manufacturer_part',)
class ManufacturerPartResource(ModelResource):
class ManufacturerPartResource(InvenTreeResource):
"""Class for managing ManufacturerPart data import/export."""
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
@ -117,7 +117,7 @@ class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
autocomplete_fields = ('manufacturer_part',)
class ManufacturerPartParameterResource(ModelResource):
class ManufacturerPartParameterResource(InvenTreeResource):
"""Class for managing ManufacturerPartParameter data import/export."""
class Meta:
@ -144,7 +144,7 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
autocomplete_fields = ('manufacturer_part',)
class SupplierPriceBreakResource(ModelResource):
class SupplierPriceBreakResource(InvenTreeResource):
"""Class for managing SupplierPriceBreak data import/export."""
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))

View File

@ -5,7 +5,8 @@ from django.contrib import admin
import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from import_export.resources import ModelResource
from InvenTree.admin import InvenTreeResource
from .models import (PurchaseOrder, PurchaseOrderExtraLine,
PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation,
@ -97,7 +98,7 @@ class SalesOrderAdmin(ImportExportModelAdmin):
autocomplete_fields = ('customer',)
class PurchaseOrderResource(ModelResource):
class PurchaseOrderResource(InvenTreeResource):
"""Class for managing import / export of PurchaseOrder data."""
# Add number of line items
@ -116,7 +117,7 @@ class PurchaseOrderResource(ModelResource):
]
class PurchaseOrderLineItemResource(ModelResource):
class PurchaseOrderLineItemResource(InvenTreeResource):
"""Class for managing import / export of PurchaseOrderLineItem data."""
part_name = Field(attribute='part__part__name', readonly=True)
@ -135,7 +136,7 @@ class PurchaseOrderLineItemResource(ModelResource):
clean_model_instances = True
class PurchaseOrderExtraLineResource(ModelResource):
class PurchaseOrderExtraLineResource(InvenTreeResource):
"""Class for managing import / export of PurchaseOrderExtraLine data."""
class Meta(GeneralExtraLineMeta):
@ -144,7 +145,7 @@ class PurchaseOrderExtraLineResource(ModelResource):
model = PurchaseOrderExtraLine
class SalesOrderResource(ModelResource):
class SalesOrderResource(InvenTreeResource):
"""Class for managing import / export of SalesOrder data."""
# Add number of line items
@ -163,7 +164,7 @@ class SalesOrderResource(ModelResource):
]
class SalesOrderLineItemResource(ModelResource):
class SalesOrderLineItemResource(InvenTreeResource):
"""Class for managing import / export of SalesOrderLineItem data."""
part_name = Field(attribute='part__name', readonly=True)
@ -192,7 +193,7 @@ class SalesOrderLineItemResource(ModelResource):
clean_model_instances = True
class SalesOrderExtraLineResource(ModelResource):
class SalesOrderExtraLineResource(InvenTreeResource):
"""Class for managing import / export of SalesOrderExtraLine data."""
class Meta(GeneralExtraLineMeta):

View File

@ -5,14 +5,14 @@ from django.contrib import admin
import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from import_export.resources import ModelResource
import part.models as models
from company.models import SupplierPart
from InvenTree.admin import InvenTreeResource
from stock.models import StockLocation
class PartResource(ModelResource):
class PartResource(InvenTreeResource):
"""Class for managing Part data import/export."""
# ForeignKey fields
@ -92,7 +92,7 @@ class PartAdmin(ImportExportModelAdmin):
]
class PartCategoryResource(ModelResource):
class PartCategoryResource(InvenTreeResource):
"""Class for managing PartCategory data import/export."""
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
@ -157,7 +157,7 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
autocomplete_fields = ('part',)
class BomItemResource(ModelResource):
class BomItemResource(InvenTreeResource):
"""Class for managing BomItem data import/export."""
level = Field(attribute='level', readonly=True)
@ -266,7 +266,7 @@ class ParameterTemplateAdmin(ImportExportModelAdmin):
search_fields = ('name', 'units')
class ParameterResource(ModelResource):
class ParameterResource(InvenTreeResource):
"""Class for managing PartParameter data import/export."""
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))

View File

@ -810,6 +810,53 @@ class PartFilter(rest_filters.FilterSet):
return queryset
convert_from = rest_filters.ModelChoiceFilter(label="Can convert from", queryset=Part.objects.all(), method='filter_convert_from')
def filter_convert_from(self, queryset, name, part):
"""Limit the queryset to valid conversion options for the specified part"""
conversion_options = part.get_conversion_options()
queryset = queryset.filter(pk__in=conversion_options)
return queryset
exclude_tree = rest_filters.ModelChoiceFilter(label="Exclude Part tree", queryset=Part.objects.all(), method='filter_exclude_tree')
def filter_exclude_tree(self, queryset, name, part):
"""Exclude all parts and variants 'down' from the specified part from the queryset"""
children = part.get_descendants(include_self=True)
queryset = queryset.exclude(id__in=children)
return queryset
ancestor = rest_filters.ModelChoiceFilter(label='Ancestor', queryset=Part.objects.all(), method='filter_ancestor')
def filter_ancestor(self, queryset, name, part):
"""Limit queryset to descendants of the specified ancestor part"""
descendants = part.get_descendants(include_self=False)
queryset = queryset.filter(id__in=descendants)
return queryset
variant_of = rest_filters.ModelChoiceFilter(label='Variant Of', queryset=Part.objects.all(), method='filter_variant_of')
def filter_variant_of(self, queryset, name, part):
"""Limit queryset to direct children (variants) of the specified part"""
queryset = queryset.filter(id__in=part.get_children())
return queryset
in_bom_for = rest_filters.ModelChoiceFilter(label='In BOM Of', queryset=Part.objects.all(), method='filter_in_bom')
def filter_in_bom(self, queryset, name, part):
"""Limit queryset to parts in the BOM for the specified part"""
queryset = queryset.filter(id__in=part.get_parts_in_bom())
return queryset
is_template = rest_filters.BooleanFilter()
assembly = rest_filters.BooleanFilter()
@ -1129,61 +1176,6 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
queryset = queryset.exclude(pk__in=id_values)
# Exclude part variant tree?
exclude_tree = params.get('exclude_tree', None)
if exclude_tree is not None:
try:
top_level_part = Part.objects.get(pk=exclude_tree)
queryset = queryset.exclude(
pk__in=[prt.pk for prt in top_level_part.get_descendants(include_self=True)]
)
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'ancestor'?
ancestor = params.get('ancestor', None)
if ancestor is not None:
# If an 'ancestor' part is provided, filter to match only children
try:
ancestor = Part.objects.get(pk=ancestor)
descendants = ancestor.get_descendants(include_self=False)
queryset = queryset.filter(pk__in=[d.pk for d in descendants])
except (ValueError, Part.DoesNotExist):
pass
# Filter by 'variant_of'
# Note that this is subtly different from 'ancestor' filter (above)
variant_of = params.get('variant_of', None)
if variant_of is not None:
try:
template = Part.objects.get(pk=variant_of)
variants = template.get_children()
queryset = queryset.filter(pk__in=[v.pk for v in variants])
except (ValueError, Part.DoesNotExist):
pass
# Filter only parts which are in the "BOM" for a given part
in_bom_for = params.get('in_bom_for', None)
if in_bom_for is not None:
try:
in_bom_for = Part.objects.get(pk=in_bom_for)
# Extract a list of parts within the BOM
bom_parts = in_bom_for.get_parts_in_bom()
print("bom_parts:", bom_parts)
print([p.pk for p in bom_parts])
queryset = queryset.filter(pk__in=[p.pk for p in bom_parts])
except (ValueError, Part.DoesNotExist):
pass
# Filter by whether the BOM has been validated (or not)
bom_valid = params.get('bom_valid', None)

View File

@ -391,6 +391,64 @@ class PartAPITest(InvenTreeAPITestCase):
response = self.get(url, {'related': 1}, expected_code=200)
self.assertEqual(len(response.data), 2)
def test_filter_by_convert(self):
"""Test that we can correctly filter the Part list by conversion options"""
category = PartCategory.objects.get(pk=3)
# First, construct a set of template / variant parts
master_part = Part.objects.create(
name='Master', description='Master part',
category=category,
is_template=True,
)
# Construct a set of variant parts
variants = []
for color in ['Red', 'Green', 'Blue', 'Yellow', 'Pink', 'Black']:
variants.append(Part.objects.create(
name=f"{color} Variant", description="Variant part with a specific color",
variant_of=master_part,
category=category,
))
url = reverse('api-part-list')
# An invalid part ID will return an error
response = self.get(
url,
{
'convert_from': 999999,
},
expected_code=400
)
self.assertIn('Select a valid choice', str(response.data['convert_from']))
for variant in variants:
response = self.get(
url,
{
'convert_from': variant.pk,
},
expected_code=200
)
# There should be the same number of results for each request
self.assertEqual(len(response.data), 6)
id_values = [p['pk'] for p in response.data]
self.assertIn(master_part.pk, id_values)
for v in variants:
# Check that all *other* variants are included also
if v == variant:
continue
self.assertIn(v.pk, id_values)
def test_include_children(self):
"""Test the special 'include_child_categories' flag.

View File

@ -5,10 +5,10 @@ from django.contrib import admin
import import_export.widgets as widgets
from import_export.admin import ImportExportModelAdmin
from import_export.fields import Field
from import_export.resources import ModelResource
from build.models import Build
from company.models import Company, SupplierPart
from InvenTree.admin import InvenTreeResource
from order.models import PurchaseOrder, SalesOrder
from part.models import Part
@ -16,7 +16,7 @@ from .models import (StockItem, StockItemAttachment, StockItemTestResult,
StockItemTracking, StockLocation)
class LocationResource(ModelResource):
class LocationResource(InvenTreeResource):
"""Class for managing StockLocation data import/export."""
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockLocation))
@ -68,7 +68,7 @@ class LocationAdmin(ImportExportModelAdmin):
]
class StockItemResource(ModelResource):
class StockItemResource(InvenTreeResource):
"""Class for managing StockItem data import/export."""
# Custom managers for ForeignKey fields

View File

@ -129,6 +129,12 @@ class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView):
serializer_class = StockSerializers.UninstallStockItemSerializer
class StockItemConvert(StockItemContextMixin, generics.CreateAPIView):
"""API endpoint for converting a stock item to a variant part"""
serializer_class = StockSerializers.ConvertStockItemSerializer
class StockItemReturn(StockItemContextMixin, generics.CreateAPIView):
"""API endpoint for returning a stock item from a customer"""
@ -1374,6 +1380,7 @@ stock_api_urls = [
# Detail views for a single stock item
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^convert/', StockItemConvert.as_view(), name='api-stock-item-convert'),
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),
re_path(r'^return/', StockItemReturn.as_view(), name='api-stock-item-return'),

View File

@ -1,20 +0,0 @@
"""Django Forms for interacting with Stock app."""
from InvenTree.forms import HelperForm
from .models import StockItem
class ConvertStockItemForm(HelperForm):
"""Form for converting a StockItem to a variant of its current part.
TODO: Migrate this form to the modern API forms interface
"""
class Meta:
"""Metaclass options."""
model = StockItem
fields = [
'part'
]

View File

@ -17,6 +17,7 @@ import common.models
import company.models
import InvenTree.helpers
import InvenTree.serializers
import part.models as part_models
from common.settings import currency_code_default, currency_code_mappings
from company.serializers import SupplierPartSerializer
from InvenTree.serializers import InvenTreeDecimalField, extract_int
@ -464,6 +465,45 @@ class UninstallStockItemSerializer(serializers.Serializer):
)
class ConvertStockItemSerializer(serializers.Serializer):
"""DRF serializer class for converting a StockItem to a valid variant part"""
class Meta:
"""Metaclass options"""
fields = [
'part',
]
part = serializers.PrimaryKeyRelatedField(
queryset=part_models.Part.objects.all(),
label=_('Part'),
help_text=_('Select part to convert stock item into'),
many=False, required=True, allow_null=False
)
def validate_part(self, part):
"""Ensure that the provided part is a valid option for the stock item"""
stock_item = self.context['item']
valid_options = stock_item.part.get_conversion_options()
if part not in valid_options:
raise ValidationError(_("Selected part is not a valid option for conversion"))
return part
def save(self):
"""Save the serializer to convert the StockItem to the selected Part"""
data = self.validated_data
part = data['part']
stock_item = self.context['item']
request = self.context['request']
stock_item.convert_to_variant(part, request.user)
class ReturnStockItemSerializer(serializers.Serializer):
"""DRF serializer for returning a stock item from a customer"""

View File

@ -588,9 +588,31 @@ $("#stock-delete").click(function () {
{% if item.part.can_convert %}
$("#stock-convert").click(function() {
launchModalForm("{% url 'stock-item-convert' item.id %}",
var html = `
<div class='alert alert-block alert-info'>
{% trans "Select one of the part variants listed below." %}
</div>
<div class='alert alert-block alert-warning'>
<strong>{% trans "Warning" %}</strong>
{% trans "This action cannot be easily undone" %}
</div>
`;
constructForm(
'{% url "api-stock-item-convert" item.pk %}',
{
method: 'POST',
title: '{% trans "Convert Stock Item" %}',
preFormContent: html,
reload: true,
fields: {
part: {
filters: {
convert_from: {{ item.part.pk }}
}
},
}
}
);
});

View File

@ -1,17 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<div class='alert alert-block alert-info'>
<strong>{% trans "Convert Stock Item" %}</strong><br>
{% blocktrans with part=item.part %}This stock item is current an instance of <em>{{part}}</em>{% endblocktrans %}<br>
{% trans "It can be converted to one of the part variants listed below." %}
</div>
<div class='alert alert-block alert-warning'>
<strong>{% trans "Warning" %}</strong>
{% trans "This action cannot be easily undone" %}
</div>
{% endblock %}

View File

@ -702,6 +702,69 @@ class StockItemTest(StockAPITestCase):
# The item is now in stock
self.assertIsNone(item.customer)
def test_convert_to_variant(self):
"""Test that we can convert a StockItem to a variant part via the API"""
category = part.models.PartCategory.objects.get(pk=3)
# First, construct a set of template / variant parts
master_part = part.models.Part.objects.create(
name='Master', description='Master part',
category=category,
is_template=True,
)
variants = []
# Construct a set of variant parts
for color in ['Red', 'Green', 'Blue', 'Yellow', 'Pink', 'Black']:
variants.append(part.models.Part.objects.create(
name=f"{color} Variant", description="Variant part with a specific color",
variant_of=master_part,
category=category,
))
stock_item = StockItem.objects.create(
part=master_part,
quantity=1000,
)
url = reverse('api-stock-item-convert', kwargs={'pk': stock_item.pk})
# Attempt to convert to a part which does not exist
response = self.post(
url,
{
'part': 999999,
},
expected_code=400,
)
self.assertIn('object does not exist', str(response.data['part']))
# Attempt to convert to a part which is not a valid option
response = self.post(
url,
{
'part': 1,
},
expected_code=400
)
self.assertIn('Selected part is not a valid option', str(response.data['part']))
for variant in variants:
response = self.post(
url,
{
'part': variant.pk,
},
expected_code=201,
)
stock_item.refresh_from_db()
self.assertEqual(stock_item.part, variant)
class StocktakeTest(StockAPITestCase):
"""Series of tests for the Stocktake API."""

View File

@ -16,7 +16,6 @@ location_urls = [
]
stock_item_detail_urls = [
re_path(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
re_path(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
# Anything else - direct to the item detail view

View File

@ -6,10 +6,9 @@ from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
import common.settings
from InvenTree.views import AjaxUpdateView, InvenTreeRoleMixin, QRCodeView
from InvenTree.views import InvenTreeRoleMixin, QRCodeView
from plugin.views import InvenTreePluginViewMixin
from . import forms as StockForms
from .models import StockItem, StockLocation
@ -133,32 +132,3 @@ class StockItemQRCode(QRCodeView):
return item.format_barcode()
except StockItem.DoesNotExist:
return None
class StockItemConvert(AjaxUpdateView):
"""View for 'converting' a StockItem to a variant of its current part."""
model = StockItem
form_class = StockForms.ConvertStockItemForm
ajax_form_title = _('Convert Stock Item')
ajax_template_name = 'stock/stockitem_convert.html'
context_object_name = 'item'
def get_form(self):
"""Filter the available parts."""
form = super().get_form()
item = self.get_object()
form.fields['part'].queryset = item.part.get_conversion_options()
return form
def save(self, obj, form):
"""Convert item to variant."""
stock_item = self.get_object()
variant = form.cleaned_data.get('part', None)
stock_item.convert_to_variant(variant, user=self.request.user)
return stock_item

View File

@ -228,7 +228,7 @@ function loadAttachmentTable(url, options) {
var html = `<span class='fas ${icon}'></span> ${filename}`;
return renderLink(html, value);
return renderLink(html, value, {download: true});
} else if (row.link) {
var html = `<span class='fas fa-link'></span> ${row.link}`;
return renderLink(html, row.link);

View File

@ -204,6 +204,9 @@ function constructChangeForm(fields, options) {
},
success: function(data) {
// Ensure the data are fully sanitized before we operate on it
data = sanitizeData(data);
// An optional function can be provided to process the returned results,
// before they are rendered to the form
if (options.processResults) {

View File

@ -1358,7 +1358,8 @@ function loadStockTestResultsTable(table, options) {
var html = value;
if (row.attachment) {
html += `<a href='${row.attachment}'><span class='fas fa-file-alt float-right'></span></a>`;
var text = `<span class='fas fa-file-alt float-right'></span>`;
html += renderLink(text, row.attachment, {download: true});
}
return html;

View File

@ -184,6 +184,13 @@ function renderLink(text, url, options={}) {
var max_length = options.max_length || -1;
var extra = '';
if (options.download) {
var fn = url.split('/').at(-1);
extra += ` download='${fn}'`;
}
// Shorten the displayed length if required
if ((max_length > 0) && (text.length > max_length)) {
var slice_length = (max_length - 3) / 2;
@ -194,7 +201,7 @@ function renderLink(text, url, options={}) {
text = `${text_start}...${text_end}`;
}
return '<a href="' + url + '">' + text + '</a>';
return `<a href='${url}'${extra}>${text}</a>`;
}
@ -374,6 +381,8 @@ $.fn.inventreeTable = function(options) {
// Extract query params
var filters = options.queryParams || options.filters || {};
options.escape = true;
// Store the total set of query params
options.query_params = filters;
@ -560,6 +569,49 @@ function customGroupSorter(sortName, sortOrder, sortData) {
$.extend($.fn.bootstrapTable.defaults, $.fn.bootstrapTable.locales['en-US-custom']);
// Enable HTML escaping by default
$.fn.bootstrapTable.escape = true;
// Override the 'calculateObjectValue' function at bootstrap-table.js:3525
// Allows us to escape any nasty HTML tags which are rendered to the DOM
$.fn.bootstrapTable.utils._calculateObjectValue = $.fn.bootstrapTable.utils.calculateObjectValue;
$.fn.bootstrapTable.utils.calculateObjectValue = function escapeCellValue(self, name, args, defaultValue) {
var args_list = [];
if (args) {
args_list.push(args[0]);
if (name && typeof(name) === 'function' && name.name == 'formatter') {
/* This is a custom "formatter" function for a particular cell,
* which may side-step regular HTML escaping, and inject malicious code into the DOM.
*
* Here we have access to the 'args' supplied to the custom 'formatter' function,
* which are in the order:
* args = [value, row, index, field]
*
* 'row' is the one we are interested in
*/
var row = Object.assign({}, args[1]);
args_list.push(sanitizeData(row));
} else {
args_list.push(args[1]);
}
for (var ii = 2; ii < args.length; ii++) {
args_list.push(args[ii]);
}
}
var value = $.fn.bootstrapTable.utils._calculateObjectValue(self, name, args_list, defaultValue);
return value;
};
})(jQuery);
$.extend($.fn.treegrid.defaults, {

17
SECURITY.md Normal file
View File

@ -0,0 +1,17 @@
# Security Policy
The InvenTree team take all security vulnerabilities seriously. Thank you for improving the security of our open source software.
We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions.
## Reporting a Vulnerability
Please report security vulnerabilities by emailing the InvenTree team at:
```
security@inventree.org
```
Someone from the InvenTree development team will acknowledge your email as soon as possible, and indicate the next steps in handling your security report.
The team will endeavour to keep you informed of the progress towards a fix for the issue, and subsequent release to the stable and development code branches. Where possible, the issue will be resolved within 90 days of reporting.