mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
47f39079fd
4
.github/release.yml
vendored
4
.github/release.yml
vendored
@ -9,6 +9,9 @@ changelog:
|
|||||||
labels:
|
labels:
|
||||||
- Semver-Major
|
- Semver-Major
|
||||||
- breaking
|
- breaking
|
||||||
|
- title: Security Patches
|
||||||
|
labels:
|
||||||
|
- security
|
||||||
- title: New Features
|
- title: New Features
|
||||||
labels:
|
labels:
|
||||||
- Semver-Minor
|
- Semver-Minor
|
||||||
@ -23,7 +26,6 @@ changelog:
|
|||||||
- setup
|
- setup
|
||||||
- demo
|
- demo
|
||||||
- CI
|
- CI
|
||||||
- security
|
|
||||||
- title: Other Changes
|
- title: Other Changes
|
||||||
labels:
|
labels:
|
||||||
- "*"
|
- "*"
|
||||||
|
33
InvenTree/InvenTree/admin.py
Normal file
33
InvenTree/InvenTree/admin.py
Normal 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
|
@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# 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
|
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
|
v60 -> 2022-06-08 : https://github.com/inventree/InvenTree/pull/3148
|
||||||
- Add availability data fields to the SupplierPart model
|
- Add availability data fields to the SupplierPart model
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
inventreeDocReady,
|
inventreeDocReady,
|
||||||
inventreeLoad,
|
inventreeLoad,
|
||||||
inventreeSave,
|
inventreeSave,
|
||||||
|
sanitizeData,
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function attachClipboard(selector, containerselector, textElement) {
|
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, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/`/g, '`');
|
||||||
|
} else {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Convenience function to determine if an element exists
|
// Convenience function to determine if an element exists
|
||||||
$.fn.exists = function() {
|
$.fn.exists = function() {
|
||||||
return this.length !== 0;
|
return this.length !== 0;
|
||||||
|
@ -4,15 +4,14 @@ from django.contrib import admin
|
|||||||
|
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
from import_export.fields import Field
|
from import_export.fields import Field
|
||||||
from import_export.resources import ModelResource
|
|
||||||
import import_export.widgets as widgets
|
import import_export.widgets as widgets
|
||||||
|
|
||||||
from build.models import Build, BuildItem
|
from build.models import Build, BuildItem
|
||||||
|
from InvenTree.admin import InvenTreeResource
|
||||||
import part.models
|
import part.models
|
||||||
|
|
||||||
|
|
||||||
class BuildResource(ModelResource):
|
class BuildResource(InvenTreeResource):
|
||||||
"""Class for managing import/export of Build data."""
|
"""Class for managing import/export of Build data."""
|
||||||
# For some reason, we need to specify the fields individually for this ModelResource,
|
# For some reason, we need to specify the fields individually for this ModelResource,
|
||||||
# but we don't for other ones.
|
# but we don't for other ones.
|
||||||
|
@ -5,8 +5,8 @@ from django.contrib import admin
|
|||||||
import import_export.widgets as widgets
|
import import_export.widgets as widgets
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
from import_export.fields import Field
|
from import_export.fields import Field
|
||||||
from import_export.resources import ModelResource
|
|
||||||
|
|
||||||
|
from InvenTree.admin import InvenTreeResource
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
|
||||||
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
||||||
@ -14,7 +14,7 @@ from .models import (Company, ManufacturerPart, ManufacturerPartAttachment,
|
|||||||
SupplierPriceBreak)
|
SupplierPriceBreak)
|
||||||
|
|
||||||
|
|
||||||
class CompanyResource(ModelResource):
|
class CompanyResource(InvenTreeResource):
|
||||||
"""Class for managing Company data import/export."""
|
"""Class for managing Company data import/export."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -38,7 +38,7 @@ class CompanyAdmin(ImportExportModelAdmin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class SupplierPartResource(ModelResource):
|
class SupplierPartResource(InvenTreeResource):
|
||||||
"""Class for managing SupplierPart data import/export."""
|
"""Class for managing SupplierPart data import/export."""
|
||||||
|
|
||||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||||
@ -74,7 +74,7 @@ class SupplierPartAdmin(ImportExportModelAdmin):
|
|||||||
autocomplete_fields = ('part', 'supplier', 'manufacturer_part',)
|
autocomplete_fields = ('part', 'supplier', 'manufacturer_part',)
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPartResource(ModelResource):
|
class ManufacturerPartResource(InvenTreeResource):
|
||||||
"""Class for managing ManufacturerPart data import/export."""
|
"""Class for managing ManufacturerPart data import/export."""
|
||||||
|
|
||||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
|
||||||
@ -117,7 +117,7 @@ class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
|
|||||||
autocomplete_fields = ('manufacturer_part',)
|
autocomplete_fields = ('manufacturer_part',)
|
||||||
|
|
||||||
|
|
||||||
class ManufacturerPartParameterResource(ModelResource):
|
class ManufacturerPartParameterResource(InvenTreeResource):
|
||||||
"""Class for managing ManufacturerPartParameter data import/export."""
|
"""Class for managing ManufacturerPartParameter data import/export."""
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -144,7 +144,7 @@ class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
|
|||||||
autocomplete_fields = ('manufacturer_part',)
|
autocomplete_fields = ('manufacturer_part',)
|
||||||
|
|
||||||
|
|
||||||
class SupplierPriceBreakResource(ModelResource):
|
class SupplierPriceBreakResource(InvenTreeResource):
|
||||||
"""Class for managing SupplierPriceBreak data import/export."""
|
"""Class for managing SupplierPriceBreak data import/export."""
|
||||||
|
|
||||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))
|
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))
|
||||||
|
@ -5,7 +5,8 @@ from django.contrib import admin
|
|||||||
import import_export.widgets as widgets
|
import import_export.widgets as widgets
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
from import_export.fields import Field
|
from import_export.fields import Field
|
||||||
from import_export.resources import ModelResource
|
|
||||||
|
from InvenTree.admin import InvenTreeResource
|
||||||
|
|
||||||
from .models import (PurchaseOrder, PurchaseOrderExtraLine,
|
from .models import (PurchaseOrder, PurchaseOrderExtraLine,
|
||||||
PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation,
|
PurchaseOrderLineItem, SalesOrder, SalesOrderAllocation,
|
||||||
@ -97,7 +98,7 @@ class SalesOrderAdmin(ImportExportModelAdmin):
|
|||||||
autocomplete_fields = ('customer',)
|
autocomplete_fields = ('customer',)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderResource(ModelResource):
|
class PurchaseOrderResource(InvenTreeResource):
|
||||||
"""Class for managing import / export of PurchaseOrder data."""
|
"""Class for managing import / export of PurchaseOrder data."""
|
||||||
|
|
||||||
# Add number of line items
|
# 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."""
|
"""Class for managing import / export of PurchaseOrderLineItem data."""
|
||||||
|
|
||||||
part_name = Field(attribute='part__part__name', readonly=True)
|
part_name = Field(attribute='part__part__name', readonly=True)
|
||||||
@ -135,7 +136,7 @@ class PurchaseOrderLineItemResource(ModelResource):
|
|||||||
clean_model_instances = True
|
clean_model_instances = True
|
||||||
|
|
||||||
|
|
||||||
class PurchaseOrderExtraLineResource(ModelResource):
|
class PurchaseOrderExtraLineResource(InvenTreeResource):
|
||||||
"""Class for managing import / export of PurchaseOrderExtraLine data."""
|
"""Class for managing import / export of PurchaseOrderExtraLine data."""
|
||||||
|
|
||||||
class Meta(GeneralExtraLineMeta):
|
class Meta(GeneralExtraLineMeta):
|
||||||
@ -144,7 +145,7 @@ class PurchaseOrderExtraLineResource(ModelResource):
|
|||||||
model = PurchaseOrderExtraLine
|
model = PurchaseOrderExtraLine
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderResource(ModelResource):
|
class SalesOrderResource(InvenTreeResource):
|
||||||
"""Class for managing import / export of SalesOrder data."""
|
"""Class for managing import / export of SalesOrder data."""
|
||||||
|
|
||||||
# Add number of line items
|
# 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."""
|
"""Class for managing import / export of SalesOrderLineItem data."""
|
||||||
|
|
||||||
part_name = Field(attribute='part__name', readonly=True)
|
part_name = Field(attribute='part__name', readonly=True)
|
||||||
@ -192,7 +193,7 @@ class SalesOrderLineItemResource(ModelResource):
|
|||||||
clean_model_instances = True
|
clean_model_instances = True
|
||||||
|
|
||||||
|
|
||||||
class SalesOrderExtraLineResource(ModelResource):
|
class SalesOrderExtraLineResource(InvenTreeResource):
|
||||||
"""Class for managing import / export of SalesOrderExtraLine data."""
|
"""Class for managing import / export of SalesOrderExtraLine data."""
|
||||||
|
|
||||||
class Meta(GeneralExtraLineMeta):
|
class Meta(GeneralExtraLineMeta):
|
||||||
|
@ -5,14 +5,14 @@ from django.contrib import admin
|
|||||||
import import_export.widgets as widgets
|
import import_export.widgets as widgets
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
from import_export.fields import Field
|
from import_export.fields import Field
|
||||||
from import_export.resources import ModelResource
|
|
||||||
|
|
||||||
import part.models as models
|
import part.models as models
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
|
from InvenTree.admin import InvenTreeResource
|
||||||
from stock.models import StockLocation
|
from stock.models import StockLocation
|
||||||
|
|
||||||
|
|
||||||
class PartResource(ModelResource):
|
class PartResource(InvenTreeResource):
|
||||||
"""Class for managing Part data import/export."""
|
"""Class for managing Part data import/export."""
|
||||||
|
|
||||||
# ForeignKey fields
|
# ForeignKey fields
|
||||||
@ -92,7 +92,7 @@ class PartAdmin(ImportExportModelAdmin):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class PartCategoryResource(ModelResource):
|
class PartCategoryResource(InvenTreeResource):
|
||||||
"""Class for managing PartCategory data import/export."""
|
"""Class for managing PartCategory data import/export."""
|
||||||
|
|
||||||
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(models.PartCategory))
|
||||||
@ -157,7 +157,7 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
|
|||||||
autocomplete_fields = ('part',)
|
autocomplete_fields = ('part',)
|
||||||
|
|
||||||
|
|
||||||
class BomItemResource(ModelResource):
|
class BomItemResource(InvenTreeResource):
|
||||||
"""Class for managing BomItem data import/export."""
|
"""Class for managing BomItem data import/export."""
|
||||||
|
|
||||||
level = Field(attribute='level', readonly=True)
|
level = Field(attribute='level', readonly=True)
|
||||||
@ -266,7 +266,7 @@ class ParameterTemplateAdmin(ImportExportModelAdmin):
|
|||||||
search_fields = ('name', 'units')
|
search_fields = ('name', 'units')
|
||||||
|
|
||||||
|
|
||||||
class ParameterResource(ModelResource):
|
class ParameterResource(InvenTreeResource):
|
||||||
"""Class for managing PartParameter data import/export."""
|
"""Class for managing PartParameter data import/export."""
|
||||||
|
|
||||||
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
|
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(models.Part))
|
||||||
|
@ -810,6 +810,53 @@ class PartFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
return queryset
|
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()
|
is_template = rest_filters.BooleanFilter()
|
||||||
|
|
||||||
assembly = rest_filters.BooleanFilter()
|
assembly = rest_filters.BooleanFilter()
|
||||||
@ -1129,61 +1176,6 @@ class PartList(APIDownloadMixin, generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = queryset.exclude(pk__in=id_values)
|
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)
|
# Filter by whether the BOM has been validated (or not)
|
||||||
bom_valid = params.get('bom_valid', None)
|
bom_valid = params.get('bom_valid', None)
|
||||||
|
|
||||||
|
@ -391,6 +391,64 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
response = self.get(url, {'related': 1}, expected_code=200)
|
response = self.get(url, {'related': 1}, expected_code=200)
|
||||||
self.assertEqual(len(response.data), 2)
|
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):
|
def test_include_children(self):
|
||||||
"""Test the special 'include_child_categories' flag.
|
"""Test the special 'include_child_categories' flag.
|
||||||
|
|
||||||
|
@ -5,10 +5,10 @@ from django.contrib import admin
|
|||||||
import import_export.widgets as widgets
|
import import_export.widgets as widgets
|
||||||
from import_export.admin import ImportExportModelAdmin
|
from import_export.admin import ImportExportModelAdmin
|
||||||
from import_export.fields import Field
|
from import_export.fields import Field
|
||||||
from import_export.resources import ModelResource
|
|
||||||
|
|
||||||
from build.models import Build
|
from build.models import Build
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
|
from InvenTree.admin import InvenTreeResource
|
||||||
from order.models import PurchaseOrder, SalesOrder
|
from order.models import PurchaseOrder, SalesOrder
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
|
|
||||||
@ -16,7 +16,7 @@ from .models import (StockItem, StockItemAttachment, StockItemTestResult,
|
|||||||
StockItemTracking, StockLocation)
|
StockItemTracking, StockLocation)
|
||||||
|
|
||||||
|
|
||||||
class LocationResource(ModelResource):
|
class LocationResource(InvenTreeResource):
|
||||||
"""Class for managing StockLocation data import/export."""
|
"""Class for managing StockLocation data import/export."""
|
||||||
|
|
||||||
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockLocation))
|
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."""
|
"""Class for managing StockItem data import/export."""
|
||||||
|
|
||||||
# Custom managers for ForeignKey fields
|
# Custom managers for ForeignKey fields
|
||||||
|
@ -129,6 +129,12 @@ class StockItemUninstall(StockItemContextMixin, generics.CreateAPIView):
|
|||||||
serializer_class = StockSerializers.UninstallStockItemSerializer
|
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):
|
class StockItemReturn(StockItemContextMixin, generics.CreateAPIView):
|
||||||
"""API endpoint for returning a stock item from a customer"""
|
"""API endpoint for returning a stock item from a customer"""
|
||||||
|
|
||||||
@ -1374,6 +1380,7 @@ stock_api_urls = [
|
|||||||
|
|
||||||
# Detail views for a single stock item
|
# Detail views for a single stock item
|
||||||
re_path(r'^(?P<pk>\d+)/', include([
|
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'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
|
||||||
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),
|
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),
|
||||||
re_path(r'^return/', StockItemReturn.as_view(), name='api-stock-item-return'),
|
re_path(r'^return/', StockItemReturn.as_view(), name='api-stock-item-return'),
|
||||||
|
@ -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'
|
|
||||||
]
|
|
@ -17,6 +17,7 @@ import common.models
|
|||||||
import company.models
|
import company.models
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.serializers
|
import InvenTree.serializers
|
||||||
|
import part.models as part_models
|
||||||
from common.settings import currency_code_default, currency_code_mappings
|
from common.settings import currency_code_default, currency_code_mappings
|
||||||
from company.serializers import SupplierPartSerializer
|
from company.serializers import SupplierPartSerializer
|
||||||
from InvenTree.serializers import InvenTreeDecimalField, extract_int
|
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):
|
class ReturnStockItemSerializer(serializers.Serializer):
|
||||||
"""DRF serializer for returning a stock item from a customer"""
|
"""DRF serializer for returning a stock item from a customer"""
|
||||||
|
|
||||||
|
@ -588,9 +588,31 @@ $("#stock-delete").click(function () {
|
|||||||
|
|
||||||
{% if item.part.can_convert %}
|
{% if item.part.can_convert %}
|
||||||
$("#stock-convert").click(function() {
|
$("#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,
|
reload: true,
|
||||||
|
fields: {
|
||||||
|
part: {
|
||||||
|
filters: {
|
||||||
|
convert_from: {{ item.part.pk }}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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 %}
|
|
@ -702,6 +702,69 @@ class StockItemTest(StockAPITestCase):
|
|||||||
# The item is now in stock
|
# The item is now in stock
|
||||||
self.assertIsNone(item.customer)
|
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):
|
class StocktakeTest(StockAPITestCase):
|
||||||
"""Series of tests for the Stocktake API."""
|
"""Series of tests for the Stocktake API."""
|
||||||
|
@ -16,7 +16,6 @@ location_urls = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
stock_item_detail_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'),
|
re_path(r'^qr_code/', views.StockItemQRCode.as_view(), name='stock-item-qr'),
|
||||||
|
|
||||||
# Anything else - direct to the item detail view
|
# Anything else - direct to the item detail view
|
||||||
|
@ -6,10 +6,9 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
import common.settings
|
import common.settings
|
||||||
from InvenTree.views import AjaxUpdateView, InvenTreeRoleMixin, QRCodeView
|
from InvenTree.views import InvenTreeRoleMixin, QRCodeView
|
||||||
from plugin.views import InvenTreePluginViewMixin
|
from plugin.views import InvenTreePluginViewMixin
|
||||||
|
|
||||||
from . import forms as StockForms
|
|
||||||
from .models import StockItem, StockLocation
|
from .models import StockItem, StockLocation
|
||||||
|
|
||||||
|
|
||||||
@ -133,32 +132,3 @@ class StockItemQRCode(QRCodeView):
|
|||||||
return item.format_barcode()
|
return item.format_barcode()
|
||||||
except StockItem.DoesNotExist:
|
except StockItem.DoesNotExist:
|
||||||
return None
|
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
|
|
||||||
|
@ -228,7 +228,7 @@ function loadAttachmentTable(url, options) {
|
|||||||
|
|
||||||
var html = `<span class='fas ${icon}'></span> ${filename}`;
|
var html = `<span class='fas ${icon}'></span> ${filename}`;
|
||||||
|
|
||||||
return renderLink(html, value);
|
return renderLink(html, value, {download: true});
|
||||||
} else if (row.link) {
|
} else if (row.link) {
|
||||||
var html = `<span class='fas fa-link'></span> ${row.link}`;
|
var html = `<span class='fas fa-link'></span> ${row.link}`;
|
||||||
return renderLink(html, row.link);
|
return renderLink(html, row.link);
|
||||||
|
@ -204,6 +204,9 @@ function constructChangeForm(fields, options) {
|
|||||||
},
|
},
|
||||||
success: function(data) {
|
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,
|
// An optional function can be provided to process the returned results,
|
||||||
// before they are rendered to the form
|
// before they are rendered to the form
|
||||||
if (options.processResults) {
|
if (options.processResults) {
|
||||||
|
@ -1358,7 +1358,8 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
var html = value;
|
var html = value;
|
||||||
|
|
||||||
if (row.attachment) {
|
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;
|
return html;
|
||||||
|
@ -184,6 +184,13 @@ function renderLink(text, url, options={}) {
|
|||||||
|
|
||||||
var max_length = options.max_length || -1;
|
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
|
// Shorten the displayed length if required
|
||||||
if ((max_length > 0) && (text.length > max_length)) {
|
if ((max_length > 0) && (text.length > max_length)) {
|
||||||
var slice_length = (max_length - 3) / 2;
|
var slice_length = (max_length - 3) / 2;
|
||||||
@ -194,7 +201,7 @@ function renderLink(text, url, options={}) {
|
|||||||
text = `${text_start}...${text_end}`;
|
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
|
// Extract query params
|
||||||
var filters = options.queryParams || options.filters || {};
|
var filters = options.queryParams || options.filters || {};
|
||||||
|
|
||||||
|
options.escape = true;
|
||||||
|
|
||||||
// Store the total set of query params
|
// Store the total set of query params
|
||||||
options.query_params = filters;
|
options.query_params = filters;
|
||||||
|
|
||||||
@ -560,6 +569,49 @@ function customGroupSorter(sortName, sortOrder, sortData) {
|
|||||||
|
|
||||||
$.extend($.fn.bootstrapTable.defaults, $.fn.bootstrapTable.locales['en-US-custom']);
|
$.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);
|
})(jQuery);
|
||||||
|
|
||||||
$.extend($.fn.treegrid.defaults, {
|
$.extend($.fn.treegrid.defaults, {
|
||||||
|
17
SECURITY.md
Normal file
17
SECURITY.md
Normal 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.
|
Loading…
Reference in New Issue
Block a user