From 60021031290936ecdcb2f12dc53522546c447f8a Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 23 Sep 2024 23:30:50 +1000 Subject: [PATCH] Barcode logging (#8150) * 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 --- docs/docs/barcodes/barcodes.md | 8 + docs/docs/settings/global.md | 2 + .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/InvenTree/mixins.py | 4 + src/backend/InvenTree/InvenTree/settings.py | 3 + src/backend/InvenTree/common/admin.py | 9 + .../migrations/0030_barcodescanresult.py | 34 ++ src/backend/InvenTree/common/models.py | 77 +++++ .../InvenTree/plugin/base/barcodes/api.py | 327 ++++++++++++++---- .../plugin/base/barcodes/serializers.py | 27 ++ .../plugin/base/barcodes/test_barcode.py | 23 +- .../templates/InvenTree/settings/barcode.html | 2 + src/backend/InvenTree/users/models.py | 1 + .../src/components/buttons/CopyButton.tsx | 19 +- src/frontend/src/components/items/QRCode.tsx | 2 +- .../components/modals/AboutInvenTreeModal.tsx | 7 +- .../src/components/nav/PanelGroup.tsx | 2 +- src/frontend/src/enums/ApiEndpoints.tsx | 11 +- src/frontend/src/functions/tables.tsx | 2 +- .../Index/Settings/AdminCenter/Index.tsx | 11 + .../pages/Index/Settings/SystemSettings.tsx | 4 +- src/frontend/src/tables/InvenTreeTable.tsx | 13 +- .../settings/BarcodeScanHistoryTable.tsx | 290 ++++++++++++++++ .../src/tables/settings/ErrorTable.tsx | 65 +++- src/frontend/tests/defaults.ts | 1 + src/frontend/tests/pui_plugins.spec.ts | 54 +-- src/frontend/tests/pui_settings.spec.ts | 40 ++- src/frontend/tests/settings.ts | 54 +++ 28 files changed, 929 insertions(+), 168 deletions(-) create mode 100644 src/backend/InvenTree/common/migrations/0030_barcodescanresult.py create mode 100644 src/frontend/src/tables/settings/BarcodeScanHistoryTable.tsx create mode 100644 src/frontend/tests/settings.ts diff --git a/docs/docs/barcodes/barcodes.md b/docs/docs/barcodes/barcodes.md index 22c07cc37fb4..a51c3dcc96bd 100644 --- a/docs/docs/barcodes/barcodes.md +++ b/docs/docs/barcodes/barcodes.md @@ -56,3 +56,11 @@ If no match is found for the scanned barcode, the following error message is dis ## App Integration Barcode scanning is a key feature of the [companion mobile app](../app/barcode.md). + +## Barcode History + +If enabled, InvenTree can retain logs of the most recent barcode scans. This can be very useful for debugging or auditing purpopes. + +Refer to the [barcode settings](../settings/global.md#barcodes) to enable barcode history logging. + +The barcode history can be viewed via the admin panel in the web interface. diff --git a/docs/docs/settings/global.md b/docs/docs/settings/global.md index e59e535e41cb..241ac74f0d40 100644 --- a/docs/docs/settings/global.md +++ b/docs/docs/settings/global.md @@ -90,6 +90,8 @@ Configuration of barcode functionality: {{ globalsetting("BARCODE_WEBCAM_SUPPORT") }} {{ globalsetting("BARCODE_SHOW_TEXT") }} {{ globalsetting("BARCODE_GENERATION_PLUGIN") }} +{{ globalsetting("BARCODE_STORE_RESULTS") }} +{{ globalsetting("BARCODE_RESULTS_MAX_NUM") }} ### Pricing and Currency diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 2387d9b87958..751cd4d0eefe 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/mixins.py b/src/backend/InvenTree/InvenTree/mixins.py index b7233616e53a..0550986b09e5 100644 --- a/src/backend/InvenTree/InvenTree/mixins.py +++ b/src/backend/InvenTree/InvenTree/mixins.py @@ -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.""" diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index ce1f6046478a..6612e25e0522 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -590,6 +590,9 @@ # 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}' diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py index a0719f9ab463..a2b02522db10 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -35,6 +35,15 @@ def formfield_for_dbfield(self, db_field, request, **kwargs): 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.""" diff --git a/src/backend/InvenTree/common/migrations/0030_barcodescanresult.py b/src/backend/InvenTree/common/migrations/0030_barcodescanresult.py new file mode 100644 index 000000000000..fe9ed14c6e33 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0030_barcodescanresult.py @@ -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), + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 03c6d8b0acae..ac76343f39ce 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -43,6 +43,7 @@ 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 @@ def save(self, *args, **kwargs): '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 @@ def clean(self) -> None: }) 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, + ) diff --git a/src/backend/InvenTree/plugin/base/barcodes/api.py b/src/backend/InvenTree/plugin/base/barcodes/api.py index a497c79a62e9..e506b1adc39d 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/api.py +++ b/src/backend/InvenTree/plugin/base/barcodes/api.py @@ -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 @@ def queryset(self): 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 @@ def handle_barcode(self, barcode: str, request, **kwargs): kwargs: Any custom fields passed by the specific serializer """ - result = self.scan_barcode(barcode, request, **kwargs) + response = self.scan_barcode(barcode, request, **kwargs) + + if response['plugin'] is None: + response['error'] = _('No match found for barcode data') + self.log_scan(request, response, False) + raise ValidationError(response) - if result['plugin'] is None: - result['error'] = _('No match found for barcode data') + response['success'] = _('Match found for barcode data') - raise ValidationError(result) + # Log the scan result + self.log_scan(request, response, True) - result['success'] = _('Match found for barcode data') - return Response(result) + return Response(response) @extend_schema_view( @@ -333,7 +415,7 @@ def get_supplier_part( 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 @@ def get_supplier_part( ) 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 @@ def handle_barcode(self, barcode: str, request, **kwargs): # 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 @@ def handle_barcode(self, barcode: str, request, **kwargs): 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 @@ def handle_barcode(self, barcode: str, request, **kwargs): # 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 @@ def get_line_item(self, stock_item, **kwargs): ) 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 @@ def get_shipment(self, **kwargs): return None def handle_barcode(self, barcode: str, request, **kwargs): - """Handle barcode scan for sales order allocation.""" - logger.debug("BarcodeSOAllocate: scanned barcode - '%s'", barcode) + """Handle barcode scan for sales order allocation. - result = self.scan_barcode(barcode, request, **kwargs) + Arguments: + barcode: Raw barcode data + request: HTTP request object - if result['plugin'] is None: - result['error'] = _('No match found for barcode data') - raise ValidationError(result) + kwargs: + sales_order: SalesOrder ID value (required) + line: SalesOrderLineItem ID value (optional) + shipment: SalesOrderShipment ID value (optional) + """ + logger.debug("BarcodeSOAllocate: scanned barcode - '%s'", barcode) - # 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) + response = self.scan_barcode(barcode, request, **kwargs) - 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 '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') + + 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 @@ def handle_barcode(self, barcode: str, request, **kwargs): 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 @@ def handle_barcode(self, barcode: str, request, **kwargs): 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( + '/', + 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) diff --git a/src/backend/InvenTree/plugin/base/barcodes/serializers.py b/src/backend/InvenTree/plugin/base/barcodes/serializers.py index 99baebeff054..a94d7b5bc083 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/serializers.py +++ b/src/backend/InvenTree/plugin/base/barcodes/serializers.py @@ -5,12 +5,39 @@ 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.""" diff --git a/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py b/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py index 9512a31fa557..5048af66c072 100644 --- a/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py +++ b/src/backend/InvenTree/plugin/base/barcodes/test_barcode.py @@ -4,6 +4,8 @@ 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 @@ def test_find_stock_item(self): """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 @@ def test_find_stock_item(self): 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 @@ def test_invalid_barcode(self): '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() diff --git a/src/backend/InvenTree/templates/InvenTree/settings/barcode.html b/src/backend/InvenTree/templates/InvenTree/settings/barcode.html index 8da99c0a9a79..e8b24065ad3b 100644 --- a/src/backend/InvenTree/templates/InvenTree/settings/barcode.html +++ b/src/backend/InvenTree/templates/InvenTree/settings/barcode.html @@ -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" %} diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index 9f34b0631999..d4c54b58535d 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -241,6 +241,7 @@ def get_ruleset_models(): 'plugin_pluginconfig', 'plugin_pluginsetting', 'plugin_notificationusersetting', + 'common_barcodescanresult', 'common_newsfeedentry', 'taggit_tag', 'taggit_taggeditem', diff --git a/src/frontend/src/components/buttons/CopyButton.tsx b/src/frontend/src/components/buttons/CopyButton.tsx index 87d7586828f2..6f2c733ec961 100644 --- a/src/frontend/src/components/buttons/CopyButton.tsx +++ b/src/frontend/src/components/buttons/CopyButton.tsx @@ -3,6 +3,7 @@ import { ActionIcon, Button, CopyButton as MantineCopyButton, + MantineSize, Text, Tooltip } from '@mantine/core'; @@ -11,10 +12,14 @@ import { InvenTreeIcon } from '../../functions/icons'; export function CopyButton({ value, - label + label, + content, + size }: Readonly<{ value: any; - label?: JSX.Element; + label?: string; + content?: JSX.Element; + size?: MantineSize; }>) { const ButtonComponent = label ? Button : ActionIcon; @@ -26,15 +31,19 @@ export function CopyButton({ color={copied ? 'teal' : 'gray'} onClick={copy} variant="transparent" - size="sm" + size={size ?? 'sm'} > {copied ? ( ) : ( )} - - {label && {label}} + {content} + {label && ( + + {label} + + )} )} diff --git a/src/frontend/src/components/items/QRCode.tsx b/src/frontend/src/components/items/QRCode.tsx index be8c573bb396..ade6ebac898c 100644 --- a/src/frontend/src/components/items/QRCode.tsx +++ b/src/frontend/src/components/items/QRCode.tsx @@ -75,7 +75,7 @@ export const InvenTreeQRCode = ({ const { data } = useQuery({ queryKey: ['qr-code', mdl_prop.model, mdl_prop.pk], queryFn: async () => { - const res = await api.post(apiUrl(ApiEndpoints.generate_barcode), { + const res = await api.post(apiUrl(ApiEndpoints.barcode_generate), { model: mdl_prop.model, pk: mdl_prop.pk }); diff --git a/src/frontend/src/components/modals/AboutInvenTreeModal.tsx b/src/frontend/src/components/modals/AboutInvenTreeModal.tsx index aecbcb60ad17..8581506fac1c 100644 --- a/src/frontend/src/components/modals/AboutInvenTreeModal.tsx +++ b/src/frontend/src/components/modals/AboutInvenTreeModal.tsx @@ -1,4 +1,4 @@ -import { Trans } from '@lingui/macro'; +import { Trans, t } from '@lingui/macro'; import { Anchor, Badge, @@ -178,10 +178,7 @@ export function AboutInvenTreeModal({ - Copy version information} - /> +