2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 13:05:42 +00:00

Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters
2022-05-10 17:55:38 +10:00
21 changed files with 284 additions and 56 deletions

View File

@ -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

View File

@ -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()

View File

@ -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<pk>\d+)/', UserSettingsDetail.as_view(), name='api-user-setting-detail'),
re_path(r'^(?P<key>\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<pk>\d+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'),
re_path(r'^(?P<key>\w+)/', GlobalSettingsDetail.as_view(), name='api-global-setting-detail'),
# Global Settings List
re_path(r'^.*$', GlobalSettingsList.as_view(), name='api-global-setting-list'),

View File

@ -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')

View File

@ -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 <key>"""
# 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):

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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<pk>\d+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'),
re_path(r'^(?P<plugin>\w+)/(?P<key>\w+)/', PluginSettingDetail.as_view(), name='api-plugin-setting-detail'),
re_path(r'^.*$', PluginSettingList.as_view(), name='api-plugin-setting-list'),
])),

View File

@ -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

View File

@ -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)

View File

@ -6,6 +6,6 @@
<em>This location has no sublocations!</em>
<ul>
<li><b>Location Name</b>: {{ location.name }}</li>
<li><b>Location Path</b>: {{ location.pathstring }}</li>
<li><strong>Location Name</strong>: {{ location.name }}</li>
<li><strong>Location Path</strong>: {{ location.pathstring }}</li>
</ul>

View File

@ -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):

View File

@ -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,

View File

@ -13,6 +13,7 @@
<table class='table table-striped table-condensed'>
<tbody>
{% 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" %}
</tbody>
</table>

View File

@ -24,7 +24,7 @@
<td>
{% if setting.is_bool %}
<div class='form-check form-switch'>
<input class='form-check-input boolean-setting' fieldname='{{ setting.key.upper }}' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' id='setting-value-{{ setting.key.upper }}' type='checkbox' {% if setting.as_bool %}checked=''{% endif %} {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}{% if notification_setting %}notification='{{request.user.id}}'{% endif %}>
<input class='form-check-input boolean-setting' fieldname='{{ setting.key.upper }}' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' id='setting-value-{{ setting.key.upper }}' type='checkbox' {% if setting.as_bool %}checked=''{% endif %} {% if plugin %}plugin='{{ plugin.slug }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}{% if notification_setting %}notification='{{request.user.id}}'{% endif %}>
</div>
{% else %}
<div id='setting-{{ setting.pk }}'>
@ -41,7 +41,7 @@
</span>
{{ setting.units }}
<div class='btn-group float-right'>
<button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if plugin %}plugin='{{ plugin.pk }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}>
<button class='btn btn-outline-secondary btn-small btn-edit-setting' pk='{{ setting.pk }}' setting='{{ setting.key.upper }}' title='{% trans "Edit setting" %}' {% if plugin %}plugin='{{ plugin.slug }}'{% endif %}{% if user_setting %}user='{{request.user.id}}'{% endif %}>
<span class='fas fa-edit icon-green'></span>
</button>
</div>

View File

@ -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,
});
});

View File

@ -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;

View File

@ -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) => {

View File

@ -1056,6 +1056,7 @@ function loadBuildOutputTable(build_info, options={}) {
'{% url "api-stock-test-result-list" %}',
{
build: build_info.pk,
ordering: '-date',
},
{
success: function(results) {

View File

@ -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/)
<h4>
@ -169,4 +169,4 @@ Find a full list of used third-party libraries in [our documentation](https://in
<!-- License -->
## :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.