2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-05-03 13:58:47 +00:00

Merge pull request #2275 from SchrodingersGat/settings-via-api

Settings via api
This commit is contained in:
Oliver 2021-11-10 09:06:02 +11:00 committed by GitHub
commit 558c2cc275
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 415 additions and 327 deletions

View File

@ -43,7 +43,6 @@ jobs:
run: | run: |
npm install markuplint npm install markuplint
npx markuplint InvenTree/build/templates/build/*.html npx markuplint InvenTree/build/templates/build/*.html
npx markuplint InvenTree/common/templates/common/*.html
npx markuplint InvenTree/company/templates/company/*.html npx markuplint InvenTree/company/templates/company/*.html
npx markuplint InvenTree/order/templates/order/*.html npx markuplint InvenTree/order/templates/order/*.html
npx markuplint InvenTree/part/templates/part/*.html npx markuplint InvenTree/part/templates/part/*.html

View File

@ -42,8 +42,6 @@ from .views import CurrencyRefreshView
from .views import AppearanceSelectView, SettingCategorySelectView from .views import AppearanceSelectView, SettingCategorySelectView
from .views import DynamicJsView from .views import DynamicJsView
from common.views import SettingEdit, UserSettingEdit
from .api import InfoView, NotFoundView from .api import InfoView, NotFoundView
from .api import ActionPluginView from .api import ActionPluginView
@ -53,7 +51,7 @@ admin.site.site_header = "InvenTree Admin"
apipatterns = [ apipatterns = [
url(r'^barcode/', include(barcode_api_urls)), url(r'^barcode/', include(barcode_api_urls)),
url(r'^common/', include(common_api_urls)), url(r'^settings/', include(common_api_urls)),
url(r'^part/', include(part_api_urls)), url(r'^part/', include(part_api_urls)),
url(r'^bom/', include(bom_api_urls)), url(r'^bom/', include(bom_api_urls)),
url(r'^company/', include(company_api_urls)), url(r'^company/', include(company_api_urls)),
@ -85,9 +83,6 @@ settings_urls = [
url(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'), url(r'^category/', SettingCategorySelectView.as_view(), name='settings-category'),
url(r'^(?P<pk>\d+)/edit/user', UserSettingEdit.as_view(), name='user-setting-edit'),
url(r'^(?P<pk>\d+)/edit/', SettingEdit.as_view(), name='setting-edit'),
# Catch any other urls # Catch any other urls
url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'), url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'),
] ]

View File

@ -12,11 +12,15 @@ import common.models
INVENTREE_SW_VERSION = "0.6.0 dev" INVENTREE_SW_VERSION = "0.6.0 dev"
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 16 INVENTREE_API_VERSION = 17
""" """
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
v17 -> 2021-11-09
- Adds API endpoints for GLOBAL and USER settings objects
- Ref: https://github.com/inventree/InvenTree/pull/2275
v16 -> 2021-10-17 v16 -> 2021-10-17
- Adds API endpoint for completing build order outputs - Adds API endpoint for completing build order outputs

View File

@ -5,5 +5,149 @@ Provides a JSON API for common components.
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.conf.urls import url, include
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, generics, permissions
import common.models
import common.serializers
class SettingsList(generics.ListAPIView):
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
ordering_fields = [
'pk',
'key',
'name',
]
search_fields = [
'key',
]
class GlobalSettingsList(SettingsList):
"""
API endpoint for accessing a list of global settings objects
"""
queryset = common.models.InvenTreeSetting.objects.all()
serializer_class = common.serializers.GlobalSettingsSerializer
class GlobalSettingsPermissions(permissions.BasePermission):
"""
Special permission class to determine if the user is "staff"
"""
def has_permission(self, request, view):
"""
Check that the requesting user is 'admin'
"""
try:
user = request.user
return user.is_staff
except AttributeError:
return False
class GlobalSettingsDetail(generics.RetrieveUpdateAPIView):
"""
Detail view for an individual "global setting" object.
- User must have 'staff' status to view / edit
"""
queryset = common.models.InvenTreeSetting.objects.all()
serializer_class = common.serializers.GlobalSettingsSerializer
permission_classes = [
GlobalSettingsPermissions,
]
class UserSettingsList(SettingsList):
"""
API endpoint for accessing a list of user settings objects
"""
queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer
def filter_queryset(self, queryset):
"""
Only list settings which apply to the current user
"""
try:
user = self.request.user
except AttributeError:
return common.models.InvenTreeUserSetting.objects.none()
queryset = super().filter_queryset(queryset)
queryset = queryset.filter(user=user)
return queryset
class UserSettingsPermissions(permissions.BasePermission):
"""
Special permission class to determine if the user can view / edit a particular setting
"""
def has_object_permission(self, request, view, obj):
try:
user = request.user
except AttributeError:
return False
return user == obj.user
class UserSettingsDetail(generics.RetrieveUpdateAPIView):
"""
Detail view for an individual "user setting" object
- User can only view / edit settings their own settings objects
"""
queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer
permission_classes = [
UserSettingsPermissions,
]
common_api_urls = [ common_api_urls = [
# User settings
url(r'^user/', include([
# User Settings Detail
url(r'^(?P<pk>\d+)/', UserSettingsDetail.as_view(), name='api-user-setting-detail'),
# User Settings List
url(r'^.*$', UserSettingsList.as_view(), name='api-user-setting-list'),
])),
# Global settings
url(r'^global/', include([
# Global Settings Detail
url(r'^(?P<pk>\d+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'),
# Global Settings List
url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'),
]))
] ]

View File

@ -34,6 +34,19 @@ import logging
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
class EmptyURLValidator(URLValidator):
def __call__(self, value):
value = str(value).strip()
if len(value) == 0:
pass
else:
super().__call__(value)
class BaseInvenTreeSetting(models.Model): class BaseInvenTreeSetting(models.Model):
""" """
An base InvenTreeSetting object is a key:value pair used for storing An base InvenTreeSetting object is a key:value pair used for storing
@ -45,6 +58,16 @@ class BaseInvenTreeSetting(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def save(self, *args, **kwargs):
"""
Enforce validation and clean before saving
"""
self.clean()
self.validate_unique()
super().save()
@classmethod @classmethod
def allValues(cls, user=None): def allValues(cls, user=None):
""" """
@ -343,6 +366,11 @@ class BaseInvenTreeSetting(models.Model):
except (ValueError): except (ValueError):
raise ValidationError(_('Must be an integer value')) raise ValidationError(_('Must be an integer value'))
options = self.valid_options()
if options and self.value not in options:
raise ValidationError(_("Chosen value is not a valid option"))
if validator is not None: if validator is not None:
self.run_validator(validator) self.run_validator(validator)
@ -409,6 +437,18 @@ class BaseInvenTreeSetting(models.Model):
return self.__class__.get_setting_choices(self.key) return self.__class__.get_setting_choices(self.key)
def valid_options(self):
"""
Return a list of valid options for this setting
"""
choices = self.choices()
if not choices:
return None
return [opt[0] for opt in choices]
def is_bool(self): def is_bool(self):
""" """
Check if this setting is required to be a boolean value Check if this setting is required to be a boolean value
@ -427,6 +467,20 @@ class BaseInvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value) return InvenTree.helpers.str2bool(self.value)
def setting_type(self):
"""
Return the field type identifier for this setting object
"""
if self.is_bool():
return 'boolean'
elif self.is_int():
return 'integer'
else:
return 'string'
@classmethod @classmethod
def validator_is_bool(cls, validator): def validator_is_bool(cls, validator):
@ -531,7 +585,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'INVENTREE_BASE_URL': { 'INVENTREE_BASE_URL': {
'name': _('Base URL'), 'name': _('Base URL'),
'description': _('Base URL for server instance'), 'description': _('Base URL for server instance'),
'validator': URLValidator(), 'validator': EmptyURLValidator(),
'default': '', 'default': '',
}, },
@ -850,7 +904,7 @@ class InvenTreeSetting(BaseInvenTreeSetting):
}, },
'SIGNUP_GROUP': { 'SIGNUP_GROUP': {
'name': _('Group on signup'), 'name': _('Group on signup'),
'description': _('Group new user are asigned on registration'), 'description': _('Group to which new users are assigned on registration'),
'default': '', 'default': '',
'choices': settings_group_options 'choices': settings_group_options
}, },
@ -867,6 +921,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
help_text=_('Settings key (must be unique - case insensitive'), help_text=_('Settings key (must be unique - case insensitive'),
) )
def to_native_value(self):
"""
Return the "pythonic" value,
e.g. convert "True" to True, and "1" to 1
"""
return self.__class__.get_setting(self.key)
class InvenTreeUserSetting(BaseInvenTreeSetting): class InvenTreeUserSetting(BaseInvenTreeSetting):
""" """
@ -1077,6 +1139,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting):
'user__id': kwargs['user'].id 'user__id': kwargs['user'].id
} }
def to_native_value(self):
"""
Return the "pythonic" value,
e.g. convert "True" to True, and "1" to 1
"""
return self.__class__.get_setting(self.key, user=self.user)
class PriceBreak(models.Model): class PriceBreak(models.Model):
""" """

View File

@ -1,3 +1,85 @@
""" """
JSON serializers for common components JSON serializers for common components
""" """
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from InvenTree.serializers import InvenTreeModelSerializer
from rest_framework import serializers
from common.models import InvenTreeSetting, InvenTreeUserSetting
class SettingsSerializer(InvenTreeModelSerializer):
"""
Base serializer for a settings object
"""
key = serializers.CharField(read_only=True)
name = serializers.CharField(read_only=True)
description = serializers.CharField(read_only=True)
type = serializers.CharField(source='setting_type', read_only=True)
choices = serializers.SerializerMethodField()
def get_choices(self, obj):
"""
Returns the choices available for a given item
"""
results = []
choices = obj.choices()
if choices:
for choice in choices:
results.append({
'value': choice[0],
'display_name': choice[1],
})
return results
class GlobalSettingsSerializer(SettingsSerializer):
"""
Serializer for the InvenTreeSetting model
"""
class Meta:
model = InvenTreeSetting
fields = [
'pk',
'key',
'value',
'name',
'description',
'type',
'choices',
]
class UserSettingsSerializer(SettingsSerializer):
"""
Serializer for the InvenTreeUserSetting model
"""
user = serializers.PrimaryKeyRelatedField(read_only=True)
class Meta:
model = InvenTreeUserSetting
fields = [
'pk',
'key',
'value',
'name',
'description',
'user',
'type',
'choices',
]

View File

@ -1,14 +0,0 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
{{ block.super }}
<!--
<p>
<strong>{{ name }}</strong><br>
{{ description }}<br>
<em>{% trans "Current value" %}: {{ value }}</em>
</p>
-->
{% endblock %}

View File

@ -4,156 +4,3 @@ Unit tests for the views associated with the 'common' app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import json
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from common.models import InvenTreeSetting
class SettingsViewTest(TestCase):
"""
Tests for the settings management views
"""
fixtures = [
'settings',
]
def setUp(self):
super().setUp()
# Create a user (required to access the views / forms)
self.user = get_user_model().objects.create_user(
username='username',
email='me@email.com',
password='password',
)
self.client.login(username='username', password='password')
def get_url(self, pk):
return reverse('setting-edit', args=(pk,))
def get_setting(self, title):
return InvenTreeSetting.get_setting_object(title)
def get(self, url, status=200):
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, status)
data = json.loads(response.content)
return response, data
def post(self, url, data, valid=None):
response = self.client.post(url, data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
json_data = json.loads(response.content)
# If a particular status code is required
if valid is not None:
if valid:
self.assertEqual(json_data['form_valid'], True)
else:
self.assertEqual(json_data['form_valid'], False)
form_errors = json.loads(json_data['form_errors'])
return json_data, form_errors
def test_instance_name(self):
"""
Test that we can get the settings view for particular setting objects.
"""
# Start with something basic - load the settings view for INVENTREE_INSTANCE
setting = self.get_setting('INVENTREE_INSTANCE')
self.assertIsNotNone(setting)
self.assertEqual(setting.value, 'My very first InvenTree Instance')
url = self.get_url(setting.pk)
self.get(url)
new_name = 'A new instance name!'
# Change the instance name via the form
data, errors = self.post(url, {'value': new_name}, valid=True)
name = InvenTreeSetting.get_setting('INVENTREE_INSTANCE')
self.assertEqual(name, new_name)
def test_choices(self):
"""
Tests for a setting which has choices
"""
setting = InvenTreeSetting.get_setting_object('PURCHASEORDER_REFERENCE_PREFIX')
# Default value!
self.assertEqual(setting.value, 'PO')
url = self.get_url(setting.pk)
# Try posting an invalid currency option
data, errors = self.post(url, {'value': 'Purchase Order'}, valid=True)
def test_binary_values(self):
"""
Test for binary value
"""
setting = InvenTreeSetting.get_setting_object('PART_COMPONENT')
self.assertTrue(setting.as_bool())
url = self.get_url(setting.pk)
setting.value = True
setting.save()
# Try posting some invalid values
# The value should be "cleaned" and stay the same
for value in ['', 'abc', 'cat', 'TRUETRUETRUE']:
self.post(url, {'value': value}, valid=True)
# Try posting some valid (True) values
for value in [True, 'True', '1', 'yes']:
self.post(url, {'value': value}, valid=True)
self.assertTrue(InvenTreeSetting.get_setting('PART_COMPONENT'))
# Try posting some valid (False) values
for value in [False, 'False']:
self.post(url, {'value': value}, valid=True)
self.assertFalse(InvenTreeSetting.get_setting('PART_COMPONENT'))
def test_part_name_format(self):
"""
Try posting some valid and invalid name formats for PART_NAME_FORMAT
"""
setting = InvenTreeSetting.get_setting_object('PART_NAME_FORMAT')
# test default value
self.assertEqual(setting.value, "{{ part.IPN if part.IPN }}{{ ' | ' if part.IPN }}{{ part.name }}"
"{{ ' | ' if part.revision }}{{ part.revision if part.revision }}")
url = self.get_url(setting.pk)
# Try posting an invalid part name format
invalid_values = ['{{asset.IPN}}', '{{part}}', '{{"|"}}', '{{part.falcon}}']
for invalid_value in invalid_values:
self.post(url, {'value': invalid_value}, valid=False)
# try posting valid value
new_format = "{{ part.name if part.name }} {{ ' with revision ' if part.revision }} {{ part.revision }}"
self.post(url, {'value': new_format}, valid=True)

View File

@ -8,138 +8,18 @@ from __future__ import unicode_literals
import os import os
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.forms import CheckboxInput, Select
from django.conf import settings from django.conf import settings
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from formtools.wizard.views import SessionWizardView from formtools.wizard.views import SessionWizardView
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from InvenTree.views import AjaxUpdateView, AjaxView from InvenTree.views import AjaxView
from InvenTree.helpers import str2bool
from . import models
from . import forms from . import forms
from .files import FileManager from .files import FileManager
class SettingEdit(AjaxUpdateView):
"""
View for editing an InvenTree key:value settings object,
(or creating it if the key does not already exist)
"""
model = models.InvenTreeSetting
ajax_form_title = _('Change Setting')
form_class = forms.SettingEditForm
ajax_template_name = "common/edit_setting.html"
def get_context_data(self, **kwargs):
"""
Add extra context information about the particular setting object.
"""
ctx = super().get_context_data(**kwargs)
setting = self.get_object()
ctx['key'] = setting.key
ctx['value'] = setting.value
ctx['name'] = self.model.get_setting_name(setting.key)
ctx['description'] = self.model.get_setting_description(setting.key)
return ctx
def get_data(self):
"""
Custom data to return to the client after POST success
"""
data = {}
setting = self.get_object()
data['pk'] = setting.pk
data['key'] = setting.key
data['value'] = setting.value
data['is_bool'] = setting.is_bool()
data['is_int'] = setting.is_int()
return data
def get_form(self):
"""
Override default get_form behaviour
"""
form = super().get_form()
setting = self.get_object()
choices = setting.choices()
if choices is not None:
form.fields['value'].widget = Select(choices=choices)
elif setting.is_bool():
form.fields['value'].widget = CheckboxInput()
self.object.value = str2bool(setting.value)
form.fields['value'].value = str2bool(setting.value)
name = self.model.get_setting_name(setting.key)
if name:
form.fields['value'].label = name
description = self.model.get_setting_description(setting.key)
if description:
form.fields['value'].help_text = description
return form
def validate(self, setting, form):
"""
Perform custom validation checks on the form data.
"""
data = form.cleaned_data
value = data.get('value', None)
if setting.choices():
"""
If a set of choices are provided for a given setting,
the provided value must be one of those choices.
"""
choices = [choice[0] for choice in setting.choices()]
if value not in choices:
form.add_error('value', _('Supplied value is not allowed'))
if setting.is_bool():
"""
If a setting is defined as a boolean setting,
the provided value must look somewhat like a boolean value!
"""
if not str2bool(value, test=True) and not str2bool(value, test=False):
form.add_error('value', _('Supplied value must be a boolean'))
class UserSettingEdit(SettingEdit):
"""
View for editing an InvenTree key:value user settings object,
(or creating it if the key does not already exist)
"""
model = models.InvenTreeUserSetting
ajax_form_title = _('Change User Setting')
form_class = forms.SettingEditForm
ajax_template_name = "common/edit_setting.html"
class MultiStepFormView(SessionWizardView): class MultiStepFormView(SessionWizardView):
""" Setup basic methods of multi-step form """ Setup basic methods of multi-step form

View File

@ -50,26 +50,17 @@
$('table').find('.btn-edit-setting').click(function() { $('table').find('.btn-edit-setting').click(function() {
var setting = $(this).attr('setting'); var setting = $(this).attr('setting');
var pk = $(this).attr('pk'); var pk = $(this).attr('pk');
var url = `/settings/${pk}/edit/`;
var is_global = true;
if ($(this).attr('user')){ if ($(this).attr('user')){
url += `user/`; is_global = false;
} }
launchModalForm( editSetting(pk, {
url, global: is_global,
{ title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}',
success: function(response) { });
if (response.is_bool) {
var enabled = response.value.toLowerCase() == 'true';
$(`#setting-value-${setting}`).prop('checked', enabled);
} else {
$(`#setting-value-${setting}`).html(response.value);
}
}
}
);
}); });
$("#edit-user").on('click', function() { $("#edit-user").on('click', function() {

View File

@ -1,6 +1,7 @@
{% load inventree_extras %} {% load inventree_extras %}
/* exported /* exported
editSetting,
user_settings, user_settings,
global_settings, global_settings,
*/ */
@ -18,3 +19,83 @@ const global_settings = {
{{ key }}: {% primitive_to_javascript value %}, {{ key }}: {% primitive_to_javascript value %},
{% endfor %} {% endfor %}
}; };
/*
* Edit a setting value
*/
function editSetting(pk, options={}) {
// Is this a global setting or a user setting?
var global = options.global || false;
var url = '';
if (global) {
url = `/api/settings/global/${pk}/`;
} else {
url = `/api/settings/user/${pk}/`;
}
// First, read the settings object from the server
inventreeGet(url, {}, {
success: function(response) {
if (response.choices && response.choices.length > 0) {
response.type = 'choice';
}
// Construct the field
var fields = {
value: {
label: response.name,
help_text: response.description,
type: response.type,
choices: response.choices,
}
};
constructChangeForm(fields, {
url: url,
method: 'PATCH',
title: options.title,
processResults: function(data, fields, opts) {
switch (data.type) {
case 'boolean':
// Convert to boolean value
data.value = data.value.toString().toLowerCase() == 'true';
break;
case 'integer':
// Convert to integer value
data.value = parseInt(data.value.toString());
break;
default:
break;
}
return data;
},
processBeforeUpload: function(data) {
// Convert value to string
data.value = data.value.toString();
return data;
},
onSuccess: function(response) {
var setting = response.key;
if (response.type == 'boolean') {
var enabled = response.value.toString().toLowerCase() == 'true';
$(`#setting-value-${setting}`).prop('checked', enabled);
} else {
$(`#setting-value-${setting}`).html(response.value);
}
}
});
},
error: function(xhr) {
showApiError(xhr, url);
}
});
}

