mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 19:46:46 +00:00
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
This commit is contained in:
parent
373c8c9cc5
commit
4d8311682c
@ -10,7 +10,9 @@ from django_q.models import OrmQ
|
|||||||
from rest_framework import filters, permissions
|
from rest_framework import filters, permissions
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
import users.models
|
||||||
from InvenTree.mixins import ListCreateAPI
|
from InvenTree.mixins import ListCreateAPI
|
||||||
from InvenTree.permissions import RolePermission
|
from InvenTree.permissions import RolePermission
|
||||||
from part.templatetags.inventree_extras import plugins_info
|
from part.templatetags.inventree_extras import plugins_info
|
||||||
@ -208,3 +210,105 @@ class AttachmentMixin:
|
|||||||
attachment = serializer.save()
|
attachment = serializer.save()
|
||||||
attachment.user = self.request.user
|
attachment.user = self.request.user
|
||||||
attachment.save()
|
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)
|
||||||
|
@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# 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
|
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
|
v101 -> 2023-03-07 : https://github.com/inventree/InvenTree/pull/4462
|
||||||
- Adds 'total_in_stock' to Part serializer, and supports API ordering
|
- Adds 'total_in_stock' to Part serializer, and supports API ordering
|
||||||
|
|
||||||
|
@ -301,3 +301,133 @@ class BulkDeleteTests(InvenTreeAPITestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("'filters' must be supplied as a dict object", str(response.data))
|
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')
|
||||||
|
@ -30,7 +30,7 @@ from stock.api import stock_api_urls
|
|||||||
from stock.urls import stock_urls
|
from stock.urls import stock_urls
|
||||||
from users.api import user_urls
|
from users.api import user_urls
|
||||||
|
|
||||||
from .api import InfoView, NotFoundView
|
from .api import APISearchView, InfoView, NotFoundView
|
||||||
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
|
from .views import (AboutView, AppearanceSelectView, CustomConnectionsView,
|
||||||
CustomEmailView, CustomLoginView,
|
CustomEmailView, CustomLoginView,
|
||||||
CustomPasswordResetFromKeyView,
|
CustomPasswordResetFromKeyView,
|
||||||
@ -43,6 +43,10 @@ admin.site.site_header = "InvenTree Admin"
|
|||||||
|
|
||||||
|
|
||||||
apipatterns = [
|
apipatterns = [
|
||||||
|
|
||||||
|
# Global search
|
||||||
|
path('search/', APISearchView.as_view(), name='api-search'),
|
||||||
|
|
||||||
re_path(r'^settings/', include(settings_api_urls)),
|
re_path(r'^settings/', include(settings_api_urls)),
|
||||||
re_path(r'^part/', include(part_api_urls)),
|
re_path(r'^part/', include(part_api_urls)),
|
||||||
re_path(r'^bom/', include(bom_api_urls)),
|
re_path(r'^bom/', include(bom_api_urls)),
|
||||||
@ -58,7 +62,7 @@ apipatterns = [
|
|||||||
# Plugin endpoints
|
# Plugin endpoints
|
||||||
path('', include(plugin_api_urls)),
|
path('', include(plugin_api_urls)),
|
||||||
|
|
||||||
# Webhook enpoint
|
# Webhook endpoints
|
||||||
path('', include(common_api_urls)),
|
path('', include(common_api_urls)),
|
||||||
|
|
||||||
# InvenTree information endpoint
|
# InvenTree information endpoint
|
||||||
|
@ -9,18 +9,7 @@
|
|||||||
global_settings,
|
global_settings,
|
||||||
modalEnable,
|
modalEnable,
|
||||||
modalShowSubmitButton,
|
modalShowSubmitButton,
|
||||||
renderBuild,
|
getModelRenderer,
|
||||||
renderCompany,
|
|
||||||
renderGroup,
|
|
||||||
renderManufacturerPart,
|
|
||||||
renderOwner,
|
|
||||||
renderPart,
|
|
||||||
renderPartCategory,
|
|
||||||
renderPartParameterTemplate,
|
|
||||||
renderStockItem,
|
|
||||||
renderStockLocation,
|
|
||||||
renderSupplierPart,
|
|
||||||
renderUser,
|
|
||||||
showAlertOrCache,
|
showAlertOrCache,
|
||||||
showApiError,
|
showApiError,
|
||||||
*/
|
*/
|
||||||
@ -1886,7 +1875,7 @@ function initializeRelatedField(field, fields, options={}) {
|
|||||||
// Custom formatting for the search results
|
// Custom formatting for the search results
|
||||||
if (field.model) {
|
if (field.model) {
|
||||||
// If the 'model' is specified, hand it off to the custom model render
|
// 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);
|
return $(html);
|
||||||
} else {
|
} else {
|
||||||
// Return a simple renderering
|
// Return a simple renderering
|
||||||
@ -1916,7 +1905,7 @@ function initializeRelatedField(field, fields, options={}) {
|
|||||||
// Custom formatting for selected item
|
// Custom formatting for selected item
|
||||||
if (field.model) {
|
if (field.model) {
|
||||||
// If the 'model' is specified, hand it off to the custom model render
|
// 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);
|
return $(html);
|
||||||
} else {
|
} else {
|
||||||
// Return a simple renderering
|
// Return a simple renderering
|
||||||
@ -2027,71 +2016,18 @@ function searching() {
|
|||||||
* - parameters: The field definition (OPTIONS) request
|
* - parameters: The field definition (OPTIONS) request
|
||||||
* - options: Other options provided at time of modal creation by the client
|
* - 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) {
|
if (!data) {
|
||||||
return parameters.placeholder || '';
|
return parameters.placeholder || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement this function for various models
|
|
||||||
|
|
||||||
var html = null;
|
var html = null;
|
||||||
|
|
||||||
var renderer = null;
|
var renderer = getModelRenderer(model);
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renderer != null) {
|
if (renderer != null) {
|
||||||
html = renderer(name, data, parameters, options);
|
html = renderer(data, parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (html != null) {
|
if (html != null) {
|
||||||
|
@ -58,7 +58,7 @@ function deleteButton(url, text='{% trans "Delete" %}') {
|
|||||||
function shortenString(input_string, options={}) {
|
function shortenString(input_string, options={}) {
|
||||||
|
|
||||||
// Maximum length can be provided via options argument, or via a user-configurable setting
|
// 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) {
|
if (!max_length || !input_string) {
|
||||||
return input_string;
|
return input_string;
|
||||||
|
@ -3,9 +3,11 @@
|
|||||||
/* globals
|
/* globals
|
||||||
blankImage,
|
blankImage,
|
||||||
select2Thumbnail
|
select2Thumbnail
|
||||||
|
shortenString
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
|
getModelRenderer,
|
||||||
renderBuild,
|
renderBuild,
|
||||||
renderCompany,
|
renderCompany,
|
||||||
renderGroup,
|
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) {
|
function getModelRenderer(model) {
|
||||||
if (data.length > max_length) {
|
|
||||||
data = data.slice(0, max_length - 3) + '...';
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
let showImage = ('showImage' in options) ? options.showImage : true;
|
||||||
var render = false;
|
let showLink = ('showLink' in options) ? options.showLink : false;
|
||||||
|
let showLabels = ('showLabels' in options) ? options.showLabels : true;
|
||||||
|
|
||||||
if ('render_pk' in parameters) {
|
let html = '';
|
||||||
render = parameters['render_pk'];
|
|
||||||
|
if (showImage) {
|
||||||
|
if (data.image) {
|
||||||
|
html += select2Thumbnail(data.image);
|
||||||
|
}
|
||||||
|
if (data.imageSecondary) {
|
||||||
|
html += select2Thumbnail(data.imageSecondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (render) {
|
let text = `<span>${data.text}</span>`;
|
||||||
return `<span class='float-right'><small>${title}: ${pk}</small></span>`;
|
|
||||||
} else {
|
if (data.textSecondary) {
|
||||||
return '';
|
text += ` - <small><em>${data.textSecondary}</em></small>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showLink && data.url) {
|
||||||
|
text = renderLink(text, data.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += text;
|
||||||
|
|
||||||
|
if (showLabels && data.labels) {
|
||||||
|
html += `<span class='float-right'><small>${data.labels}</small></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "Company" model
|
// Renderer for "Company" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderCompany(data, parameters={}) {
|
||||||
function renderCompany(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var html = select2Thumbnail(data.image);
|
return renderModel(
|
||||||
|
{
|
||||||
html += `<span><b>${data.name}</b></span> - <i>${trim(data.description)}</i>`;
|
image: data.image || blankImage(),
|
||||||
|
text: data.name,
|
||||||
html += renderId('{% trans "Company ID" %}', data.pk, parameters);
|
textSecondary: shortenString(data.description),
|
||||||
|
url: data.url || `/company/${data.pk}/`,
|
||||||
return html;
|
},
|
||||||
|
parameters
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "StockItem" model
|
// Renderer for "StockItem" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderStockItem(data, parameters={}) {
|
||||||
function renderStockItem(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
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) {
|
if (render_part_detail) {
|
||||||
image = data.part_detail.thumbnail || data.part_detail.image || blankImage();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var render_part_detail = true;
|
let text = '';
|
||||||
|
let stock_detail = '';
|
||||||
if ('render_part_detail' in parameters) {
|
|
||||||
render_part_detail = parameters['render_part_detail'];
|
|
||||||
}
|
|
||||||
|
|
||||||
var part_detail = '';
|
|
||||||
|
|
||||||
if (render_part_detail && data.part_detail) {
|
if (render_part_detail && data.part_detail) {
|
||||||
part_detail = `<img src='${image}' class='select2-thumbnail'><span>${data.part_detail.full_name}</span> - `;
|
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) {
|
if (render_location_detail && data.location_detail) {
|
||||||
location_detail = ` <small>- (<em>${data.location_detail.name}</em>)</small>`;
|
text += ` <small>- (<em>${data.location_detail.name}</em>)</small>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (data.quantity == 0) {
|
||||||
stock_detail = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock"% }</span>`;
|
stock_detail = `<span class='badge rounded-pill bg-danger'>{% trans "No Stock"% }</span>`;
|
||||||
} else {
|
} else {
|
||||||
@ -138,313 +193,246 @@ function renderStockItem(name, data, parameters={}, options={}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = `
|
return renderModel(
|
||||||
<span>
|
{
|
||||||
${part_detail}
|
image: part_image,
|
||||||
${stock_detail}
|
text: text,
|
||||||
${location_detail}
|
textSecondary: stock_detail,
|
||||||
${renderId('{% trans "Stock ID" %}', data.pk, parameters)}
|
url: data.url || `/stock/item/${data.pk}/`,
|
||||||
</span>
|
},
|
||||||
`;
|
parameters
|
||||||
|
);
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "StockLocation" model
|
// Renderer for "StockLocation" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderStockLocation(data, parameters={}) {
|
||||||
function renderStockLocation(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var level = '- '.repeat(data.level);
|
let render_description = ('render_description' in parameters) ? parameters.render_description : true;
|
||||||
|
let level = '- '.repeat(data.level);
|
||||||
|
|
||||||
var html = `<span>${level}${data.pathstring}</span>`;
|
return renderModel(
|
||||||
|
{
|
||||||
var render_description = true;
|
text: `${level}${data.pathstring}`,
|
||||||
|
textSecondary: render_description ? shortenString(data.description) : '',
|
||||||
if ('render_description' in parameters) {
|
url: data.url || `/stock/location/${data.pk}/`,
|
||||||
render_description = parameters['render_description'];
|
},
|
||||||
}
|
parameters
|
||||||
|
);
|
||||||
if (render_description && data.description) {
|
|
||||||
html += ` - <i>${trim(data.description)}</i>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += renderId('{% trans "Location ID" %}', data.pk, parameters);
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderBuild(data, parameters={}) {
|
||||||
function renderBuild(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var image = null;
|
var image = blankImage();
|
||||||
|
|
||||||
if (data.part_detail && data.part_detail.thumbnail) {
|
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);
|
return renderModel(
|
||||||
|
{
|
||||||
html += `<span><b>${data.reference}</b> - ${data.quantity} x ${data.part_detail.full_name}</span>`;
|
image: image,
|
||||||
|
text: data.reference,
|
||||||
html += renderId('{% trans "Build ID" %}', data.pk, parameters);
|
textSecondary: `${data.quantity} x ${data.part_detail.full_name}`,
|
||||||
|
url: data.url || `/build/${data.pk}/`,
|
||||||
return html;
|
},
|
||||||
|
parameters
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "Part" model
|
// Renderer for "Part" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderPart(data, parameters={}) {
|
||||||
function renderPart(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var html = select2Thumbnail(data.image);
|
let labels = '';
|
||||||
|
|
||||||
html += ` <span>${data.full_name || data.name}</span>`;
|
|
||||||
|
|
||||||
if (data.description) {
|
|
||||||
html += ` - <i><small>${trim(data.description)}</small></i>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
var stock_data = '';
|
|
||||||
|
|
||||||
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
|
if (user_settings.PART_SHOW_QUANTITY_IN_FORMS) {
|
||||||
stock_data = partStockLabel(data);
|
labels = partStockLabel(data);
|
||||||
|
|
||||||
|
if (!data.active) {
|
||||||
|
labels += `<span class='badge badge-right rounded-pill bg-danger'>{% trans "Inactive" %}</span>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var extra = '';
|
return renderModel(
|
||||||
|
{
|
||||||
if (!data.active) {
|
image: data.image || blankImage(),
|
||||||
extra += `<span class='badge badge-right rounded-pill bg-danger'>{% trans "Inactive" %}</span>`;
|
text: data.full_name || data.name,
|
||||||
}
|
textSecondary: shortenString(data.description),
|
||||||
|
labels: labels,
|
||||||
html += `
|
url: data.url || `/part/${data.pk}/`,
|
||||||
<span class='float-right'>
|
},
|
||||||
<small>
|
parameters,
|
||||||
${stock_data}
|
);
|
||||||
${extra}
|
|
||||||
${renderId('{% trans "Part ID" %}', data.pk, parameters)}
|
|
||||||
</small>
|
|
||||||
</span>`;
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "Group" model
|
// Renderer for "Group" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderGroup(data, parameters={}) {
|
||||||
function renderGroup(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var html = `<span>${data.name}</span>`;
|
|
||||||
|
|
||||||
return html;
|
|
||||||
|
|
||||||
|
return renderModel(
|
||||||
|
{
|
||||||
|
text: data.name,
|
||||||
|
},
|
||||||
|
parameters
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renderer for "User" model
|
// Renderer for "User" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderUser(data, parameters={}) {
|
||||||
function renderUser(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var html = `<span>${data.username}</span>`;
|
return renderModel(
|
||||||
|
{
|
||||||
if (data.first_name && data.last_name) {
|
text: data.username,
|
||||||
html += ` - <i>${data.first_name} ${data.last_name}</i>`;
|
textSecondary: `${data.first_name} ${data.last_name}`,
|
||||||
}
|
},
|
||||||
|
parameters
|
||||||
return html;
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "Owner" model
|
// Renderer for "Owner" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderOwner(data, parameters={}) {
|
||||||
function renderOwner(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var html = `<span>${data.name}</span>`;
|
let label = '';
|
||||||
|
|
||||||
switch (data.label) {
|
switch (data.label) {
|
||||||
case 'user':
|
case 'user':
|
||||||
html += `<span class='float-right fas fa-user'></span>`;
|
label = `<span class='float-right fas fa-user'></span>`;
|
||||||
break;
|
break;
|
||||||
case 'group':
|
case 'group':
|
||||||
html += `<span class='float-right fas fa-users'></span>`;
|
label = `<span class='float-right fas fa-users'></span>`;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html;
|
return renderModel(
|
||||||
|
{
|
||||||
|
text: data.name,
|
||||||
|
labels: label,
|
||||||
|
},
|
||||||
|
parameters
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "PurchaseOrder" model
|
// Renderer for "PurchaseOrder" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderPurchaseOrder(data, parameters={}) {
|
||||||
function renderPurchaseOrder(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var html = '';
|
let image = blankImage();
|
||||||
|
|
||||||
if (data.supplier_detail) {
|
if (data.supplier_detail) {
|
||||||
thumbnail = data.supplier_detail.thumbnail || data.supplier_detail.image;
|
image = data.supplier_detail.thumbnail || data.supplier_detail.image || blankImage();
|
||||||
|
|
||||||
html += select2Thumbnail(thumbnail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html += `<span>${data.reference}</span>`;
|
return renderModel(
|
||||||
|
{
|
||||||
var thumbnail = null;
|
image: image,
|
||||||
|
text: `${data.reference} - ${data.supplier_detail.name}`,
|
||||||
if (data.supplier_detail) {
|
textSecondary: shortenString(data.description),
|
||||||
html += ` - <span>${data.supplier_detail.name}</span>`;
|
url: data.url || `/order/purchase-order/${data.pk}/`,
|
||||||
}
|
},
|
||||||
|
parameters
|
||||||
if (data.description) {
|
);
|
||||||
html += ` - <em>${trim(data.description)}</em>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += renderId('{% trans "Order ID" %}', data.pk, parameters);
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "SalesOrder" model
|
// Renderer for "SalesOrder" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderSalesOrder(data, parameters={}) {
|
||||||
function renderSalesOrder(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var html = `<span>${data.reference}</span>`;
|
let image = blankImage();
|
||||||
|
|
||||||
var thumbnail = null;
|
|
||||||
|
|
||||||
if (data.customer_detail) {
|
if (data.customer_detail) {
|
||||||
thumbnail = data.customer_detail.thumbnail || data.customer_detail.image;
|
image = data.customer_detail.thumbnail || data.customer_detail.image || blankImage();
|
||||||
|
|
||||||
html += ' - ' + select2Thumbnail(thumbnail);
|
|
||||||
html += `<span>${data.customer_detail.name}</span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.description) {
|
return renderModel(
|
||||||
html += ` - <em>${trim(data.description)}</em>`;
|
{
|
||||||
}
|
image: image,
|
||||||
|
text: `${data.reference} - ${data.customer_detail.name}`,
|
||||||
html += renderId('{% trans "Order ID" %}', data.pk, parameters);
|
textSecondary: shortenString(data.description),
|
||||||
|
url: data.url || `/order/sales-order/${data.pk}/`,
|
||||||
return html;
|
},
|
||||||
|
parameters
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "SalesOrderShipment" model
|
// Renderer for "SalesOrderShipment" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderSalesOrderShipment(data, parameters={}) {
|
||||||
function renderSalesOrderShipment(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var html = `
|
return renderModel(
|
||||||
<span>${data.order_detail.reference} - {% trans "Shipment" %} ${data.reference}</span>
|
{
|
||||||
<span class='float-right'>
|
text: data.order_detail.reference,
|
||||||
<small>{% trans "Shipment ID" %}: ${data.pk}</small>
|
textSecondary: `{% trans "Shipment" %} ${data.reference}`,
|
||||||
</span>
|
},
|
||||||
`;
|
parameters
|
||||||
|
);
|
||||||
html += renderId('{% trans "Shipment ID" %}', data.pk, parameters);
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "PartCategory" model
|
// Renderer for "PartCategory" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderPartCategory(data, parameters={}) {
|
||||||
function renderPartCategory(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var level = '- '.repeat(data.level);
|
let level = '- '.repeat(data.level);
|
||||||
|
|
||||||
var html = `<span>${level}${data.pathstring}</span>`;
|
return renderModel(
|
||||||
|
{
|
||||||
if (data.description) {
|
text: `${level}${data.pathstring}`,
|
||||||
html += ` - <i>${trim(data.description)}</i>`;
|
textSecondary: shortenString(data.description),
|
||||||
}
|
url: data.url || `/part/category/${data.pk}/`,
|
||||||
|
},
|
||||||
html += renderId('{% trans "Category ID" %}', data.pk, parameters);
|
parameters
|
||||||
|
);
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
function renderPartParameterTemplate(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var units = '';
|
function renderPartParameterTemplate(data, parameters={}) {
|
||||||
|
|
||||||
|
let units = '';
|
||||||
|
|
||||||
if (data.units) {
|
if (data.units) {
|
||||||
units = ` [${data.units}]`;
|
units = ` [${data.units}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = `<span>${data.name}${units}</span>`;
|
return renderModel(
|
||||||
|
{
|
||||||
return html;
|
text: `${data.name}${units}`,
|
||||||
|
},
|
||||||
|
parameters
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "ManufacturerPart" model
|
// Renderer for "ManufacturerPart" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderManufacturerPart(data, parameters={}) {
|
||||||
function renderManufacturerPart(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var manufacturer_image = null;
|
return renderModel(
|
||||||
var part_image = null;
|
{
|
||||||
|
image: data.manufacturer_detail ? data.manufacturer_detail.thumbnail || data.manufacturer_detail.image || blankImage() : null,
|
||||||
if (data.manufacturer_detail) {
|
imageSecondary: data.part.detail ? data.part_detail.thumbnail || data.part_detail.image || blankImage() : null,
|
||||||
manufacturer_image = data.manufacturer_detail.image;
|
text: `${data.manufacturer_detail.name} - ${data.MPN}`,
|
||||||
}
|
textSecondary: data.part_detail.full_name,
|
||||||
|
url: data.url || `/manufacturer-part/${data.pk}/`,
|
||||||
if (data.part_detail) {
|
},
|
||||||
part_image = data.part_detail.thumbnail || data.part_detail.image;
|
parameters
|
||||||
}
|
);
|
||||||
|
|
||||||
var html = '';
|
|
||||||
|
|
||||||
html += select2Thumbnail(manufacturer_image);
|
|
||||||
html += select2Thumbnail(part_image);
|
|
||||||
|
|
||||||
if (data.manufacturer_detail) {
|
|
||||||
html += ` <span><b>${data.manufacturer_detail.name}</b> - ${data.MPN}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.part_detail) {
|
|
||||||
html += ` - <i>${data.part_detail.full_name}</i>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += renderId('{% trans "Manufacturer Part ID" %}', data.pk, parameters);
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Renderer for "SupplierPart" model
|
// Renderer for "SupplierPart" model
|
||||||
// eslint-disable-next-line no-unused-vars
|
function renderSupplierPart(data, parameters={}) {
|
||||||
function renderSupplierPart(name, data, parameters={}, options={}) {
|
|
||||||
|
|
||||||
var supplier_image = null;
|
return renderModel(
|
||||||
var part_image = null;
|
{
|
||||||
|
image: data.supplier_detail ? data.supplier_detail.thumbnail || data.supplier_detail.image || blankImage() : null,
|
||||||
if (data.supplier_detail) {
|
imageSecondary: data.part_detail ? data.part_detail.thumbnail || data.part_detail.image || blankImage() : null,
|
||||||
supplier_image = data.supplier_detail.image;
|
text: `${data.supplier_detail.name} - ${data.SKU}`,
|
||||||
}
|
textSecondary: data.part_detail.full_name,
|
||||||
|
url: data.url || `/supplier-part/${data.pk}/`
|
||||||
if (data.part_detail) {
|
},
|
||||||
part_image = data.part_detail.thumbnail || data.part_detail.image;
|
parameters
|
||||||
}
|
);
|
||||||
|
|
||||||
var html = '';
|
|
||||||
|
|
||||||
html += select2Thumbnail(supplier_image);
|
|
||||||
|
|
||||||
if (data.part_detail) {
|
|
||||||
html += select2Thumbnail(part_image);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.supplier_detail) {
|
|
||||||
html += ` <span><b>${data.supplier_detail.name}</b> - ${data.SKU}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.part_detail) {
|
|
||||||
html += ` - <i>${data.part_detail.full_name}</i>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
html += renderId('{% trans "Supplier Part ID" %}', data.pk, parameters);
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
}
|
||||||
|
@ -640,7 +640,7 @@ function partStockLabel(part, options={}) {
|
|||||||
var stock_health = part.unallocated_stock + part.building + part.ordering - part.minimum_stock;
|
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!
|
// 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
|
// Check for demand from unallocated build orders
|
||||||
var required_build_order_quantity = null;
|
var required_build_order_quantity = null;
|
||||||
|
@ -60,6 +60,10 @@ function openSearchPanel() {
|
|||||||
|
|
||||||
var panel = $('#offcanvas-search');
|
var panel = $('#offcanvas-search');
|
||||||
|
|
||||||
|
let search_input = panel.find('#search-input');
|
||||||
|
search_input.find('#search-input').val('');
|
||||||
|
search_input.focus();
|
||||||
|
|
||||||
clearSearchResults();
|
clearSearchResults();
|
||||||
|
|
||||||
// Request user roles if we do not have them
|
// Request user roles if we do not have them
|
||||||
@ -72,7 +76,7 @@ function openSearchPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Callback for text input changed
|
// Callback for text input changed
|
||||||
panel.find('#search-input').on('keyup change', searchTextChanged);
|
search_input.on('keyup change', searchTextChanged);
|
||||||
|
|
||||||
// Callback for "clear search" button
|
// Callback for "clear search" button
|
||||||
panel.find('#search-clear').click(function(event) {
|
panel.find('#search-clear').click(function(event) {
|
||||||
@ -80,7 +84,7 @@ function openSearchPanel() {
|
|||||||
// Prevent this button from actually submitting the form
|
// Prevent this button from actually submitting the form
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
panel.find('#search-input').val('');
|
search_input('#search-input').val('');
|
||||||
clearSearchResults();
|
clearSearchResults();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -94,7 +98,9 @@ function openSearchPanel() {
|
|||||||
var searchInputTimer = null;
|
var searchInputTimer = null;
|
||||||
var searchText = null;
|
var searchText = null;
|
||||||
var searchTextCurrent = null;
|
var searchTextCurrent = null;
|
||||||
var searchQueries = [];
|
var searchQuery = null;
|
||||||
|
var searchResultTypes = [];
|
||||||
|
var searchRequest = null;
|
||||||
|
|
||||||
function searchTextChanged(event) {
|
function searchTextChanged(event) {
|
||||||
|
|
||||||
@ -121,41 +127,40 @@ function updateSearch() {
|
|||||||
|
|
||||||
searchTextCurrent = searchText;
|
searchTextCurrent = searchText;
|
||||||
|
|
||||||
// Cancel any previous AJAX requests
|
// Cancel previous search request
|
||||||
searchQueries.forEach(function(query) {
|
if (searchRequest != null) {
|
||||||
query.abort();
|
searchRequest.abort();
|
||||||
});
|
searchRequest = null;
|
||||||
|
}
|
||||||
searchQueries = [];
|
|
||||||
|
|
||||||
// Show the "searching" text
|
// Show the "searching" text
|
||||||
$('#offcanvas-search').find('#search-pending').show();
|
$('#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) {
|
if (checkPermission('part') && user_settings.SEARCH_PREVIEW_SHOW_PARTS) {
|
||||||
|
|
||||||
var params = {};
|
let filters = {};
|
||||||
|
|
||||||
if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
|
if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
|
||||||
// Return *only* active parts
|
// Return *only* active parts
|
||||||
params.active = true;
|
filters.active = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for matching parts
|
addSearchQuery('part', '{% trans "Parts" %}', filters);
|
||||||
addSearchQuery(
|
|
||||||
'part',
|
|
||||||
'{% trans "Parts" %}',
|
|
||||||
'{% url "api-part-list" %}',
|
|
||||||
params,
|
|
||||||
renderPart,
|
|
||||||
{
|
|
||||||
url: '/part',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkPermission('part') && checkPermission('purchase_order')) {
|
if (checkPermission('part') && checkPermission('purchase_order')) {
|
||||||
|
|
||||||
var params = {
|
let filters = {
|
||||||
part_detail: true,
|
part_detail: true,
|
||||||
supplier_detail: true,
|
supplier_detail: true,
|
||||||
manufacturer_detail: true,
|
manufacturer_detail: true,
|
||||||
@ -163,54 +168,26 @@ function updateSearch() {
|
|||||||
|
|
||||||
if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
|
if (user_settings.SEARCH_HIDE_INACTIVE_PARTS) {
|
||||||
// Return *only* active parts
|
// Return *only* active parts
|
||||||
params.active = true;
|
filters.active = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user_settings.SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS) {
|
if (user_settings.SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS) {
|
||||||
addSearchQuery(
|
addSearchQuery('supplierpart', '{% trans "Supplier Parts" %}', filters);
|
||||||
'supplierpart',
|
|
||||||
'{% trans "Supplier Parts" %}',
|
|
||||||
'{% url "api-supplier-part-list" %}',
|
|
||||||
params,
|
|
||||||
renderSupplierPart,
|
|
||||||
{
|
|
||||||
url: '/supplier-part',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user_settings.SEARCH_PREVIEW_SHOW_MANUFACTURER_PARTS) {
|
if (user_settings.SEARCH_PREVIEW_SHOW_MANUFACTURER_PARTS) {
|
||||||
addSearchQuery(
|
addSearchQuery('manufacturerpart', '{% trans "Manufacturer Parts" %}', filters);
|
||||||
'manufacturerpart',
|
|
||||||
'{% trans "Manufacturer Parts" %}',
|
|
||||||
'{% url "api-manufacturer-part-list" %}',
|
|
||||||
params,
|
|
||||||
renderManufacturerPart,
|
|
||||||
{
|
|
||||||
url: '/manufacturer-part',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkPermission('part_category') && user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) {
|
if (checkPermission('part_category') && user_settings.SEARCH_PREVIEW_SHOW_CATEGORIES) {
|
||||||
// Search for matching part categories
|
let filters = {};
|
||||||
addSearchQuery(
|
|
||||||
'category',
|
addSearchQuery('partcategory', '{% trans "Part Categories" %}', filters);
|
||||||
'{% trans "Part Categories" %}',
|
|
||||||
'{% url "api-part-category-list" %}',
|
|
||||||
{},
|
|
||||||
renderPartCategory,
|
|
||||||
{
|
|
||||||
url: '/part/category',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkPermission('stock') && user_settings.SEARCH_PREVIEW_SHOW_STOCK) {
|
if (checkPermission('stock') && user_settings.SEARCH_PREVIEW_SHOW_STOCK) {
|
||||||
// Search for matching stock items
|
let filters = {
|
||||||
|
|
||||||
var filters = {
|
|
||||||
part_detail: true,
|
part_detail: true,
|
||||||
location_detail: true,
|
location_detail: true,
|
||||||
};
|
};
|
||||||
@ -220,61 +197,27 @@ function updateSearch() {
|
|||||||
filters.in_stock = true;
|
filters.in_stock = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
addSearchQuery(
|
addSearchQuery('stockitem', '{% trans "Stock Items" %}', filters);
|
||||||
'stock',
|
|
||||||
'{% trans "Stock Items" %}',
|
|
||||||
'{% url "api-stock-list" %}',
|
|
||||||
filters,
|
|
||||||
renderStockItem,
|
|
||||||
{
|
|
||||||
url: '/stock/item',
|
|
||||||
render_location_detail: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkPermission('stock_location') && user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) {
|
if (checkPermission('stock_location') && user_settings.SEARCH_PREVIEW_SHOW_LOCATIONS) {
|
||||||
// Search for matching stock locations
|
let filters = {};
|
||||||
addSearchQuery(
|
|
||||||
'location',
|
addSearchQuery('stocklocation', '{% trans "Stock Locations" %}', filters);
|
||||||
'{% trans "Stock Locations" %}',
|
|
||||||
'{% url "api-location-list" %}',
|
|
||||||
{},
|
|
||||||
renderStockLocation,
|
|
||||||
{
|
|
||||||
url: '/stock/location',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkPermission('build') && user_settings.SEARCH_PREVIEW_SHOW_BUILD_ORDERS) {
|
if (checkPermission('build') && user_settings.SEARCH_PREVIEW_SHOW_BUILD_ORDERS) {
|
||||||
// Search for matching build orders
|
let filters = {
|
||||||
addSearchQuery(
|
part_detail: true
|
||||||
'build',
|
};
|
||||||
'{% trans "Build Orders" %}',
|
|
||||||
'{% url "api-build-list" %}',
|
addSearchQuery('build', '{% trans "Build Orders" %}', filters);
|
||||||
{
|
|
||||||
part_detail: true,
|
|
||||||
},
|
|
||||||
renderBuild,
|
|
||||||
{
|
|
||||||
url: '/build',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((checkPermission('sales_order') || checkPermission('purchase_order')) && user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) {
|
if ((checkPermission('sales_order') || checkPermission('purchase_order')) && user_settings.SEARCH_PREVIEW_SHOW_COMPANIES) {
|
||||||
// Search for matching companies
|
let filters = {};
|
||||||
addSearchQuery(
|
|
||||||
'company',
|
addSearchQuery('company', '{% trans "Companies" %}', filters);
|
||||||
'{% trans "Companies" %}',
|
|
||||||
'{% url "api-company-list" %}',
|
|
||||||
{},
|
|
||||||
renderCompany,
|
|
||||||
{
|
|
||||||
url: '/company',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkPermission('purchase_order') && user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) {
|
if (checkPermission('purchase_order') && user_settings.SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS) {
|
||||||
@ -287,17 +230,7 @@ function updateSearch() {
|
|||||||
filters.outstanding = true;
|
filters.outstanding = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for matching purchase orders
|
addSearchQuery('purchaseorder', '{% trans "Purchase Orders" %}', filters);
|
||||||
addSearchQuery(
|
|
||||||
'purchaseorder',
|
|
||||||
'{% trans "Purchase Orders" %}',
|
|
||||||
'{% url "api-po-list" %}',
|
|
||||||
filters,
|
|
||||||
renderPurchaseOrder,
|
|
||||||
{
|
|
||||||
url: '/order/purchase-order',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (checkPermission('sales_order') && user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) {
|
if (checkPermission('sales_order') && user_settings.SEARCH_PREVIEW_SHOW_SALES_ORDERS) {
|
||||||
@ -311,23 +244,55 @@ function updateSearch() {
|
|||||||
filters.outstanding = true;
|
filters.outstanding = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for matching sales orders
|
addSearchQuery('salesorder', '{% trans "Sales Orders" %}', filters);
|
||||||
addSearchQuery(
|
|
||||||
'salesorder',
|
|
||||||
'{% trans "Sales Orders" %}',
|
|
||||||
'{% url "api-so-list" %}',
|
|
||||||
filters,
|
|
||||||
renderSalesOrder,
|
|
||||||
{
|
|
||||||
url: '/order/sales-order',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait until all the pending queries are completed
|
let ctx = $('#offcanvas-search').find('#search-context');
|
||||||
$.when.apply($, searchQueries).done(function() {
|
|
||||||
$('#offcanvas-search').find('#search-pending').hide();
|
ctx.html(`
|
||||||
});
|
<div class='alert alert-block alert-secondary'>
|
||||||
|
<span class='fas fa-spinner fa-spin'></span> <em>{% trans "Searching" %}</em>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 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(`
|
||||||
|
<div class='alert alert-block alert-warning'>
|
||||||
|
<span class='fas fa-exclamation-circle'></span> <em>{% trans "No results" %}</em>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
// Hide the "pending" icon
|
||||||
|
$('#offcanvas-search').find('#search-pending').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -335,12 +300,14 @@ function clearSearchResults() {
|
|||||||
|
|
||||||
var panel = $('#offcanvas-search');
|
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-pending').hide();
|
||||||
|
|
||||||
|
panel.find('#search-context').html(`
|
||||||
|
<div class='alert alert-block alert-info'>
|
||||||
|
<span class='fas fa-search'></span> <em>{% trans "Enter search query" %}</em>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
// Delete any existing search results
|
// Delete any existing search results
|
||||||
panel.find('#search-results').empty();
|
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
|
searchQuery[key] = query_params;
|
||||||
query_params.search = searchTextCurrent;
|
|
||||||
|
|
||||||
// How many results to show in each group?
|
render_params.showImage = true;
|
||||||
query_params.offset = 0;
|
render_params.showLink = true;
|
||||||
query_params.limit = user_settings.SEARCH_PREVIEW_RESULTS;
|
render_params.showLabels = true;
|
||||||
|
|
||||||
// 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(`
|
|
||||||
<div class='search-result-group-wrapper' id='search-results-wrapper-${key}'></div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
|
searchResultTypes.push({
|
||||||
|
key: key,
|
||||||
|
title: title,
|
||||||
|
renderer: getModelRenderer(key),
|
||||||
|
renderParams: render_params,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Add a group of results to the list
|
// Add a group of results to the list
|
||||||
function addSearchResults(key, results, title, renderFunc, renderParams={}) {
|
function addSearchResults(results, resultType) {
|
||||||
|
|
||||||
if (results.length == 0) {
|
if (results.length == 0) {
|
||||||
// Do not display this group, as there are no results
|
// Do not display this group, as there are no results
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var panel = $('#offcanvas-search');
|
let panel = $('#offcanvas-search');
|
||||||
|
|
||||||
// Ensure the 'no results found' element is hidden
|
// Ensure the 'no results found' element is hidden
|
||||||
panel.find('#search-no-results').hide();
|
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(`
|
||||||
|
<div class='search-result-group-wrapper' id='search-results-wrapper-${key}'>
|
||||||
<div class='search-result-group' id='search-results-${key}'>
|
<div class='search-result-group' id='search-results-${key}'>
|
||||||
<div class='search-result-header' style='display: flex;'>
|
<div class='search-result-header' style='display: flex;'>
|
||||||
<h5>${title}</h5>
|
<h5>${title}</h5>
|
||||||
@ -418,17 +373,14 @@ function addSearchResults(key, results, title, renderFunc, renderParams={}) {
|
|||||||
<div class='collapse search-result-list' id='search-result-list-${key}'>
|
<div class='collapse search-result-list' id='search-result-list-${key}'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`);
|
`);
|
||||||
|
|
||||||
results.forEach(function(result) {
|
results.forEach(function(result) {
|
||||||
|
|
||||||
var pk = result.pk || result.id;
|
var pk = result.pk || result.id;
|
||||||
|
|
||||||
var html = renderFunc(key, result, renderParams);
|
var html = renderer(result, renderParams);
|
||||||
|
|
||||||
if (renderParams.url) {
|
|
||||||
html = `<a href='${renderParams.url}/${pk}/'>` + html + `</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result_html = `
|
var result_html = `
|
||||||
<div class='search-result-entry' id='search-result-${key}-${pk}'>
|
<div class='search-result-entry' id='search-result-${key}-${pk}'>
|
||||||
|
@ -12,11 +12,6 @@
|
|||||||
<button id='search-clear' class='btn btn-outline-secondary' title='{% trans "Clear search" %}'>
|
<button id='search-clear' class='btn btn-outline-secondary' title='{% trans "Clear search" %}'>
|
||||||
<span class='fas fa-backspace'></span>
|
<span class='fas fa-backspace'></span>
|
||||||
</button>
|
</button>
|
||||||
<!--
|
|
||||||
<button id='search-filter' class="btn btn-outline-secondary" title='{% trans "Filter results" %}'>
|
|
||||||
<span class='fas fa-filter'></span>
|
|
||||||
</button>
|
|
||||||
-->
|
|
||||||
<button id='search-close' class="btn btn-outline-secondary" data-bs-dismiss='offcanvas' title='{% trans "Close search menu" %}'>
|
<button id='search-close' class="btn btn-outline-secondary" data-bs-dismiss='offcanvas' title='{% trans "Close search menu" %}'>
|
||||||
<span class='fas fa-times icon-red'></span>
|
<span class='fas fa-times icon-red'></span>
|
||||||
</button>
|
</button>
|
||||||
@ -25,15 +20,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="offcanvas-body">
|
<div class="offcanvas-body">
|
||||||
<div id="search-center">
|
<div id="search-center">
|
||||||
<p id='search-pending' class='text-muted' display='none'>
|
<div id='search-context'>
|
||||||
<em>{% trans "Searching" %}...</em>
|
<div class='alert alert-block alert-info'>
|
||||||
<span class='float-right'>
|
<span class='fas fa-search'></span> <em>{% trans "Enter search query" %}</em>
|
||||||
<span class='fas fa-spinner fa-spin'></span>
|
</div>
|
||||||
</span>
|
</div>
|
||||||
</p>
|
|
||||||
<p id='search-no-results' class='text-muted'>
|
|
||||||
<em>{% trans "No search results" %}</em>
|
|
||||||
</p>
|
|
||||||
<div id='search-results'>
|
<div id='search-results'>
|
||||||
<!-- Search results go here -->
|
<!-- Search results go here -->
|
||||||
</div>
|
</div>
|
||||||
|
@ -261,8 +261,7 @@ class RuleSet(models.Model):
|
|||||||
|
|
||||||
# Print message instead of throwing an error
|
# Print message instead of throwing an error
|
||||||
name = getattr(user, 'name', user.pk)
|
name = getattr(user, 'name', user.pk)
|
||||||
|
logger.debug(f"User '{name}' failed permission check for {table}.{permission}")
|
||||||
logger.info(f"User '{name}' failed permission check for {table}.{permission}")
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -451,7 +450,7 @@ def update_group_roles(group, debug=False):
|
|||||||
group.permissions.add(permission)
|
group.permissions.add(permission)
|
||||||
|
|
||||||
if debug: # pragma: no cover
|
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
|
# Remove any extra permissions from the group
|
||||||
for perm in permissions_to_delete:
|
for perm in permissions_to_delete:
|
||||||
@ -466,7 +465,7 @@ def update_group_roles(group, debug=False):
|
|||||||
group.permissions.remove(permission)
|
group.permissions.remove(permission)
|
||||||
|
|
||||||
if debug: # pragma: no cover
|
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
|
# Enable all action permissions for certain children models
|
||||||
# if parent model has 'change' permission
|
# if parent model has 'change' permission
|
||||||
@ -488,7 +487,7 @@ def update_group_roles(group, debug=False):
|
|||||||
permission = get_permission_object(child_perm)
|
permission = get_permission_object(child_perm)
|
||||||
if permission:
|
if permission:
|
||||||
group.permissions.add(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):
|
def clear_user_role_cache(user):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user