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" %}... - - - -
-- {% trans "No search results" %} -
+