2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-30 02:30:52 +00:00

Barcode logging ()

* Add model for recording barcode scan results

* Add "admin" interface for new model

* Add API endpoints for barcode scan history

* Add global setting to control barcode result save

* Add frontend API endpoint

* Add PUI table in "admin center"

* Add API filter class

* Enable table filtering

* Update model definition

* Allow more characters for barcode log

* Log results to server

* Add setting to control how long results are stored

* Table updates

* Add background task to delete old barcode scans

* Add detail drawer for barcode scan

* Log messages for BarcodePOReceive

* Add warning message if barcode logging is not enabled

* Add "context" data to BarcodeScanResult

* Display context data (if available)

* Add context data when scanning

* Simplify / refactor BarcodeSOAllocate

* Refactor BarcodePOAllocate

* Limit the number of saved scans

* Improve error message display in PUI

* Simplify barcode logging

* Improve table

* Updates

* Settings page fix

* Fix panel tooltips

* Adjust table

* Add "result" field

* Refactor calls to "log_scan"

* Display result in PUI table

* Updates

* Fix typo

* Update unit test

* Improve exception handling

* Unit test updates

* Enhanced unit test

* Ensure all database key config values are upper case

* Refactor some playwright helpers

* Adds playwright test for barcode scan history table

* Requires some timeout

* Add docs
This commit is contained in:
Oliver
2024-09-23 23:30:50 +10:00
committed by GitHub
parent f7e0edb7a6
commit 6002103129
28 changed files with 929 additions and 168 deletions

@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 256
INVENTREE_API_VERSION = 257
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v257 - 2024-09-22 : https://github.com/inventree/InvenTree/pull/8150
- Adds API endpoint for reporting barcode scan history
v256 - 2024-09-19 : https://github.com/inventree/InvenTree/pull/7704
- Adjustments for "stocktake" (stock history) API endpoints