View File

@ -61,7 +61,11 @@ function inventreeGet(url, filters={}, options={}) {
}, },
error: function(xhr, ajaxOptions, thrownError) { error: function(xhr, ajaxOptions, thrownError) {
console.error('Error on GET at ' + url); console.error('Error on GET at ' + url);
console.error(thrownError);
if (thrownError) {
console.error('Error: ' + thrownError);
}
if (options.error) { if (options.error) {
options.error({ options.error({
error: thrownError error: thrownError
@ -174,7 +178,7 @@ function showApiError(xhr, url) {
var title = null; var title = null;
var message = null; var message = null;
switch (xhr.status) { switch (xhr.status || 0) {
// No response // No response
case 0: case 0:
title = '{% trans "No Response" %}'; title = '{% trans "No Response" %}';

View File

@ -199,14 +199,6 @@ function constructChangeForm(fields, options) {
}, },
success: function(data) { success: function(data) {
// Push existing 'value' to each field
for (const field in data) {
if (field in fields) {
fields[field].value = data[field];
}
}
// An optional function can be provided to process the returned results, // An optional function can be provided to process the returned results,
// before they are rendered to the form // before they are rendered to the form
if (options.processResults) { if (options.processResults) {
@ -218,6 +210,14 @@ function constructChangeForm(fields, options) {
} }
} }
// Push existing 'value' to each field
for (const field in data) {
if (field in fields) {
fields[field].value = data[field];
}
}
// Store the entire data object // Store the entire data object
options.instance = data; options.instance = data;
@ -724,6 +724,11 @@ function submitFormData(fields, options) {
data = form_data; data = form_data;
} }
// Optionally pre-process the data before uploading to the server
if (options.processBeforeUpload) {
data = options.processBeforeUpload(data);
}
// Submit data // Submit data
upload_func( upload_func(
options.url, options.url,

View File

@ -992,7 +992,7 @@ function loadPartTable(table, url, options={}) {
} }
}); });
var grid_view = inventreeLoad('part-grid-view') == 1; var grid_view = options.gridView && inventreeLoad('part-grid-view') == 1;
$(table).inventreeTable({ $(table).inventreeTable({
url: url, url: url,
@ -1020,7 +1020,7 @@ function loadPartTable(table, url, options={}) {
$('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); $('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary');
} }
}, },
buttons: [ buttons: options.gridView ? [
{ {
icon: 'fas fa-bars', icon: 'fas fa-bars',
attributes: { attributes: {
@ -1053,7 +1053,7 @@ function loadPartTable(table, url, options={}) {
); );
} }
} }
], ] : [],
customView: function(data) { customView: function(data) {
var html = ''; var html = '';