diff --git a/.github/release.yml b/.github/release.yml index d691460313..34562cd9b7 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -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: - "*" diff --git a/InvenTree/InvenTree/admin.py b/InvenTree/InvenTree/admin.py new file mode 100644 index 0000000000..2d5798a9d1 --- /dev/null +++ b/InvenTree/InvenTree/admin.py @@ -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 diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 2daa9e3da7..b7ce609491 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 7999e4a7be..32e94c1a24 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -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, '>').replace(/"/g, '"').replace(/'/g, ''').replace(/`/g, '`'); + } else { + return data; + } +} + + // Convenience function to determine if an element exists $.fn.exists = function() { return this.length !== 0; diff --git a/InvenTree/build/admin.py b/InvenTree/build/admin.py index eec7376ede..6f203d071b 100644 --- a/InvenTree/build/admin.py +++ b/InvenTree/build/admin.py @@ -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. diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index d279c9227d..11e9a2720e 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -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)) diff --git a/InvenTree/order/admin.py b/InvenTree/order/admin.py index be953de701..aa24c095f6 100644 --- a/InvenTree/order/admin.py +++ b/InvenTree/order/admin.py @@ -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): diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index 9c1648e616..bf4ae571f5 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -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)) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 23b2955744..7f9b0d150d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 1c8aa694d3..3d2c0d8a06 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -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. diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index f3c56553c5..270281ae3e 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -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 diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 13b3c0219e..e3fe14d6b4 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -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\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'), diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py deleted file mode 100644 index e091730deb..0000000000 --- a/InvenTree/stock/forms.py +++ /dev/null @@ -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' - ] diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index aebc102e79..a5a39ab146 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -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""" diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 19964e8b29..ff8c7687b7 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -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 = ` +
+ {% trans "Select one of the part variants listed below." %} +
+
+ {% trans "Warning" %} + {% trans "This action cannot be easily undone" %} +
+ `; + + 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 }} + } + }, + } } ); }); diff --git a/InvenTree/stock/templates/stock/stockitem_convert.html b/InvenTree/stock/templates/stock/stockitem_convert.html deleted file mode 100644 index 90c3fd8e1e..0000000000 --- a/InvenTree/stock/templates/stock/stockitem_convert.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -
- {% trans "Convert Stock Item" %}
- {% blocktrans with part=item.part %}This stock item is current an instance of {{part}}{% endblocktrans %}
- {% trans "It can be converted to one of the part variants listed below." %} -
- -
- {% trans "Warning" %} - {% trans "This action cannot be easily undone" %} -
- -{% endblock %} diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 93f35b78e1..e35eabc7df 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -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.""" diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 6c4eec8d7e..b61bd8eb60 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -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 diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 267b8734d1..6177972259 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -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 diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js index 0dc3f438af..991d3efeba 100644 --- a/InvenTree/templates/js/translated/attachment.js +++ b/InvenTree/templates/js/translated/attachment.js @@ -228,7 +228,7 @@ function loadAttachmentTable(url, options) { var html = ` ${filename}`; - return renderLink(html, value); + return renderLink(html, value, {download: true}); } else if (row.link) { var html = ` ${row.link}`; return renderLink(html, row.link); diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 9f536fd548..fb9f422b67 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -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) { diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index 825e1a8094..35de58dd97 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -1358,7 +1358,8 @@ function loadStockTestResultsTable(table, options) { var html = value; if (row.attachment) { - html += ``; + var text = ``; + html += renderLink(text, row.attachment, {download: true}); } return html; diff --git a/InvenTree/templates/js/translated/tables.js b/InvenTree/templates/js/translated/tables.js index fcbaba7336..b65d46b283 100644 --- a/InvenTree/templates/js/translated/tables.js +++ b/InvenTree/templates/js/translated/tables.js @@ -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 '' + text + ''; + return `${text}`; } @@ -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, { diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000000..6054604914 --- /dev/null +++ b/SECURITY.md @@ -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.