diff --git a/.github/workflows/html.yaml b/.github/workflows/html.yaml index 069da7cbb4..d0084ae032 100644 --- a/.github/workflows/html.yaml +++ b/.github/workflows/html.yaml @@ -43,7 +43,6 @@ jobs: run: | npm install markuplint npx markuplint InvenTree/build/templates/build/*.html - npx markuplint InvenTree/common/templates/common/*.html npx markuplint InvenTree/company/templates/company/*.html npx markuplint InvenTree/order/templates/order/*.html npx markuplint InvenTree/part/templates/part/*.html diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 053ba05264..648600265c 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -42,8 +42,6 @@ from .views import CurrencyRefreshView from .views import AppearanceSelectView, SettingCategorySelectView from .views import DynamicJsView -from common.views import SettingEdit, UserSettingEdit - from .api import InfoView, NotFoundView from .api import ActionPluginView @@ -53,7 +51,7 @@ admin.site.site_header = "InvenTree Admin" apipatterns = [ 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'^bom/', include(bom_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'^(?P\d+)/edit/user', UserSettingEdit.as_view(), name='user-setting-edit'), - url(r'^(?P\d+)/edit/', SettingEdit.as_view(), name='setting-edit'), - # Catch any other urls url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'), ] diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index 48539713f2..935a0bed37 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -12,11 +12,15 @@ import common.models INVENTREE_SW_VERSION = "0.6.0 dev" # 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 +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 - Adds API endpoint for completing build order outputs diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index 8a2dfbd6a7..6dd51bdff1 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -5,5 +5,149 @@ Provides a JSON API for common components. # -*- coding: utf-8 -*- 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 = [ + + # User settings + url(r'^user/', include([ + # User Settings Detail + url(r'^(?P\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\d+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'), + + # Global Settings List + url(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'), + ])) + ] diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 53924f11fa..d0093eb10e 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -34,6 +34,19 @@ import logging 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): """ An base InvenTreeSetting object is a key:value pair used for storing @@ -45,6 +58,16 @@ class BaseInvenTreeSetting(models.Model): class Meta: abstract = True + def save(self, *args, **kwargs): + """ + Enforce validation and clean before saving + """ + + self.clean() + self.validate_unique() + + super().save() + @classmethod def allValues(cls, user=None): """ @@ -343,6 +366,11 @@ class BaseInvenTreeSetting(models.Model): except (ValueError): 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: self.run_validator(validator) @@ -409,6 +437,18 @@ class BaseInvenTreeSetting(models.Model): 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): """ 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) + 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 def validator_is_bool(cls, validator): @@ -531,7 +585,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'INVENTREE_BASE_URL': { 'name': _('Base URL'), 'description': _('Base URL for server instance'), - 'validator': URLValidator(), + 'validator': EmptyURLValidator(), 'default': '', }, @@ -850,7 +904,7 @@ class InvenTreeSetting(BaseInvenTreeSetting): }, 'SIGNUP_GROUP': { 'name': _('Group on signup'), - 'description': _('Group new user are asigned on registration'), + 'description': _('Group to which new users are assigned on registration'), 'default': '', 'choices': settings_group_options }, @@ -867,6 +921,14 @@ class InvenTreeSetting(BaseInvenTreeSetting): 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): """ @@ -1077,6 +1139,14 @@ class InvenTreeUserSetting(BaseInvenTreeSetting): '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): """ diff --git a/InvenTree/common/serializers.py b/InvenTree/common/serializers.py index 99ac03cdfd..4a27e3f30e 100644 --- a/InvenTree/common/serializers.py +++ b/InvenTree/common/serializers.py @@ -1,3 +1,85 @@ """ 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', + ] diff --git a/InvenTree/common/templates/common/edit_setting.html b/InvenTree/common/templates/common/edit_setting.html deleted file mode 100644 index c479e268a5..0000000000 --- a/InvenTree/common/templates/common/edit_setting.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "modal_form.html" %} -{% load i18n %} - -{% block pre_form_content %} - -{{ block.super }} - -{% endblock %} \ No newline at end of file diff --git a/InvenTree/common/test_views.py b/InvenTree/common/test_views.py index 76a0a4516e..7d7bfde87e 100644 --- a/InvenTree/common/test_views.py +++ b/InvenTree/common/test_views.py @@ -4,156 +4,3 @@ Unit tests for the views associated with the 'common' app # -*- coding: utf-8 -*- 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) diff --git a/InvenTree/common/views.py b/InvenTree/common/views.py index 6cac8bbb19..4b8310ddd2 100644 --- a/InvenTree/common/views.py +++ b/InvenTree/common/views.py @@ -8,138 +8,18 @@ from __future__ import unicode_literals import os from django.utils.translation import ugettext_lazy as _ -from django.forms import CheckboxInput, Select from django.conf import settings from django.core.files.storage import FileSystemStorage from formtools.wizard.views import SessionWizardView from crispy_forms.helper import FormHelper -from InvenTree.views import AjaxUpdateView, AjaxView -from InvenTree.helpers import str2bool +from InvenTree.views import AjaxView -from . import models from . import forms 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): """ Setup basic methods of multi-step form diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index 7599733975..6c37722316 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -50,26 +50,17 @@ $('table').find('.btn-edit-setting').click(function() { var setting = $(this).attr('setting'); var pk = $(this).attr('pk'); - var url = `/settings/${pk}/edit/`; + + var is_global = true; if ($(this).attr('user')){ - url += `user/`; + is_global = false; } - launchModalForm( - url, - { - 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); - } - } - } - ); + editSetting(pk, { + global: is_global, + title: is_global ? '{% trans "Edit Global Setting" %}' : '{% trans "Edit User Setting" %}', + }); }); $("#edit-user").on('click', function() { diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js index cb21e1fefc..8201dc8374 100644 --- a/InvenTree/templates/js/dynamic/settings.js +++ b/InvenTree/templates/js/dynamic/settings.js @@ -1,6 +1,7 @@ {% load inventree_extras %} /* exported + editSetting, user_settings, global_settings, */ @@ -18,3 +19,83 @@ const global_settings = { {{ key }}: {% primitive_to_javascript value %}, {% 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); + } + }); +} diff --git a/InvenTree/templates/js/translated/api.js b/InvenTree/templates/js/translated/api.js index d0640408be..15a74a9a71 100644 --- a/InvenTree/templates/js/translated/api.js +++ b/InvenTree/templates/js/translated/api.js @@ -61,7 +61,11 @@ function inventreeGet(url, filters={}, options={}) { }, error: function(xhr, ajaxOptions, thrownError) { console.error('Error on GET at ' + url); - console.error(thrownError); + + if (thrownError) { + console.error('Error: ' + thrownError); + } + if (options.error) { options.error({ error: thrownError @@ -174,7 +178,7 @@ function showApiError(xhr, url) { var title = null; var message = null; - switch (xhr.status) { + switch (xhr.status || 0) { // No response case 0: title = '{% trans "No Response" %}'; diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 18ba08d512..2f25fef259 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -198,14 +198,6 @@ function constructChangeForm(fields, options) { json: 'application/json', }, 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, // before they are rendered to the form @@ -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 options.instance = data; @@ -724,6 +724,11 @@ function submitFormData(fields, options) { data = form_data; } + // Optionally pre-process the data before uploading to the server + if (options.processBeforeUpload) { + data = options.processBeforeUpload(data); + } + // Submit data upload_func( options.url, diff --git a/InvenTree/templates/js/translated/part.js b/InvenTree/templates/js/translated/part.js index 742083bbe4..dc1adf8837 100644 --- a/InvenTree/templates/js/translated/part.js +++ b/InvenTree/templates/js/translated/part.js @@ -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({ url: url, @@ -1020,7 +1020,7 @@ function loadPartTable(table, url, options={}) { $('#view-part-list').removeClass('btn-outline-secondary').addClass('btn-secondary'); } }, - buttons: [ + buttons: options.gridView ? [ { icon: 'fas fa-bars', attributes: { @@ -1053,7 +1053,7 @@ function loadPartTable(table, url, options={}) { ); } } - ], + ] : [], customView: function(data) { var html = '';