@ -180,5 +180,9 @@ class RetrieveUpdateDestroyAPI(CleanMixin, generics.RetrieveUpdateDestroyAPIView
"""View for retrieve, update and destroy API."""
class RetrieveDestroyAPI(generics.RetrieveDestroyAPIView):
"""View for retrieve and destroy API."""
class UpdateAPI(CleanMixin, generics.UpdateAPIView):
"""View for update API."""

@ -590,6 +590,9 @@ for key in db_keys:
# Check that required database configuration options are specified
required_keys = ['ENGINE', 'NAME']
# Ensure all database keys are upper case
db_config = {key.upper(): value for key, value in db_config.items()}
for key in required_keys:
if key not in db_config: # pragma: no cover
error_msg = f'Missing required database configuration value {key}'

@ -35,6 +35,15 @@ class AttachmentAdmin(admin.ModelAdmin):
search_fields = ('content_type', 'comment')
@admin.register(common.models.BarcodeScanResult)
class BarcodeScanResultAdmin(admin.ModelAdmin):
"""Admin interface for BarcodeScanResult objects."""
list_display = ('data', 'timestamp', 'user', 'endpoint', 'result')
list_filter = ('user', 'endpoint', 'result')
@admin.register(common.models.ProjectCode)
class ProjectCodeAdmin(ImportExportModelAdmin):
"""Admin settings for ProjectCode."""

@ -0,0 +1,34 @@
# Generated by Django 4.2.15 on 2024-09-21 06:05
import InvenTree.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('common', '0029_inventreecustomuserstatemodel'),
]
operations = [
migrations.CreateModel(
name='BarcodeScanResult',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', models.CharField(help_text='Barcode data', max_length=250, verbose_name='Data')),
('timestamp', models.DateTimeField(auto_now_add=True, help_text='Date and time of the barcode scan', verbose_name='Timestamp')),
('endpoint', models.CharField(blank=True, help_text='URL endpoint which processed the barcode', max_length=250, null=True, verbose_name='Path')),
('context', models.JSONField(blank=True, help_text='Context data for the barcode scan', max_length=1000, null=True, verbose_name='Context')),
('response', models.JSONField(blank=True, help_text='Response data from the barcode scan', max_length=1000, null=True, verbose_name='Response')),
('result', models.BooleanField(default=False, help_text='Was the barcode scan successful?', verbose_name='Result')),
('user', models.ForeignKey(blank=True, help_text='User who scanned the barcode', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'verbose_name': 'Barcode Scan',
},
bases=(InvenTree.models.PluginValidationMixin, models.Model),
),
]

@ -43,6 +43,7 @@ from taggit.managers import TaggableManager
import build.validators
import common.currency
import common.validators
import InvenTree.exceptions
import InvenTree.fields
import InvenTree.helpers
import InvenTree.models
@ -1398,6 +1399,18 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': True,
'validator': bool,
},
'BARCODE_STORE_RESULTS': {
'name': _('Store Barcode Results'),
'description': _('Store barcode scan results in the database'),
'default': False,
'validator': bool,
},
'BARCODE_RESULTS_MAX_NUM': {
'name': _('Barcode Scans Maximum Count'),
'description': _('Maximum number of barcode scan results to store'),
'default': 100,
'validator': [int, MinValueValidator(1)],
},
'BARCODE_INPUT_DELAY': {
'name': _('Barcode Input Delay'),
'description': _('Barcode input processing delay time'),
@ -3445,3 +3458,67 @@ class InvenTreeCustomUserStateModel(models.Model):
})
return super().clean()
class BarcodeScanResult(InvenTree.models.InvenTreeModel):
"""Model for storing barcode scans results."""
BARCODE_SCAN_MAX_LEN = 250
class Meta:
"""Model meta options."""
verbose_name = _('Barcode Scan')
data = models.CharField(
max_length=BARCODE_SCAN_MAX_LEN,
verbose_name=_('Data'),
help_text=_('Barcode data'),
blank=False,
null=False,
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True,
null=True,
verbose_name=_('User'),
help_text=_('User who scanned the barcode'),
)
timestamp = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Timestamp'),
help_text=_('Date and time of the barcode scan'),
)
endpoint = models.CharField(
max_length=250,
verbose_name=_('Path'),
help_text=_('URL endpoint which processed the barcode'),
blank=True,
null=True,
)
context = models.JSONField(
max_length=1000,
verbose_name=_('Context'),
help_text=_('Context data for the barcode scan'),
blank=True,
null=True,
)
response = models.JSONField(
max_length=1000,
verbose_name=_('Response'),
help_text=_('Response data from the barcode scan'),
blank=True,
null=True,
)
result = models.BooleanField(
verbose_name=_('Result'),
help_text=_('Was the barcode scan successful?'),
default=False,
)

