From 4d8311682c2b646700eb0a87ecffda6e4f38467c Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 20 Mar 2023 07:21:27 +1100 Subject: [PATCH] Search refactor (#4505) * Adds 'global search' endpoint which allows for a single API request for multi-model search * Pass 'search' and 'offset' params through to list methods * Refactor model renderer function selection * Refactor existing javascript * Update API version * javascript cleanup * Refactor model rendering code - Pipe into a single function for consistent display - Add link rendering support * Improve UX for searching * JS linting * Fix bug caused by typo * Fix link rendering for company * Adds unit testing for global search endpoint --- InvenTree/InvenTree/api.py | 104 ++++ InvenTree/InvenTree/api_version.py | 5 +- InvenTree/InvenTree/test_api.py | 130 +++++ InvenTree/InvenTree/urls.py | 8 +- InvenTree/templates/js/translated/forms.js | 76 +-- InvenTree/templates/js/translated/helpers.js | 2 +- .../js/translated/model_renderers.js | 530 +++++++++--------- InvenTree/templates/js/translated/part.js | 2 +- InvenTree/templates/js/translated/search.js | 302 +++++----- InvenTree/templates/search.html | 19 +- InvenTree/users/models.py | 9 +- 11 files changed, 647 insertions(+), 540 deletions(-) diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index 51dcfe4fa6..eb45bb94f3 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -10,7 +10,9 @@ from django_q.models import OrmQ from rest_framework import filters, permissions from rest_framework.response import Response from rest_framework.serializers import ValidationError +from rest_framework.views import APIView +import users.models from InvenTree.mixins import ListCreateAPI from InvenTree.permissions import RolePermission from part.templatetags.inventree_extras import plugins_info @@ -208,3 +210,105 @@ class AttachmentMixin: attachment = serializer.save() attachment.user = self.request.user attachment.save() + + +class APISearchView(APIView): + """A general-purpose 'search' API endpoint + + Returns hits against a number of different models simultaneously, + to consolidate multiple API requests into a single query. + + Is much more efficient and simplifies code! + """ + + permission_classes = [ + permissions.IsAuthenticated, + ] + + def get_result_types(self): + """Construct a list of search types we can return""" + + import build.api + import company.api + import order.api + import part.api + import stock.api + + return { + 'build': build.api.BuildList, + 'company': company.api.CompanyList, + 'manufacturerpart': company.api.ManufacturerPartList, + 'supplierpart': company.api.SupplierPartList, + 'part': part.api.PartList, + 'partcategory': part.api.CategoryList, + 'purchaseorder': order.api.PurchaseOrderList, + 'salesorder': order.api.SalesOrderList, + 'stockitem': stock.api.StockList, + 'stocklocation': stock.api.StockLocationList, + } + + def post(self, request, *args, **kwargs): + """Perform search query against available models""" + + data = request.data + + search = data.get('search', '') + + # Enforce a 'limit' parameter + try: + limit = int(data.get('limit', 1)) + except ValueError: + limit = 1 + + try: + offset = int(data.get('offset', 0)) + except ValueError: + offset = 0 + + results = {} + + for key, cls in self.get_result_types().items(): + # Only return results which are specifically requested + if key in data: + + params = data[key] + + params['search'] = search + + # Enforce limit + params['limit'] = limit + params['offset'] = offset + + # Enforce json encoding + params['format'] = 'json' + + # Ignore if the params are wrong + if type(params) is not dict: + continue + + view = cls() + + # Override regular query params with specific ones for this search request + request._request.GET = params + view.request = request + view.format_kwarg = 'format' + + # Check permissions and update results dict with particular query + model = view.serializer_class.Meta.model + app_label = model._meta.app_label + model_name = model._meta.model_name + table = f'{app_label}_{model_name}' + + try: + if users.models.RuleSet.check_table_permission(request.user, table, 'view'): + results[key] = view.list(request, *args, **kwargs).data + else: + results[key] = { + 'error': _('User does not have permission to view this model') + } + except Exception as exc: + results[key] = { + 'error': str(exc) + } + + return Response(results) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 5777f97afa..78f0fb15e5 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,14 @@ # InvenTree API version -INVENTREE_API_VERSION = 101 +INVENTREE_API_VERSION = 102 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v102 -> 2023-03-18 : https://github.com/inventree/InvenTree/pull/4505 + - Adds global search API endpoint for consolidated search results + v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462 - Adds 'total_in_stock' to Part serializer, and supports API ordering diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index e34cd362e9..fb2209257c 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -301,3 +301,133 @@ class BulkDeleteTests(InvenTreeAPITestCase): ) self.assertIn("'filters' must be supplied as a dict object", str(response.data)) + + +class SearchTests(InvenTreeAPITestCase): + """Unit tests for global search endpoint""" + + fixtures = [ + 'category', + 'part', + 'company', + 'location', + 'supplier_part', + 'stock', + 'order', + 'sales_order', + ] + + def test_results(self): + """Test individual result types""" + + response = self.post( + reverse('api-search'), + { + 'search': 'chair', + 'limit': 3, + 'part': {}, + 'build': {}, + }, + expected_code=200 + ) + + # No build results + self.assertEqual(response.data['build']['count'], 0) + + # 3 (of 5) part results + self.assertEqual(response.data['part']['count'], 5) + self.assertEqual(len(response.data['part']['results']), 3) + + # Other results not included + self.assertNotIn('purchaseorder', response.data) + self.assertNotIn('salesorder', response.data) + + # Search for orders + response = self.post( + reverse('api-search'), + { + 'search': '01', + 'limit': 2, + 'purchaseorder': {}, + 'salesorder': {}, + }, + expected_code=200, + ) + + self.assertEqual(response.data['purchaseorder']['count'], 1) + self.assertEqual(response.data['salesorder']['count'], 0) + + self.assertNotIn('stockitem', response.data) + self.assertNotIn('build', response.data) + + def test_permissions(self): + """Test that users with insufficient permissions are handled correctly""" + + # First, remove all roles + for ruleset in self.group.rule_sets.all(): + ruleset.can_view = False + ruleset.can_change = False + ruleset.can_delete = False + ruleset.can_add = False + ruleset.save() + + models = [ + 'build', + 'company', + 'manufacturerpart', + 'supplierpart', + 'part', + 'partcategory', + 'purchaseorder', + 'stockitem', + 'stocklocation', + 'salesorder', + ] + + query = { + 'search': 'c', + 'limit': 3, + } + + for mdl in models: + query[mdl] = {} + + response = self.post( + reverse('api-search'), + query, + expected_code=200 + ) + + # Check for 'permission denied' error + for mdl in models: + self.assertEqual(response.data[mdl]['error'], 'User does not have permission to view this model') + + # Assign view roles for some parts + self.assignRole('build.view') + self.assignRole('part.view') + + response = self.post( + reverse('api-search'), + query, + expected_code=200 + ) + + # Check for expected results, based on permissions + # We expect results to be returned for the following model types + has_permission = [ + 'build', + 'manufacturerpart', + 'supplierpart', + 'part', + 'partcategory', + 'stocklocation', + 'stockitem', + ] + + for mdl in models: + result = response.data[mdl] + if mdl in has_permission: + self.assertIn('count', result) + else: + self.assertIn('error', result) + self.assertEqual(result['error'], 'User does not have permission to view this model') diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index bf3e36c8e3..047485db6d 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -30,7 +30,7 @@ from stock.api import stock_api_urls from stock.urls import stock_urls from users.api import user_urls -from .api import InfoView, NotFoundView +from .api import APISearchView, InfoView, NotFoundView from .views import (AboutView, AppearanceSelectView, CustomConnectionsView, CustomEmailView, CustomLoginView, CustomPasswordResetFromKeyView, @@ -43,6 +43,10 @@ admin.site.site_header = "InvenTree Admin" apipatterns = [ + + # Global search + path('search/', APISearchView.as_view(), name='api-search'), + re_path(r'^settings/', include(settings_api_urls)), re_path(r'^part/', include(part_api_urls)), re_path(r'^bom/', include(bom_api_urls)), @@ -58,7 +62,7 @@ apipatterns = [ # Plugin endpoints path('', include(plugin_api_urls)), - # Webhook enpoint + # Webhook endpoints path('', include(common_api_urls)), # InvenTree information endpoint diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 800a587f8c..a445d66b08 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -9,18 +9,7 @@ global_settings, modalEnable, modalShowSubmitButton, - renderBuild, - renderCompany, - renderGroup, - renderManufacturerPart, - renderOwner, - renderPart, - renderPartCategory, - renderPartParameterTemplate, - renderStockItem, - renderStockLocation, - renderSupplierPart, - renderUser, + getModelRenderer, showAlertOrCache, showApiError, */ @@ -1886,7 +1875,7 @@ function initializeRelatedField(field, fields, options={}) { // Custom formatting for the search results if (field.model) { // If the 'model' is specified, hand it off to the custom model render - var html = renderModelData(name, field.model, data, field, options); + var html = renderModelData(name, field.model, data, field); return $(html); } else { // Return a simple renderering @@ -1916,7 +1905,7 @@ function initializeRelatedField(field, fields, options={}) { // Custom formatting for selected item if (field.model) { // If the 'model' is specified, hand it off to the custom model render - var html = renderModelData(name, field.model, data, field, options); + var html = renderModelData(name, field.model, data, field); return $(html); } else { // Return a simple renderering @@ -2027,71 +2016,18 @@ function searching() { * - parameters: The field definition (OPTIONS) request * - options: Other options provided at time of modal creation by the client */ -function renderModelData(name, model, data, parameters, options) { +function renderModelData(name, model, data, parameters) { if (!data) { return parameters.placeholder || ''; } - // TODO: Implement this function for various models - var html = null; - var renderer = null; - - // Find a custom renderer - switch (model) { - case 'company': - renderer = renderCompany; - break; - case 'stockitem': - renderer = renderStockItem; - break; - case 'stocklocation': - renderer = renderStockLocation; - break; - case 'part': - renderer = renderPart; - break; - case 'partcategory': - renderer = renderPartCategory; - break; - case 'partparametertemplate': - renderer = renderPartParameterTemplate; - break; - case 'purchaseorder': - renderer = renderPurchaseOrder; - break; - case 'salesorder': - renderer = renderSalesOrder; - break; - case 'salesordershipment': - renderer = renderSalesOrderShipment; - break; - case 'manufacturerpart': - renderer = renderManufacturerPart; - break; - case 'supplierpart': - renderer = renderSupplierPart; - break; - case 'build': - renderer = renderBuild; - break; - case 'owner': - renderer = renderOwner; - break; - case 'user': - renderer = renderUser; - break; - case 'group': - renderer = renderGroup; - break; - default: - break; - } + var renderer = getModelRenderer(model); if (renderer != null) { - html = renderer(name, data, parameters, options); + html = renderer(data, parameters); } if (html != null) { diff --git a/InvenTree/templates/js/translated/helpers.js b/InvenTree/templates/js/translated/helpers.js index 145a0e6c0e..e245c5b204 100644 --- a/InvenTree/templates/js/translated/helpers.js +++ b/InvenTree/templates/js/translated/helpers.js @@ -58,7 +58,7 @@ function deleteButton(url, text='{% trans "Delete" %}') { function shortenString(input_string, options={}) { // Maximum length can be provided via options argument, or via a user-configurable setting - var max_length = options.max_length || user_settings.TABLE_STRING_MAX_LENGTH; + var max_length = options.max_length || user_settings.TABLE_STRING_MAX_LENGTH || 100; if (!max_length || !input_string) { return input_string; diff --git a/InvenTree/templates/js/translated/model_renderers.js b/InvenTree/templates/js/translated/model_renderers.js index 3e79482a81..033986e7d2 100644 --- a/InvenTree/templates/js/translated/model_renderers.js +++ b/InvenTree/templates/js/translated/model_renderers.js @@ -3,9 +3,11 @@ /* globals blankImage, select2Thumbnail + shortenString */ /* exported + getModelRenderer, renderBuild, renderCompany, renderGroup, @@ -34,91 +36,144 @@ /* - * Trim the supplied string to ensure the string length is limited to the provided value + * Return an appropriate model renderer based on the 'name' of the model */ -function trim(data, max_length=100) { - if (data.length > max_length) { - data = data.slice(0, max_length - 3) + '...'; - } +function getModelRenderer(model) { - return data; + // Find a custom renderer + switch (model) { + case 'company': + return renderCompany; + case 'stockitem': + return renderStockItem; + case 'stocklocation': + return renderStockLocation; + case 'part': + return renderPart; + case 'partcategory': + return renderPartCategory; + case 'partparametertemplate': + return renderPartParameterTemplate; + case 'purchaseorder': + return renderPurchaseOrder; + case 'salesorder': + return renderSalesOrder; + case 'salesordershipment': + return renderSalesOrderShipment; + case 'manufacturerpart': + return renderManufacturerPart; + case 'supplierpart': + return renderSupplierPart; + case 'build': + return renderBuild; + case 'owner': + return renderOwner; + case 'user': + return renderUser; + case 'group': + return renderGroup; + default: + // Un-handled model type + console.error(`Rendering not implemented for model '${model}'`); + return null; + } } -// Should the ID be rendered for this string -function renderId(title, pk, parameters={}) { +/* + * Generic method for rendering model data in a consistent fashion: + * + * data: + * - image: Render an image (optional) + * - imageSecondary: Render a secondary image (optional) + * - text: Primary text + * - pk: primary key (unique ID) of the model instance + * - textSecondary: Secondary text + * - url: href for link target (is enabled or disabled by showLink option) + * - labels: extra labels to display + * + * options: + * - showImage: Option to create image(s) (default = true) + * - showLink: Option to create link (default = false) + * - showLabels: Option to show or hide extra labels (default = true) + */ +function renderModel(data, options={}) { - // Default = do not render - var render = false; + let showImage = ('showImage' in options) ? options.showImage : true; + let showLink = ('showLink' in options) ? options.showLink : false; + let showLabels = ('showLabels' in options) ? options.showLabels : true; - if ('render_pk' in parameters) { - render = parameters['render_pk']; + let html = ''; + + if (showImage) { + if (data.image) { + html += select2Thumbnail(data.image); + } + if (data.imageSecondary) { + html += select2Thumbnail(data.imageSecondary); + } } - if (render) { - return `${title}: ${pk}`; - } else { - return ''; + let text = `${data.text}`; + + if (data.textSecondary) { + text += ` - ${data.textSecondary}`; } + + if (showLink && data.url) { + text = renderLink(text, data.url); + } + + html += text; + + if (showLabels && data.labels) { + html += `${data.labels}`; + } + + return html; + } // Renderer for "Company" model -// eslint-disable-next-line no-unused-vars -function renderCompany(name, data, parameters={}, options={}) { +function renderCompany(data, parameters={}) { - var html = select2Thumbnail(data.image); - - html += `${data.name} - ${trim(data.description)}`; - - html += renderId('{% trans "Company ID" %}', data.pk, parameters); - - return html; + return renderModel( + { + image: data.image || blankImage(), + text: data.name, + textSecondary: shortenString(data.description), + url: data.url || `/company/${data.pk}/`, + }, + parameters + ); } // Renderer for "StockItem" model -// eslint-disable-next-line no-unused-vars -function renderStockItem(name, data, parameters={}, options={}) { +function renderStockItem(data, parameters={}) { - var image = blankImage(); + let part_image = null; + let render_part_detail = ('render_part_detail' in parameters) ? parameters.render_part_detail : true; + let render_location_detail = ('render_location_detail' in parameters) ? parameters.render_location_detail : false; + let render_available_quantity = ('render_available_quantity' in parameters) ? parameters.render_available_quantity : false; - if (data.part_detail) { - image = data.part_detail.thumbnail || data.part_detail.image || blankImage(); + if (render_part_detail) { } - var render_part_detail = true; - - if ('render_part_detail' in parameters) { - render_part_detail = parameters['render_part_detail']; - } - - var part_detail = ''; + let text = ''; + let stock_detail = ''; if (render_part_detail && data.part_detail) { - part_detail = `${data.part_detail.full_name} - `; + part_image = data.part_detail.thumbnail || data.part_detail.image || blankImage(); + + text += data.part_detail.full_name; } - var render_location_detail = false; - - if ('render_location_detail' in parameters) { - render_location_detail = parameters['render_location_detail']; - } - - var location_detail = ''; - if (render_location_detail && data.location_detail) { - location_detail = ` - (${data.location_detail.name})`; + text += ` - (${data.location_detail.name})`; } - var render_available_quantity = false; - - if ('render_available_quantity' in parameters) { - render_available_quantity = parameters['render_available_quantity']; - } - - var stock_detail = ''; - if (data.quantity == 0) { stock_detail = `{% trans "No Stock"% }`; } else { @@ -138,313 +193,246 @@ function renderStockItem(name, data, parameters={}, options={}) { } } - var html = ` - - ${part_detail} - ${stock_detail} - ${location_detail} - ${renderId('{% trans "Stock ID" %}', data.pk, parameters)} - - `; - - return html; + return renderModel( + { + image: part_image, + text: text, + textSecondary: stock_detail, + url: data.url || `/stock/item/${data.pk}/`, + }, + parameters + ); } // Renderer for "StockLocation" model -// eslint-disable-next-line no-unused-vars -function renderStockLocation(name, data, parameters={}, options={}) { +function renderStockLocation(data, parameters={}) { - var level = '- '.repeat(data.level); + let render_description = ('render_description' in parameters) ? parameters.render_description : true; + let level = '- '.repeat(data.level); - var html = `${level}${data.pathstring}`; - - var render_description = true; - - if ('render_description' in parameters) { - render_description = parameters['render_description']; - } - - if (render_description && data.description) { - html += ` - ${trim(data.description)}`; - } - - html += renderId('{% trans "Location ID" %}', data.pk, parameters); - - return html; + return renderModel( + { + text: `${level}${data.pathstring}`, + textSecondary: render_description ? shortenString(data.description) : '', + url: data.url || `/stock/location/${data.pk}/`, + }, + parameters + ); } -// eslint-disable-next-line no-unused-vars -function renderBuild(name, data, parameters={}, options={}) { +function renderBuild(data, parameters={}) { - var image = null; + var image = blankImage(); if (data.part_detail && data.part_detail.thumbnail) { - image = data.part_detail.thumbnail; + image = data.part_detail.thumbnail || data.part_detail.image || blankImage(); } - var html = select2Thumbnail(image); - - html += `${data.reference} - ${data.quantity} x ${data.part_detail.full_name}`; - - html += renderId('{% trans "Build ID" %}', data.pk, parameters); - - return html; + return renderModel( + { + image: image, + text: data.reference, + textSecondary: `${data.quantity} x ${data.part_detail.full_name}`, + url: data.url || `/build/${data.pk}/`, + }, + parameters + ); } // Renderer for "Part" model -// eslint-disable-next-line no-unused-vars -function renderPart(name, data, parameters={}, options={}) { +function renderPart(data, parameters={}) { - var html = select2Thumbnail(data.image); - - html += ` ${data.full_name || data.name}`; - - if (data.description) { - html += ` - ${trim(data.description)}`; - } - - var stock_data = ''; + let labels = ''; if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) { - stock_data = partStockLabel(data); + labels = partStockLabel(data); + + if (!data.active) { + labels += `{% trans "Inactive" %}`; + } } - var extra = ''; - - if (!data.active) { - extra += `{% trans "Inactive" %}`; - } - - html += ` - - - ${stock_data} - ${extra} - ${renderId('{% trans "Part ID" %}', data.pk, parameters)} - - `; - - return html; + return renderModel( + { + image: data.image || blankImage(), + text: data.full_name || data.name, + textSecondary: shortenString(data.description), + labels: labels, + url: data.url || `/part/${data.pk}/`, + }, + parameters, + ); } // Renderer for "Group" model -// eslint-disable-next-line no-unused-vars -function renderGroup(name, data, parameters={}, options={}) { - - var html = `${data.name}`; - - return html; +function renderGroup(data, parameters={}) { + return renderModel( + { + text: data.name, + }, + parameters + ); } // Renderer for "User" model -// eslint-disable-next-line no-unused-vars -function renderUser(name, data, parameters={}, options={}) { +function renderUser(data, parameters={}) { - var html = `${data.username}`; - - if (data.first_name && data.last_name) { - html += ` - ${data.first_name} ${data.last_name}`; - } - - return html; + return renderModel( + { + text: data.username, + textSecondary: `${data.first_name} ${data.last_name}`, + }, + parameters + ); } // Renderer for "Owner" model -// eslint-disable-next-line no-unused-vars -function renderOwner(name, data, parameters={}, options={}) { +function renderOwner(data, parameters={}) { - var html = `${data.name}`; + let label = ''; switch (data.label) { case 'user': - html += ``; + label = ``; break; case 'group': - html += ``; + label = ``; break; default: break; } - return html; + return renderModel( + { + text: data.name, + labels: label, + }, + parameters + ); + } // Renderer for "PurchaseOrder" model -// eslint-disable-next-line no-unused-vars -function renderPurchaseOrder(name, data, parameters={}, options={}) { +function renderPurchaseOrder(data, parameters={}) { - var html = ''; + let image = blankImage(); if (data.supplier_detail) { - thumbnail = data.supplier_detail.thumbnail || data.supplier_detail.image; - - html += select2Thumbnail(thumbnail); + image = data.supplier_detail.thumbnail || data.supplier_detail.image || blankImage(); } - html += `${data.reference}`; - - var thumbnail = null; - - if (data.supplier_detail) { - html += ` - ${data.supplier_detail.name}`; - } - - if (data.description) { - html += ` - ${trim(data.description)}`; - } - - html += renderId('{% trans "Order ID" %}', data.pk, parameters); - - return html; + return renderModel( + { + image: image, + text: `${data.reference} - ${data.supplier_detail.name}`, + textSecondary: shortenString(data.description), + url: data.url || `/order/purchase-order/${data.pk}/`, + }, + parameters + ); } // Renderer for "SalesOrder" model -// eslint-disable-next-line no-unused-vars -function renderSalesOrder(name, data, parameters={}, options={}) { +function renderSalesOrder(data, parameters={}) { - var html = `${data.reference}`; - - var thumbnail = null; + let image = blankImage(); if (data.customer_detail) { - thumbnail = data.customer_detail.thumbnail || data.customer_detail.image; - - html += ' - ' + select2Thumbnail(thumbnail); - html += `${data.customer_detail.name}`; + image = data.customer_detail.thumbnail || data.customer_detail.image || blankImage(); } - if (data.description) { - html += ` - ${trim(data.description)}`; - } - - html += renderId('{% trans "Order ID" %}', data.pk, parameters); - - return html; + return renderModel( + { + image: image, + text: `${data.reference} - ${data.customer_detail.name}`, + textSecondary: shortenString(data.description), + url: data.url || `/order/sales-order/${data.pk}/`, + }, + parameters + ); } // Renderer for "SalesOrderShipment" model -// eslint-disable-next-line no-unused-vars -function renderSalesOrderShipment(name, data, parameters={}, options={}) { +function renderSalesOrderShipment(data, parameters={}) { - var html = ` - ${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference} - - {% trans "Shipment ID" %}: ${data.pk} - - `; - - html += renderId('{% trans "Shipment ID" %}', data.pk, parameters); - - return html; + return renderModel( + { + text: data.order_detail.reference, + textSecondary: `{% trans "Shipment" %} ${data.reference}`, + }, + parameters + ); } // Renderer for "PartCategory" model -// eslint-disable-next-line no-unused-vars -function renderPartCategory(name, data, parameters={}, options={}) { +function renderPartCategory(data, parameters={}) { - var level = '- '.repeat(data.level); + let level = '- '.repeat(data.level); - var html = `${level}${data.pathstring}`; - - if (data.description) { - html += ` - ${trim(data.description)}`; - } - - html += renderId('{% trans "Category ID" %}', data.pk, parameters); - - return html; + return renderModel( + { + text: `${level}${data.pathstring}`, + textSecondary: shortenString(data.description), + url: data.url || `/part/category/${data.pk}/`, + }, + parameters + ); } -// eslint-disable-next-line no-unused-vars -function renderPartParameterTemplate(name, data, parameters={}, options={}) { - var units = ''; +function renderPartParameterTemplate(data, parameters={}) { + + let units = ''; if (data.units) { units = ` [${data.units}]`; } - var html = `${data.name}${units}`; - - return html; + return renderModel( + { + text: `${data.name}${units}`, + }, + parameters + ); } // Renderer for "ManufacturerPart" model -// eslint-disable-next-line no-unused-vars -function renderManufacturerPart(name, data, parameters={}, options={}) { +function renderManufacturerPart(data, parameters={}) { - var manufacturer_image = null; - var part_image = null; - - if (data.manufacturer_detail) { - manufacturer_image = data.manufacturer_detail.image; - } - - if (data.part_detail) { - part_image = data.part_detail.thumbnail || data.part_detail.image; - } - - var html = ''; - - html += select2Thumbnail(manufacturer_image); - html += select2Thumbnail(part_image); - - if (data.manufacturer_detail) { - html += ` ${data.manufacturer_detail.name} - ${data.MPN}`; - } - - if (data.part_detail) { - html += ` - ${data.part_detail.full_name}`; - } - - html += renderId('{% trans "Manufacturer Part ID" %}', data.pk, parameters); - - return html; + return renderModel( + { + image: data.manufacturer_detail ? data.manufacturer_detail.thumbnail || data.manufacturer_detail.image || blankImage() : null, + imageSecondary: data.part.detail ? data.part_detail.thumbnail || data.part_detail.image || blankImage() : null, + text: `${data.manufacturer_detail.name} - ${data.MPN}`, + textSecondary: data.part_detail.full_name, + url: data.url || `/manufacturer-part/${data.pk}/`, + }, + parameters + ); } // Renderer for "SupplierPart" model -// eslint-disable-next-line no-unused-vars -function renderSupplierPart(name, data, parameters={}, options={}) { +function renderSupplierPart(data, parameters={}) { - var supplier_image = null; - var part_image = null; - - if (data.supplier_detail) { - supplier_image = data.supplier_detail.image; - } - - if (data.part_detail) { - part_image = data.part_detail.thumbnail || data.part_detail.image; - } - - var html = ''; - - html += select2Thumbnail(supplier_image); - - if (data.part_detail) { - html += select2Thumbnail(part_image); - } - - if (data.supplier_detail) { - html += ` ${data.supplier_detail.name} - ${data.SKU}`; - } - - if (data.part_detail) { - html += ` - ${data.part_detail.full_name}`; - } - - html += renderId('{% trans "Supplier Part ID" %}', data.pk, parameters); - - return html; + return renderModel( + { + image: data.supplier_detail ? data.supplier_detail.thumbnail || data.supplier_detail.image || blankImage() : null, + imageSecondary: data.part_detail ? data.part_detail.thumbnail || data.part_detail.image || blankImage() : null, + text: `${data.supplier_detail.name} - ${data.SKU}`, + textSecondary: data.part_detail.full_name, + url: data.url || `/supplier-part/${data.pk}/` + }, + parameters + ); } diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 4871dff7f5..eea9c770cc 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -640,7 +640,7 @@ function partStockLabel(part, options={}) { var stock_health = part.unallocated_stock + part.building + part.ordering - part.minimum_stock; // TODO: Refactor the API to include this information, so we don't have to request it! - if (!options.noDemandInfo) { + if (options.showDemandInfo) { // Check for demand from unallocated build orders var required_build_order_quantity = null; diff --git a/InvenTree/templates/js/translated/search.js b/InvenTree/templates/js/translated/search.js index b4ce964cc4..b01273e9ef 100644 --- a/InvenTree/templates/js/translated/search.js +++ b/InvenTree/templates/js/translated/search.js @@ -60,6 +60,10 @@ function openSearchPanel() { var panel = $('#offcanvas-search'); + let search_input = panel.find('#search-input'); + search_input.find('#search-input').val(''); + search_input.focus(); + clearSearchResults(); // Request user roles if we do not have them @@ -72,7 +76,7 @@ function openSearchPanel() { } // Callback for text input changed - panel.find('#search-input').on('keyup change', searchTextChanged); + search_input.on('keyup change', searchTextChanged); // Callback for "clear search" button panel.find('#search-clear').click(function(event) { @@ -80,7 +84,7 @@ function openSearchPanel() { // Prevent this button from actually submitting the form event.preventDefault(); - panel.find('#search-input').val(''); + search_input('#search-input').val(''); clearSearchResults(); }); @@ -94,7 +98,9 @@ function openSearchPanel() { var searchInputTimer = null; var searchText = null; var searchTextCurrent = null; -var searchQueries = []; +var searchQuery = null; +var searchResultTypes = []; +var searchRequest = null; function searchTextChanged(event) { @@ -121,41 +127,40 @@ function updateSearch() { searchTextCurrent = searchText; - // Cancel any previous AJAX requests - searchQueries.forEach(function(query) { - query.abort(); - }); - - searchQueries = []; + // Cancel previous search request + if (searchRequest != null) { + searchRequest.abort(); + searchRequest = null; + } // Show the "searching" text $('#offcanvas-search').find('#search-pending').show(); + searchResultTypes = []; + + // Construct base query + searchQuery = { + search: searchTextCurrent, + limit: user_settings.SEARCH_PREVIEW_RESULTS, + offset: 0, + }; + + // Search for 'part' results if (checkPermission('part') && user_settings.SEARCH_PREVIEW_SHOW_PARTS) { - var params = {}; + let filters = {}; if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) { // Return *only* active parts - params.active = true; + filters.active = true; } - // Search for matching parts - addSearchQuery( - 'part', - '{% trans "Parts" %}', - '{% url "api-part-list" %}', - params, - renderPart, - { - url: '/part', - } - ); + addSearchQuery('part', '{% trans "Parts" %}', filters); } if (checkPermission('part') && checkPermission('purchase_order')) { - var params = { + let filters = { part_detail: true, supplier_detail: true, manufacturer_detail: true, @@ -163,54 +168,26 @@ function updateSearch() { if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) { // Return *only* active parts - params.active = true; + filters.active = true; } if (user_settings.SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS) { - addSearchQuery( - 'supplierpart', - '{% trans "Supplier Parts" %}', - '{% url "api-supplier-part-list" %}', - params, - renderSupplierPart, - { - url: '/supplier-part', - } - ); + addSearchQuery('supplierpart', '{% trans "Supplier Parts" %}', filters); } if (user_settings.SEARCH_PREVIEW_SHOW_MANUFACTURER_PARTS) { - addSearchQuery( - 'manufacturerpart', - '{% trans "Manufacturer Parts" %}', - '{% url "api-manufacturer-part-list" %}', - params, - renderManufacturerPart, - { - url: '/manufacturer-part', - } - ); + addSearchQuery('manufacturerpart', '{% trans "Manufacturer Parts" %}', filters); } } if (checkPermission('part_category') && user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) { - // Search for matching part categories - addSearchQuery( - 'category', - '{% trans "Part Categories" %}', - '{% url "api-part-category-list" %}', - {}, - renderPartCategory, - { - url: '/part/category', - }, - ); + let filters = {}; + + addSearchQuery('partcategory', '{% trans "Part Categories" %}', filters); } if (checkPermission('stock') && user_settings.SEARCH_PREVIEW_SHOW_STOCK) { - // Search for matching stock items - - var filters = { + let filters = { part_detail: true, location_detail: true, }; @@ -220,61 +197,27 @@ function updateSearch() { filters.in_stock = true; } - addSearchQuery( - 'stock', - '{% trans "Stock Items" %}', - '{% url "api-stock-list" %}', - filters, - renderStockItem, - { - url: '/stock/item', - render_location_detail: true, - } - ); + addSearchQuery('stockitem', '{% trans "Stock Items" %}', filters); } if (checkPermission('stock_location') && user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) { - // Search for matching stock locations - addSearchQuery( - 'location', - '{% trans "Stock Locations" %}', - '{% url "api-location-list" %}', - {}, - renderStockLocation, - { - url: '/stock/location', - } - ); + let filters = {}; + + addSearchQuery('stocklocation', '{% trans "Stock Locations" %}', filters); } if (checkPermission('build') && user_settings.SEARCH_PREVIEW_SHOW_BUILD_ORDERS) { - // Search for matching build orders - addSearchQuery( - 'build', - '{% trans "Build Orders" %}', - '{% url "api-build-list" %}', - { - part_detail: true, - }, - renderBuild, - { - url: '/build', - } - ); + let filters = { + part_detail: true + }; + + addSearchQuery('build', '{% trans "Build Orders" %}', filters); } if ((checkPermission('sales_order') || checkPermission('purchase_order')) && user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) { - // Search for matching companies - addSearchQuery( - 'company', - '{% trans "Companies" %}', - '{% url "api-company-list" %}', - {}, - renderCompany, - { - url: '/company', - } - ); + let filters = {}; + + addSearchQuery('company', '{% trans "Companies" %}', filters); } if (checkPermission('purchase_order') && user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) { @@ -287,17 +230,7 @@ function updateSearch() { filters.outstanding = true; } - // Search for matching purchase orders - addSearchQuery( - 'purchaseorder', - '{% trans "Purchase Orders" %}', - '{% url "api-po-list" %}', - filters, - renderPurchaseOrder, - { - url: '/order/purchase-order', - } - ); + addSearchQuery('purchaseorder', '{% trans "Purchase Orders" %}', filters); } if (checkPermission('sales_order') && user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) { @@ -311,23 +244,55 @@ function updateSearch() { filters.outstanding = true; } - // Search for matching sales orders - addSearchQuery( - 'salesorder', - '{% trans "Sales Orders" %}', - '{% url "api-so-list" %}', - filters, - renderSalesOrder, - { - url: '/order/sales-order', - } - ); + addSearchQuery('salesorder', '{% trans "Sales Orders" %}', filters); } - // Wait until all the pending queries are completed - $.when.apply($, searchQueries).done(function() { - $('#offcanvas-search').find('#search-pending').hide(); - }); + let ctx = $('#offcanvas-search').find('#search-context'); + + ctx.html(` +
+ {% trans "Searching" %} +
+ `); + + // Send off the search query + searchRequest = inventreePut( + '{% url "api-search" %}', + searchQuery, + { + method: 'POST', + success: function(response) { + + let any_results = false; + + searchResultTypes.forEach(function(resultType) { + if (resultType.key in response) { + let result = response[resultType.key]; + + if (result.count != null && result.count > 0 && result.results) { + addSearchResults(result.results, resultType); + + any_results = true; + } + } + }); + + if (any_results) { + ctx.html(''); + } else { + ctx.html(` +
+ {% trans "No results" %} +
+ `); + } + }, + complete: function() { + // Hide the "pending" icon + $('#offcanvas-search').find('#search-pending').hide(); + } + } + ); } @@ -335,12 +300,14 @@ function clearSearchResults() { var panel = $('#offcanvas-search'); - // Ensure the 'no results found' element is visible - panel.find('#search-no-results').show(); - - // Ensure that the 'searching' element is hidden panel.find('#search-pending').hide(); + panel.find('#search-context').html(` +
+ {% trans "Enter search query" %} +
+ `); + // Delete any existing search results panel.find('#search-results').empty(); @@ -349,59 +316,47 @@ function clearSearchResults() { } -function addSearchQuery(key, title, query_url, query_params, render_func, render_params={}) { +/* + * Add an individual search query, with callback for rendering + */ +function addSearchQuery(key, title, query_params, render_params={}) { - // Include current search term - query_params.search = searchTextCurrent; + searchQuery[key] = query_params; - // How many results to show in each group? - query_params.offset = 0; - query_params.limit = user_settings.SEARCH_PREVIEW_RESULTS; - - // Do not display "pk" value for search results - render_params.render_pk = false; - - // Add the result group to the panel - $('#offcanvas-search').find('#search-results').append(` -
- `); - - var request = inventreeGet( - query_url, - query_params, - { - success: function(response) { - addSearchResults( - key, - response.results, - title, - render_func, - render_params, - ); - } - }, - ); - - // Add the query to the stack - searchQueries.push(request); + render_params.showImage = true; + render_params.showLink = true; + render_params.showLabels = true; + searchResultTypes.push({ + key: key, + title: title, + renderer: getModelRenderer(key), + renderParams: render_params, + }); } // Add a group of results to the list -function addSearchResults(key, results, title, renderFunc, renderParams={}) { +function addSearchResults(results, resultType) { if (results.length == 0) { // Do not display this group, as there are no results return; } - var panel = $('#offcanvas-search'); + let panel = $('#offcanvas-search'); // Ensure the 'no results found' element is hidden panel.find('#search-no-results').hide(); - panel.find(`#search-results-wrapper-${key}`).append(` + let key = resultType.key; + let title = resultType.title; + let renderer = resultType.renderer; + let renderParams = resultType.renderParams; + + // Add the result group to the panel + panel.find('#search-results').append(` +
${title}
@@ -418,17 +373,14 @@ function addSearchResults(key, results, title, renderFunc, renderParams={}) {
+
`); results.forEach(function(result) { var pk = result.pk || result.id; - var html = renderFunc(key, result, renderParams); - - if (renderParams.url) { - html = `` + html + ``; - } + var html = renderer(result, renderParams); var result_html = `
diff --git a/InvenTree/templates/search.html b/InvenTree/templates/search.html index 3a855b4c2e..64508e6986 100644 --- a/InvenTree/templates/search.html +++ b/InvenTree/templates/search.html @@ -12,11 +12,6 @@ - @@ -25,15 +20,11 @@
-

- {% trans "Searching" %}... - - - -

-

- {% trans "No search results" %} -

+
+
+ {% trans "Enter search query" %} +
+
diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index f5d5ce2148..e68a00a539 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -261,8 +261,7 @@ class RuleSet(models.Model): # Print message instead of throwing an error name = getattr(user, 'name', user.pk) - - logger.info(f"User '{name}' failed permission check for {table}.{permission}") + logger.debug(f"User '{name}' failed permission check for {table}.{permission}") return False @@ -451,7 +450,7 @@ def update_group_roles(group, debug=False): group.permissions.add(permission) if debug: # pragma: no cover - logger.info(f"Adding permission {perm} to group {group.name}") + logger.debug(f"Adding permission {perm} to group {group.name}") # Remove any extra permissions from the group for perm in permissions_to_delete: @@ -466,7 +465,7 @@ def update_group_roles(group, debug=False): group.permissions.remove(permission) if debug: # pragma: no cover - logger.info(f"Removing permission {perm} from group {group.name}") + logger.debug(f"Removing permission {perm} from group {group.name}") # Enable all action permissions for certain children models # if parent model has 'change' permission @@ -488,7 +487,7 @@ def update_group_roles(group, debug=False): permission = get_permission_object(child_perm) if permission: group.permissions.add(permission) - logger.info(f"Adding permission {child_perm} to group {group.name}") + logger.debug(f"Adding permission {child_perm} to group {group.name}") def clear_user_role_cache(user):