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 dee776f7d9..8fa0f3b28e 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/models.py b/InvenTree/part/models.py index de2616b772..492da8f5de 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -49,6 +49,8 @@ from InvenTree import validators from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin from InvenTree.fields import InvenTreeURLField from InvenTree.helpers import decimal2string, normalize, decimal2money + +import InvenTree.ready import InvenTree.tasks from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus @@ -2292,7 +2294,7 @@ def after_save_part(sender, instance: Part, created, **kwargs): Function to be executed after a Part is saved """ - if not created: + if not created and not InvenTree.ready.isImportingData(): # Check part stock only if we are *updating* the part (not creating it) # Run this check in the background 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/events.py b/InvenTree/plugin/events.py index b54581bf71..043f3b97a3 100644 --- a/InvenTree/plugin/events.py +++ b/InvenTree/plugin/events.py @@ -17,7 +17,7 @@ from django.dispatch.dispatcher import receiver from common.models import InvenTreeSetting import common.notifications -from InvenTree.ready import canAppAccessDatabase +from InvenTree.ready import canAppAccessDatabase, isImportingData from InvenTree.tasks import offload_task from plugin.registry import registry @@ -113,6 +113,10 @@ def allow_table_event(table_name): We *do not* want events to be fired for some tables! """ + if isImportingData(): + # Prevent table events during the data import process + return False + table_name = table_name.lower().strip() # Ignore any tables which start with these prefixes 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/samples/integration/templates/panel_demo/childless.html b/InvenTree/plugin/samples/integration/templates/panel_demo/childless.html index 061dcc514d..b74ce094b7 100644 --- a/InvenTree/plugin/samples/integration/templates/panel_demo/childless.html +++ b/InvenTree/plugin/samples/integration/templates/panel_demo/childless.html @@ -6,6 +6,6 @@ This location has no sublocations!
    -
  • Location Name: {{ location.name }}
  • -
  • Location Path: {{ location.pathstring }}
  • +
  • Location Name: {{ location.name }}
  • +
  • Location Path: {{ location.pathstring }}
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/stock/models.py b/InvenTree/stock/models.py index 040b748521..53e1321e1a 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -30,7 +30,8 @@ from mptt.managers import TreeManager from decimal import Decimal, InvalidOperation from datetime import datetime, timedelta -from InvenTree import helpers +import InvenTree.helpers +import InvenTree.ready import InvenTree.tasks import common.models @@ -137,7 +138,7 @@ class StockLocation(InvenTreeTree): def format_barcode(self, **kwargs): """ Return a JSON string for formatting a barcode for this StockLocation object """ - return helpers.MakeBarcode( + return InvenTree.helpers.MakeBarcode( 'stocklocation', self.pk, { @@ -577,7 +578,7 @@ class StockItem(MPTTModel): Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change) """ - return helpers.MakeBarcode( + return InvenTree.helpers.MakeBarcode( "stockitem", self.id, { @@ -1775,7 +1776,7 @@ class StockItem(MPTTModel): sn=self.serial) else: s = '{n} x {part}'.format( - n=helpers.decimal2string(self.quantity), + n=InvenTree.helpers.decimal2string(self.quantity), part=self.part.full_name) if self.location: @@ -1783,7 +1784,7 @@ class StockItem(MPTTModel): if self.purchase_order: s += " ({pre}{po})".format( - pre=helpers.getSetting("PURCHASEORDER_REFERENCE_PREFIX"), + pre=InvenTree.helpers.getSetting("PURCHASEORDER_REFERENCE_PREFIX"), po=self.purchase_order, ) @@ -1851,7 +1852,7 @@ class StockItem(MPTTModel): result_map = {} for result in results: - key = helpers.generateTestKey(result.test) + key = InvenTree.helpers.generateTestKey(result.test) result_map[key] = result # Do we wish to "cascade" and include test results from installed stock items? @@ -1898,7 +1899,7 @@ class StockItem(MPTTModel): failed = 0 for test in required: - key = helpers.generateTestKey(test.test_name) + key = InvenTree.helpers.generateTestKey(test.test_name) if key in results: result = results[key] @@ -1949,7 +1950,7 @@ class StockItem(MPTTModel): # Attempt to validate report filter (skip if invalid) try: - filters = helpers.validateFilterString(test_report.filters) + filters = InvenTree.helpers.validateFilterString(test_report.filters) if item_query.filter(**filters).exists(): reports.append(test_report) except (ValidationError, FieldError): @@ -1977,7 +1978,7 @@ class StockItem(MPTTModel): for lbl in label.models.StockItemLabel.objects.filter(enabled=True): try: - filters = helpers.validateFilterString(lbl.filters) + filters = InvenTree.helpers.validateFilterString(lbl.filters) if item_query.filter(**filters).exists(): labels.append(lbl) @@ -2016,8 +2017,9 @@ def after_delete_stock_item(sender, instance: StockItem, **kwargs): Function to be executed after a StockItem object is deleted """ - # Run this check in the background - InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) + if not InvenTree.ready.isImportingData(): + # Run this check in the background + InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) @receiver(post_save, sender=StockItem, dispatch_uid='stock_item_post_save_log') @@ -2026,8 +2028,9 @@ def after_save_stock_item(sender, instance: StockItem, created, **kwargs): Hook function to be executed after StockItem object is saved/updated """ - # Run this check in the background - InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) + if not InvenTree.ready.isImportingData(): + # Run this check in the background + InvenTree.tasks.offload_task('part.tasks.notify_low_stock_if_required', instance.part) class StockItemAttachment(InvenTreeAttachment): @@ -2170,7 +2173,7 @@ class StockItemTestResult(models.Model): @property def key(self): - return helpers.generateTestKey(self.test) + return InvenTree.helpers.generateTestKey(self.test) stock_item = models.ForeignKey( StockItem, 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/InvenTree/templates/js/translated/build.js b/InvenTree/templates/js/translated/build.js index a636cfeec8..94c2780a28 100644 --- a/InvenTree/templates/js/translated/build.js +++ b/InvenTree/templates/js/translated/build.js @@ -1056,6 +1056,7 @@ function loadBuildOutputTable(build_info, options={}) { '{% url "api-stock-test-result-list" %}', { build: build_info.pk, + ordering: '-date', }, { success: function(results) { 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.