@ -3,19 +3,27 @@
import logging
from django.db.models import F
from django.urls import path
from django.urls import include, path
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import permissions, status
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
import common.models
import order.models
import plugin.base.barcodes.helper
import stock.models
from common.settings import get_global_setting
from InvenTree.api import BulkDeleteMixin
from InvenTree.exceptions import log_error
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.helpers import hash_barcode
from InvenTree.mixins import ListAPI, RetrieveDestroyAPI
from InvenTree.permissions import IsStaffOrReadOnly
from plugin import registry
from users.models import RuleSet
@ -30,6 +38,70 @@ class BarcodeView(CreateAPIView):
# Default serializer class (can be overridden)
serializer_class = barcode_serializers.BarcodeSerializer
def log_scan(self, request, response=None, result=False):
"""Log a barcode scan to the database.
Arguments:
request: HTTP request object
response: Optional response data
"""
from common.models import BarcodeScanResult
# Extract context data from the request
context = {**request.GET.dict(), **request.POST.dict(), **request.data}
barcode = context.pop('barcode', '')
# Exit if storing barcode scans is disabled
if not get_global_setting('BARCODE_STORE_RESULTS', backup=False, create=False):
return
# Ensure that the response data is stringified first, otherwise cannot be JSON encoded
if type(response) is dict:
response = {key: str(value) for key, value in response.items()}
elif response is None:
pass
else:
response = str(response)
# Ensure that the context data is stringified first, otherwise cannot be JSON encoded
if type(context) is dict:
context = {key: str(value) for key, value in context.items()}
elif context is None:
pass
else:
context = str(context)
# Ensure data is not too long
if len(barcode) > BarcodeScanResult.BARCODE_SCAN_MAX_LEN:
barcode = barcode[: BarcodeScanResult.BARCODE_SCAN_MAX_LEN]
try:
BarcodeScanResult.objects.create(
data=barcode,
user=request.user,
endpoint=request.path,
response=response,
result=result,
context=context,
)
# Ensure that we do not store too many scans
max_scans = int(get_global_setting('BARCODE_RESULTS_MAX_NUM', create=False))
num_scans = BarcodeScanResult.objects.count()
if num_scans > max_scans:
n = num_scans - max_scans
old_scan_ids = (
BarcodeScanResult.objects.all()
.order_by('timestamp')
.values_list('pk', flat=True)[:n]
)
BarcodeScanResult.objects.filter(pk__in=old_scan_ids).delete()
except Exception:
# Gracefully log error to database
log_error(f'{self.__class__.__name__}.log_scan')
def queryset(self):
"""This API view does not have a queryset."""
return None
@ -40,7 +112,13 @@ class BarcodeView(CreateAPIView):
def create(self, request, *args, **kwargs):
"""Handle create method - override default create."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
serializer.is_valid(raise_exception=True)
except Exception as exc:
self.log_scan(request, response={'error': str(exc)}, result=False)
raise exc
data = serializer.validated_data
barcode = str(data.pop('barcode')).strip()
@ -119,15 +197,19 @@ class BarcodeScan(BarcodeView):
kwargs:
Any custom fields passed by the specific serializer
"""
result = self.scan_barcode(barcode, request, **kwargs)
response = self.scan_barcode(barcode, request, **kwargs)
if result['plugin'] is None:
result['error'] = _('No match found for barcode data')
if response['plugin'] is None:
response['error'] = _('No match found for barcode data')
self.log_scan(request, response, False)
raise ValidationError(response)
raise ValidationError(result)
response['success'] = _('Match found for barcode data')
result['success'] = _('Match found for barcode data')
return Response(result)
# Log the scan result
self.log_scan(request, response, True)
return Response(response)
@extend_schema_view(
@ -333,7 +415,7 @@ class BarcodePOAllocate(BarcodeView):
supplier_parts = company.models.SupplierPart.objects.filter(supplier=supplier)
if not part and not supplier_part and not manufacturer_part:
raise ValidationError({'error': _('No matching part data found')})
raise ValidationError(_('No matching part data found'))
if part and (part_id := part.get('pk', None)):
supplier_parts = supplier_parts.filter(part__pk=part_id)
@ -349,12 +431,10 @@ class BarcodePOAllocate(BarcodeView):
)
if supplier_parts.count() == 0:
raise ValidationError({'error': _('No matching supplier parts found')})
raise ValidationError(_('No matching supplier parts found'))
if supplier_parts.count() > 1:
raise ValidationError({
'error': _('Multiple matching supplier parts found')
})
raise ValidationError(_('Multiple matching supplier parts found'))
# At this stage, we have a single matching supplier part
return supplier_parts.first()
@ -364,25 +444,32 @@ class BarcodePOAllocate(BarcodeView):
# The purchase order is provided as part of the request
purchase_order = kwargs.get('purchase_order')
result = self.scan_barcode(barcode, request, **kwargs)
response = self.scan_barcode(barcode, request, **kwargs)
if result['plugin'] is None:
result['error'] = _('No match found for barcode data')
raise ValidationError(result)
if response['plugin'] is None:
response['error'] = _('No matching plugin found for barcode data')
supplier_part = self.get_supplier_part(
purchase_order,
part=result.get('part', None),
supplier_part=result.get('supplierpart', None),
manufacturer_part=result.get('manufacturerpart', None),
)
result['success'] = _('Matched supplier part')
result['supplierpart'] = supplier_part.format_matched_response()
else:
try:
supplier_part = self.get_supplier_part(
purchase_order,
part=response.get('part', None),
supplier_part=response.get('supplierpart', None),
manufacturer_part=response.get('manufacturerpart', None),
)
response['success'] = _('Matched supplier part')
response['supplierpart'] = supplier_part.format_matched_response()
except ValidationError as e:
response['error'] = str(e)
# TODO: Determine the 'quantity to order' for the supplier part
return Response(result)
self.log_scan(request, response, 'success' in response)
if 'error' in response:
raise ValidationError
return Response(response)
class BarcodePOReceive(BarcodeView):
@ -427,6 +514,7 @@ class BarcodePOReceive(BarcodeView):
if result := internal_barcode_plugin.scan(barcode):
if 'stockitem' in result:
response['error'] = _('Item has already been received')
self.log_scan(request, response, False)
raise ValidationError(response)
# Now, look just for "supplier-barcode" plugins
@ -464,11 +552,13 @@ class BarcodePOReceive(BarcodeView):
# A plugin has not been found!
if plugin is None:
response['error'] = _('No match for supplier barcode')
self.log_scan(request, response, 'success' in response)
if 'error' in response:
raise ValidationError(response)
elif 'error' in response:
raise ValidationError(response)
else:
return Response(response)
return Response(response)
class BarcodeSOAllocate(BarcodeView):
@ -489,7 +579,11 @@ class BarcodeSOAllocate(BarcodeView):
serializer_class = barcode_serializers.BarcodeSOAllocateSerializer
def get_line_item(self, stock_item, **kwargs):
"""Return the matching line item for the provided stock item."""
"""Return the matching line item for the provided stock item.
Raises:
ValidationError: If no single matching line item is found
"""
# Extract sales order object (required field)
sales_order = kwargs['sales_order']
@ -506,22 +600,24 @@ class BarcodeSOAllocate(BarcodeView):
)
if lines.count() > 1:
raise ValidationError({'error': _('Multiple matching line items found')})
raise ValidationError(_('Multiple matching line items found'))
if lines.count() == 0:
raise ValidationError({'error': _('No matching line item found')})
raise ValidationError(_('No matching line item found'))
return lines.first()
def get_shipment(self, **kwargs):
"""Extract the shipment from the provided kwargs, or guess."""
"""Extract the shipment from the provided kwargs, or guess.
Raises:
ValidationError: If the shipment does not match the sales order
"""
sales_order = kwargs['sales_order']
if shipment := kwargs.get('shipment', None):
if shipment.order != sales_order:
raise ValidationError({
'error': _('Shipment does not match sales order')
})
raise ValidationError(_('Shipment does not match sales order'))
return shipment
@ -536,37 +632,55 @@ class BarcodeSOAllocate(BarcodeView):
return None
def handle_barcode(self, barcode: str, request, **kwargs):
"""Handle barcode scan for sales order allocation."""
"""Handle barcode scan for sales order allocation.
Arguments:
barcode: Raw barcode data
request: HTTP request object
kwargs:
sales_order: SalesOrder ID value (required)
line: SalesOrderLineItem ID value (optional)
shipment: SalesOrderShipment ID value (optional)
"""
logger.debug("BarcodeSOAllocate: scanned barcode - '%s'", barcode)
result = self.scan_barcode(barcode, request, **kwargs)
response = self.scan_barcode(barcode, request, **kwargs)
if result['plugin'] is None:
result['error'] = _('No match found for barcode data')
raise ValidationError(result)
if 'sales_order' not in kwargs:
# SalesOrder ID *must* be provided
response['error'] = _('No sales order provided')
elif response['plugin'] is None:
# Check that the barcode at least matches a plugin
response['error'] = _('No matching plugin found for barcode data')
else:
try:
stock_item_id = response['stockitem'].get('pk', None)
stock_item = stock.models.StockItem.objects.get(pk=stock_item_id)
except Exception:
response['error'] = _('Barcode does not match an existing stock item')
# Check that the scanned barcode was a StockItem
if 'stockitem' not in result:
result['error'] = _('Barcode does not match an existing stock item')
raise ValidationError(result)
try:
stock_item_id = result['stockitem'].get('pk', None)
stock_item = stock.models.StockItem.objects.get(pk=stock_item_id)
except (ValueError, stock.models.StockItem.DoesNotExist):
result['error'] = _('Barcode does not match an existing stock item')
raise ValidationError(result)
if 'error' in response:
self.log_scan(request, response, False)
raise ValidationError(response)
# At this stage, we have a valid StockItem object
# Extract any other data from the kwargs
line_item = self.get_line_item(stock_item, **kwargs)
sales_order = kwargs['sales_order']
shipment = self.get_shipment(**kwargs)
if stock_item is not None and line_item is not None:
if stock_item.part != line_item.part:
result['error'] = _('Stock item does not match line item')
raise ValidationError(result)
try:
# Extract any other data from the kwargs
# Note: This may raise a ValidationError at some point - we break on the first error
sales_order = kwargs['sales_order']
line_item = self.get_line_item(stock_item, **kwargs)
shipment = self.get_shipment(**kwargs)
if stock_item is not None and line_item is not None:
if stock_item.part != line_item.part:
response['error'] = _('Stock item does not match line item')
except ValidationError as e:
response['error'] = str(e)
if 'error' in response:
self.log_scan(request, response, False)
raise ValidationError(response)
quantity = kwargs.get('quantity', None)
@ -574,11 +688,12 @@ class BarcodeSOAllocate(BarcodeView):
if stock_item.serialized:
quantity = 1
if quantity is None:
elif quantity is None:
quantity = line_item.quantity - line_item.shipped
quantity = min(quantity, stock_item.unallocated_quantity())
response = {
**response,
'stock_item': stock_item.pk if stock_item else None,
'part': stock_item.part.pk if stock_item else None,
'sales_order': sales_order.pk if sales_order else None,
@ -590,25 +705,91 @@ class BarcodeSOAllocate(BarcodeView):
if stock_item is not None and quantity is not None:
if stock_item.unallocated_quantity() < quantity:
response['error'] = _('Insufficient stock available')
raise ValidationError(response)
# If we have sufficient information, we can allocate the stock item
if all(x is not None for x in [line_item, sales_order, shipment, quantity]):
order.models.SalesOrderAllocation.objects.create(
line=line_item, shipment=shipment, item=stock_item, quantity=quantity
)
# If we have sufficient information, we can allocate the stock item
elif all(
x is not None for x in [line_item, sales_order, shipment, quantity]
):
order.models.SalesOrderAllocation.objects.create(
line=line_item,
shipment=shipment,
item=stock_item,
quantity=quantity,
)
response['success'] = _('Stock item allocated to sales order')
response['success'] = _('Stock item allocated to sales order')
else:
response['error'] = _('Not enough information')
response['action_required'] = True
self.log_scan(request, response, 'success' in response)
if 'error' in response:
raise ValidationError(response)
else:
return Response(response)
response['error'] = _('Not enough information')
response['action_required'] = True
raise ValidationError(response)
class BarcodeScanResultMixin:
"""Mixin class for BarcodeScan API endpoints."""
queryset = common.models.BarcodeScanResult.objects.all()
serializer_class = barcode_serializers.BarcodeScanResultSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
def get_queryset(self):
"""Return the queryset for the BarcodeScan API."""
queryset = super().get_queryset()
# Pre-fetch user data
queryset = queryset.prefetch_related('user')
return queryset
class BarcodeScanResultFilter(rest_filters.FilterSet):
"""Custom filterset for the BarcodeScanResult API."""
class Meta:
"""Meta class for the BarcodeScanResultFilter."""
model = common.models.BarcodeScanResult
fields = ['user', 'result']
class BarcodeScanResultList(BarcodeScanResultMixin, BulkDeleteMixin, ListAPI):
"""List API endpoint for BarcodeScan objects."""
filterset_class = BarcodeScanResultFilter
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['user', 'plugin', 'timestamp', 'endpoint', 'result']
ordering = '-timestamp'
search_fields = ['plugin']
class BarcodeScanResultDetail(BarcodeScanResultMixin, RetrieveDestroyAPI):
"""Detail endpoint for a BarcodeScan object."""
barcode_api_urls = [
# Barcode scan history
path(
'history/',
include([
path(
'<int:pk>/',
BarcodeScanResultDetail.as_view(),
name='api-barcode-scan-result-detail',
),
path(
'', BarcodeScanResultList.as_view(), name='api-barcode-scan-result-list'
),
]),
),
# Generate a barcode for a database object
path('generate/', BarcodeGenerate.as_view(), name='api-barcode-generate'),
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)

@ -5,12 +5,39 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
import common.models
import order.models
import plugin.base.barcodes.helper
import stock.models
from InvenTree.serializers import UserSerializer
from order.status_codes import PurchaseOrderStatus, SalesOrderStatus
class BarcodeScanResultSerializer(serializers.ModelSerializer):
"""Serializer for barcode scan results."""
class Meta:
"""Meta class for BarcodeScanResultSerializer."""
model = common.models.BarcodeScanResult
fields = [
'pk',
'data',
'timestamp',
'endpoint',
'context',
'response',
'result',
'user',
'user_detail',
]
read_only_fields = fields
user_detail = UserSerializer(source='user', read_only=True)
class BarcodeSerializer(serializers.Serializer):
"""Generic serializer for receiving barcode data."""

@ -4,6 +4,8 @@ from django.urls import reverse
import company.models
import order.models
from common.models import BarcodeScanResult
from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part
from stock.models import StockItem
@ -89,6 +91,11 @@ class BarcodeAPITest(InvenTreeAPITestCase):
"""Test that we can lookup a stock item based on ID."""
item = StockItem.objects.first()
# Save barcode scan results to database
set_global_setting('BARCODE_STORE_RESULTS', True)
n = BarcodeScanResult.objects.count()
response = self.post(
self.scan_url, {'barcode': item.format_barcode()}, expected_code=200
)
@ -97,6 +104,20 @@ class BarcodeAPITest(InvenTreeAPITestCase):
self.assertIn('barcode_data', response.data)
self.assertEqual(response.data['stockitem']['pk'], item.pk)
self.assertEqual(BarcodeScanResult.objects.count(), n + 1)
result = BarcodeScanResult.objects.last()
self.assertTrue(result.result)
self.assertEqual(result.data, item.format_barcode())
response = result.response
self.assertEqual(response['plugin'], 'InvenTreeBarcode')
for k in ['barcode_data', 'stockitem', 'success']:
self.assertIn(k, response)
def test_invalid_item(self):
"""Test response for invalid stock item."""
response = self.post(
@ -309,7 +330,7 @@ class SOAllocateTest(InvenTreeAPITestCase):
'123456789', sales_order=self.sales_order.pk, expected_code=400
)
self.assertIn('No match found for barcode', str(result['error']))
self.assertIn('No matching plugin found for barcode data', str(result['error']))
# Test with a barcode that matches a *different* stock item
item = StockItem.objects.exclude(pk=self.stock_item.pk).first()

@ -17,6 +17,8 @@
{% include "InvenTree/settings/setting.html" with key="BARCODE_WEBCAM_SUPPORT" icon="fa-video" %}
{% include "InvenTree/settings/setting.html" with key="BARCODE_SHOW_TEXT" icon="fa-closed-captioning" %}
{% include "InvenTree/settings/setting.html" with key="BARCODE_GENERATION_PLUGIN" icon="fa-qrcode" %}
{% include "InvenTree/settings/setting.html" with key="BARCODE_STORE_RESULTS" icon="fa-qrcode" %}
{% include "InvenTree/settings/setting.html" with key="BARCODE_RESULTS_MAX_NUM" icon="fa-qrcode" %}
</tbody>
</table>

@ -241,6 +241,7 @@ class RuleSet(models.Model):
'plugin_pluginconfig',
'plugin_pluginsetting',
'plugin_notificationusersetting',
'common_barcodescanresult',
'common_newsfeedentry',
'taggit_tag',
'taggit_taggeditem',