diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index e7b9d4d95a..86f41816b2 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -4,11 +4,19 @@ InvenTree API version information # InvenTree API version -INVENTREE_API_VERSION = 44 +INVENTREE_API_VERSION = 46 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v46 -> 2022-05-09 + - Fixes read permissions on settings API + - Allows non-staff users to read global settings via the API + +v45 -> 2022-05-08 : https://github.com/inventree/InvenTree/pull/2944 + - Settings are now accessed via the API using their unique key, not their PK + - This allows the settings to be accessed without prior knowledge of the PK + v44 -> 2022-05-04 : https://github.com/inventree/InvenTree/pull/2931 - Converting more server-side rendered forms to the API - Exposes more core functionality to API endpoints diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 3e57883875..d3d0038cea 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -421,7 +421,10 @@ class DataFileUploadSerializer(serializers.Serializer): - Fuzzy match """ - column_name = column_name.strip() + if not column_name: + return None + + column_name = str(column_name).strip() column_name_lower = column_name.lower() diff --git a/InvenTree/common/api.py b/InvenTree/common/api.py index f8aaa2ded9..1996a4bdbf 100644 --- a/InvenTree/common/api.py +++ b/InvenTree/common/api.py @@ -146,7 +146,12 @@ class GlobalSettingsPermissions(permissions.BasePermission): try: user = request.user - return user.is_staff + if request.method in ['GET', 'HEAD', 'OPTIONS']: + return True + else: + # Any other methods require staff access permissions + return user.is_staff + except AttributeError: # pragma: no cover return False @@ -158,10 +163,24 @@ class GlobalSettingsDetail(generics.RetrieveUpdateAPIView): - User must have 'staff' status to view / edit """ + lookup_field = 'key' queryset = common.models.InvenTreeSetting.objects.all() serializer_class = common.serializers.GlobalSettingsSerializer + def get_object(self): + """ + Attempt to find a global setting object with the provided key. + """ + + key = self.kwargs['key'] + + if key not in common.models.InvenTreeSetting.SETTINGS.keys(): + raise NotFound() + + return common.models.InvenTreeSetting.get_setting_object(key) + permission_classes = [ + permissions.IsAuthenticated, GlobalSettingsPermissions, ] @@ -213,9 +232,22 @@ class UserSettingsDetail(generics.RetrieveUpdateAPIView): - User can only view / edit settings their own settings objects """ + lookup_field = 'key' queryset = common.models.InvenTreeUserSetting.objects.all() serializer_class = common.serializers.UserSettingsSerializer + def get_object(self): + """ + Attempt to find a user setting object with the provided key. + """ + + key = self.kwargs['key'] + + if key not in common.models.InvenTreeUserSetting.SETTINGS.keys(): + raise NotFound() + + return common.models.InvenTreeUserSetting.get_setting_object(key, user=self.request.user) + permission_classes = [ UserSettingsPermissions, ] @@ -378,7 +410,7 @@ settings_api_urls = [ # User settings re_path(r'^user/', include([ # User Settings Detail - re_path(r'^(?P\d+)/', UserSettingsDetail.as_view(), name='api-user-setting-detail'), + re_path(r'^(?P\w+)/', UserSettingsDetail.as_view(), name='api-user-setting-detail'), # User Settings List re_path(r'^.*$', UserSettingsList.as_view(), name='api-user-setting-list'), @@ -396,7 +428,7 @@ settings_api_urls = [ # Global settings re_path(r'^global/', include([ # Global Settings Detail - re_path(r'^(?P\d+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'), + re_path(r'^(?P\w+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'), # Global Settings List re_path(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'), diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 1434bba95e..11157763cb 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -842,6 +842,13 @@ class InvenTreeSetting(BaseInvenTreeSetting): 'validator': bool, }, + 'BARCODE_WEBCAM_SUPPORT': { + 'name': _('Barcode Webcam Support'), + 'description': _('Allow barcode scanning via webcam in browser'), + 'default': True, + 'validator': bool, + }, + 'PART_IPN_REGEX': { 'name': _('IPN Regex'), 'description': _('Regular expression pattern for matching Part IPN') diff --git a/InvenTree/common/tests.py b/InvenTree/common/tests.py index 13f89d57bf..50e53ebde0 100644 --- a/InvenTree/common/tests.py +++ b/InvenTree/common/tests.py @@ -186,7 +186,7 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase): # Check default value self.assertEqual(setting.value, 'My company name') - url = reverse('api-global-setting-detail', kwargs={'pk': setting.pk}) + url = reverse('api-global-setting-detail', kwargs={'key': setting.key}) # Test getting via the API for val in ['test', '123', 'My company nam3']: @@ -212,6 +212,47 @@ class GlobalSettingsApiTest(InvenTreeAPITestCase): setting.refresh_from_db() self.assertEqual(setting.value, val) + def test_api_detail(self): + """Test that we can access the detail view for a setting based on the """ + + # These keys are invalid, and should return 404 + for key in ["apple", "carrot", "dog"]: + response = self.get( + reverse('api-global-setting-detail', kwargs={'key': key}), + expected_code=404, + ) + + key = 'INVENTREE_INSTANCE' + url = reverse('api-global-setting-detail', kwargs={'key': key}) + + InvenTreeSetting.objects.filter(key=key).delete() + + # Check that we can access a setting which has not previously been created + self.assertFalse(InvenTreeSetting.objects.filter(key=key).exists()) + + # Access via the API, and the default value should be received + response = self.get(url, expected_code=200) + + self.assertEqual(response.data['value'], 'InvenTree server') + + # Now, the object should have been created in the DB + self.patch( + url, + { + 'value': 'My new title', + }, + expected_code=200, + ) + + setting = InvenTreeSetting.objects.get(key=key) + + self.assertEqual(setting.value, 'My new title') + + # And retrieving via the API now returns the updated value + response = self.get(url, expected_code=200) + + self.assertEqual(response.data['value'], 'My new title') + class UserSettingsApiTest(InvenTreeAPITestCase): """ @@ -226,6 +267,34 @@ class UserSettingsApiTest(InvenTreeAPITestCase): self.get(url, expected_code=200) + def test_user_setting_invalid(self): + """Test a user setting with an invalid key""" + + url = reverse('api-user-setting-detail', kwargs={'key': 'DONKEY'}) + + self.get(url, expected_code=404) + + def test_user_setting_init(self): + """Test we can retrieve a setting which has not yet been initialized""" + + key = 'HOMEPAGE_PART_LATEST' + + # Ensure it does not actually exist in the database + self.assertFalse(InvenTreeUserSetting.objects.filter(key=key).exists()) + + url = reverse('api-user-setting-detail', kwargs={'key': key}) + + response = self.get(url, expected_code=200) + + self.assertEqual(response.data['value'], 'True') + + self.patch(url, {'value': 'False'}, expected_code=200) + + setting = InvenTreeUserSetting.objects.get(key=key, user=self.user) + + self.assertEqual(setting.value, 'False') + self.assertEqual(setting.to_native_value(), False) + def test_user_setting_boolean(self): """ Test a boolean user setting value @@ -241,7 +310,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase): self.assertEqual(setting.to_native_value(), True) # Fetch via API - url = reverse('api-user-setting-detail', kwargs={'pk': setting.pk}) + url = reverse('api-user-setting-detail', kwargs={'key': setting.key}) response = self.get(url, expected_code=200) @@ -300,7 +369,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase): user=self.user ) - url = reverse('api-user-setting-detail', kwargs={'pk': setting.pk}) + url = reverse('api-user-setting-detail', kwargs={'key': setting.key}) # Check default value self.assertEqual(setting.value, 'YYYY-MM-DD') @@ -339,7 +408,7 @@ class UserSettingsApiTest(InvenTreeAPITestCase): user=self.user ) - url = reverse('api-user-setting-detail', kwargs={'pk': setting.pk}) + url = reverse('api-user-setting-detail', kwargs={'key': setting.key}) # Check default value for this setting self.assertEqual(setting.value, 10) @@ -396,12 +465,35 @@ class NotificationUserSettingsApiTest(InvenTreeAPITestCase): class PluginSettingsApiTest(InvenTreeAPITestCase): """Tests for the plugin settings API""" + def test_plugin_list(self): + """List installed plugins via API""" + url = reverse('api-plugin-list') + + self.get(url, expected_code=200) + def test_api_list(self): """Test list URL""" url = reverse('api-plugin-setting-list') self.get(url, expected_code=200) + def test_invalid_plugin_slug(self): + """Test that an invalid plugin slug returns a 404""" + + url = reverse('api-plugin-setting-detail', kwargs={'plugin': 'doesnotexist', 'key': 'doesnotmatter'}) + + response = self.get(url, expected_code=404) + + self.assertIn("Plugin 'doesnotexist' not installed", str(response.data)) + + def test_invalid_setting_key(self): + """Test that an invalid setting key returns a 404""" + ... + + def test_uninitialized_setting(self): + """Test that requesting an uninitialized setting creates the setting""" + ... + class WebhookMessageTests(TestCase): def setUp(self): diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index b580853b65..c7a824d8c8 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -71,15 +71,14 @@ class LabelPrintMixin: plugin_key = request.query_params.get('plugin', None) - for slug, plugin in registry.plugins.items(): + plugin = registry.get_plugin(plugin_key) - if slug == plugin_key and plugin.mixin_enabled('labels'): + if plugin: + config = plugin.plugin_config() - config = plugin.plugin_config() - - if config and config.active: - # Only return the plugin if it is enabled! - return plugin + if config and config.active: + # Only return the plugin if it is enabled! + return plugin # No matches found return None diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index b86e0acecc..5932c36757 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -91,11 +91,23 @@ class TemplateTagTest(TestCase): def test_global_settings(self): result = inventree_extras.global_settings() - self.assertEqual(len(result), 61) + self.assertEqual(len(result), len(InvenTreeSetting.SETTINGS)) def test_visible_global_settings(self): result = inventree_extras.visible_global_settings() - self.assertEqual(len(result), 60) + + n = len(result) + + n_hidden = 0 + n_visible = 0 + + for val in InvenTreeSetting.SETTINGS.values(): + if val.get('hidden', False): + n_hidden += 1 + else: + n_visible += 1 + + self.assertEqual(n, n_visible) class PartTest(TestCase): diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index 5c8c1b3e72..b9fd6e643d 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -10,11 +10,15 @@ from django.urls import include, re_path from rest_framework import generics from rest_framework import status from rest_framework import permissions +from rest_framework.exceptions import NotFound from rest_framework.response import Response +from django_filters.rest_framework import DjangoFilterBackend + from common.api import GlobalSettingsPermissions from plugin.models import PluginConfig, PluginSetting import plugin.serializers as PluginSerializers +from plugin.registry import registry class PluginList(generics.ListAPIView): @@ -98,6 +102,15 @@ class PluginSettingList(generics.ListAPIView): GlobalSettingsPermissions, ] + filter_backends = [ + DjangoFilterBackend, + ] + + filter_fields = [ + 'plugin__active', + 'plugin__key', + ] + class PluginSettingDetail(generics.RetrieveUpdateAPIView): """ @@ -109,6 +122,34 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView): queryset = PluginSetting.objects.all() serializer_class = PluginSerializers.PluginSettingSerializer + def get_object(self): + """ + Lookup the plugin setting object, based on the URL. + The URL provides the 'slug' of the plugin, and the 'key' of the setting. + + Both the 'slug' and 'key' must be valid, else a 404 error is raised + """ + + plugin_slug = self.kwargs['plugin'] + key = self.kwargs['key'] + + # Check that the 'plugin' specified is valid! + if not PluginConfig.objects.filter(key=plugin_slug).exists(): + raise NotFound(detail=f"Plugin '{plugin_slug}' not installed") + + # Get the list of settings available for the specified plugin + plugin = registry.get_plugin(plugin_slug) + + if plugin is None: + raise NotFound(detail=f"Plugin '{plugin_slug}' not found") + + settings = getattr(plugin, 'SETTINGS', {}) + + if key not in settings: + raise NotFound(detail=f"Plugin '{plugin_slug}' has no setting matching '{key}'") + + return PluginSetting.get_setting_object(key, plugin=plugin) + # Staff permission required permission_classes = [ GlobalSettingsPermissions, @@ -119,7 +160,7 @@ plugin_api_urls = [ # Plugin settings URLs re_path(r'^settings/', include([ - re_path(r'^(?P\d+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'), + re_path(r'^(?P\w+)/(?P\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'), re_path(r'^.*$', PluginSettingList.as_view(), name='api-plugin-setting-list'), ])), diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 45961d7a8b..9a007307d3 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -63,6 +63,17 @@ class PluginsRegistry: # mixins self.mixins_settings = {} + def get_plugin(self, slug): + """ + Lookup plugin by slug (unique key). + """ + + if slug not in self.plugins: + logger.warning(f"Plugin registry has no record of plugin '{slug}'") + return None + + return self.plugins[slug] + def call_plugin_function(self, slug, func, *args, **kwargs): """ Call a member function (named by 'func') of the plugin named by 'slug'. @@ -73,7 +84,10 @@ class PluginsRegistry: Instead, any error messages are returned to the worker. """ - plugin = self.plugins[slug] + plugin = self.get_plugin(slug) + + if not plugin: + return plugin_func = getattr(plugin, func) diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index 276604b390..2f3ccee4e2 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -138,7 +138,7 @@ class PluginSettingSerializer(GenericReferencedSettingSerializer): 'plugin', ] - plugin = serializers.PrimaryKeyRelatedField(read_only=True) + plugin = serializers.CharField(source='plugin.key', read_only=True) class NotificationUserSettingSerializer(GenericReferencedSettingSerializer): diff --git a/InvenTree/templates/InvenTree/settings/barcode.html b/InvenTree/templates/InvenTree/settings/barcode.html index 8532476b75..ea45455203 100644 --- a/InvenTree/templates/InvenTree/settings/barcode.html +++ b/InvenTree/templates/InvenTree/settings/barcode.html @@ -13,6 +13,7 @@ {% include "InvenTree/settings/setting.html" with key="BARCODE_ENABLE" icon="fa-qrcode" %} + {% include "InvenTree/settings/setting.html" with key="BARCODE_WEBCAM_SUPPORT" icon="fa-video" %}
diff --git a/InvenTree/templates/InvenTree/settings/setting.html b/InvenTree/templates/InvenTree/settings/setting.html index 95865700fe..0bc099f8a2 100644 --- a/InvenTree/templates/InvenTree/settings/setting.html +++ b/InvenTree/templates/InvenTree/settings/setting.html @@ -24,7 +24,7 @@ {% if setting.is_bool %}
- +
{% else %}
@@ -41,7 +41,7 @@ {{ setting.units }}
-
diff --git a/InvenTree/templates/InvenTree/settings/settings.html b/InvenTree/templates/InvenTree/settings/settings.html index b35ec0107a..4e460398f1 100644 --- a/InvenTree/templates/InvenTree/settings/settings.html +++ b/InvenTree/templates/InvenTree/settings/settings.html @@ -66,8 +66,8 @@ // Callback for when boolean settings are edited $('table').find('.boolean-setting').change(function() { - var setting = $(this).attr('setting'); var pk = $(this).attr('pk'); + var setting = $(this).attr('setting'); var plugin = $(this).attr('plugin'); var user = $(this).attr('user'); var notification = $(this).attr('notification'); @@ -75,12 +75,12 @@ $('table').find('.boolean-setting').change(function() { var checked = this.checked; // Global setting by default - var url = `/api/settings/global/${pk}/`; + var url = `/api/settings/global/${setting}/`; if (plugin) { - url = `/api/plugin/settings/${pk}/`; + url = `/api/plugin/settings/${plugin}/${setting}/`; } else if (user) { - url = `/api/settings/user/${pk}/`; + url = `/api/settings/user/${setting}/`; } else if (notification) { url = `/api/settings/notification/${pk}/`; } @@ -105,9 +105,9 @@ $('table').find('.boolean-setting').change(function() { // Callback for when non-boolean settings are edited $('table').find('.btn-edit-setting').click(function() { var setting = $(this).attr('setting'); - var pk = $(this).attr('pk'); var plugin = $(this).attr('plugin'); var is_global = true; + var notification = $(this).attr('notification'); if ($(this).attr('user')){ is_global = false; @@ -117,15 +117,19 @@ $('table').find('.btn-edit-setting').click(function() { if (plugin != null) { title = '{% trans "Edit Plugin Setting" %}'; + } else if (notification) { + title = '{% trans "Edit Notification Setting" %}'; + setting = $(this).attr('pk'); } else if (is_global) { title = '{% trans "Edit Global Setting" %}'; } else { title = '{% trans "Edit User Setting" %}'; } - editSetting(pk, { + editSetting(setting, { plugin: plugin, global: is_global, + notification: notification, title: title, }); }); diff --git a/InvenTree/templates/js/dynamic/settings.js b/InvenTree/templates/js/dynamic/settings.js index 2832bd3482..21eb9df5e2 100644 --- a/InvenTree/templates/js/dynamic/settings.js +++ b/InvenTree/templates/js/dynamic/settings.js @@ -29,23 +29,28 @@ const plugins_enabled = false; {% endif %} /* - * Edit a setting value + * Interactively edit a setting value. + * Launches a modal dialog form to adjut the value of the setting. */ -function editSetting(pk, options={}) { +function editSetting(key, options={}) { // Is this a global setting or a user setting? var global = options.global || false; var plugin = options.plugin; + var notification = options.notification; + var url = ''; if (plugin) { - url = `/api/plugin/settings/${pk}/`; + url = `/api/plugin/settings/${plugin}/${key}/`; + } else if (notification) { + url = `/api/settings/notification/${pk}/`; } else if (global) { - url = `/api/settings/global/${pk}/`; + url = `/api/settings/global/${key}/`; } else { - url = `/api/settings/user/${pk}/`; + url = `/api/settings/user/${key}/`; } var reload_required = false; diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index a6305eb1df..d6ce81fa38 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -70,7 +70,7 @@ function onBarcodeScanClicked(e) { } function onCameraAvailable(hasCamera, options) { - if ( hasCamera == true ) { + if (hasCamera && global_settings.BARCODE_WEBCAM_SUPPORT) { // Camera is only acccessible if page is served over secure connection if ( window.isSecureContext == true ) { qrScanner = new QrScanner(document.getElementById('barcode_scan_video'), (result) => { diff --git a/README.md b/README.md index a499ce8856..6083117cac 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ [![Docker Pulls](https://img.shields.io/docker/pulls/inventree/inventree)](https://hub.docker.com/r/inventree/inventree) ![GitHub Org's stars](https://img.shields.io/github/stars/inventree?style=social) -![Twitter Follow](https://img.shields.io/twitter/follow/inventreedb?style=social) -![Subreddit subscribers](https://img.shields.io/reddit/subreddit-subscribers/inventree?style=social) +[![Twitter Follow](https://img.shields.io/twitter/follow/inventreedb?style=social)](https://twitter.com/inventreedb) +[![Subreddit subscribers](https://img.shields.io/reddit/subreddit-subscribers/inventree?style=social)](https://www.reddit.com/r/InvenTree/)

@@ -169,4 +169,4 @@ Find a full list of used third-party libraries in [our documentation](https://in ## :warning: License -Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License. See LICENSE.txt for more information. +Distributed under the [MIT](https://choosealicense.com/licenses/mit/) License. See [LICENSE.txt](https://github.com/inventree/InvenTree/blob/master/LICENSE) for more information.