mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +00:00 
			
		
		
		
	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
This commit is contained in:
		| @@ -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. | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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 ? ( | ||||
|               <InvenTreeIcon icon="check" /> | ||||
|             ) : ( | ||||
|               <InvenTreeIcon icon="copy" /> | ||||
|             )} | ||||
|  | ||||
|             {label && <Text ml={10}>{label}</Text>} | ||||
|             {content} | ||||
|             {label && ( | ||||
|               <Text p={size ?? 'sm'} size={size ?? 'sm'}> | ||||
|                 {label} | ||||
|               </Text> | ||||
|             )} | ||||
|           </ButtonComponent> | ||||
|         </Tooltip> | ||||
|       )} | ||||
|   | ||||
| @@ -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 | ||||
|       }); | ||||
|   | ||||
| @@ -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({ | ||||
|       </Table> | ||||
|       <Divider /> | ||||
|       <Group justify="space-between"> | ||||
|         <CopyButton | ||||
|           value={copyval} | ||||
|           label={<Trans>Copy version information</Trans>} | ||||
|         /> | ||||
|         <CopyButton value={copyval} label={t`Copy version information`} /> | ||||
|         <Space /> | ||||
|         <Button | ||||
|           color="red" | ||||
|   | ||||
| @@ -134,7 +134,7 @@ function BasePanelGroup({ | ||||
|               (panel) => | ||||
|                 !panel.hidden && ( | ||||
|                   <Tooltip | ||||
|                     label={`tooltip-${panel.name}`} | ||||
|                     label={panel.label ?? panel.name} | ||||
|                     key={panel.name} | ||||
|                     disabled={expanded} | ||||
|                     position="right" | ||||
|   | ||||
| @@ -39,10 +39,6 @@ export enum ApiEndpoints { | ||||
|   api_search = 'search/', | ||||
|   settings_global_list = 'settings/global/', | ||||
|   settings_user_list = 'settings/user/', | ||||
|   barcode = 'barcode/', | ||||
|   barcode_link = 'barcode/link/', | ||||
|   barcode_unlink = 'barcode/unlink/', | ||||
|   generate_barcode = 'barcode/generate/', | ||||
|   news = 'news/', | ||||
|   global_status = 'generic/status/', | ||||
|   custom_state_list = 'generic/status/custom/', | ||||
| @@ -54,6 +50,13 @@ export enum ApiEndpoints { | ||||
|   content_type_list = 'contenttype/', | ||||
|   icons = 'icons/', | ||||
|  | ||||
|   // Barcode API endpoints | ||||
|   barcode = 'barcode/', | ||||
|   barcode_history = 'barcode/history/', | ||||
|   barcode_link = 'barcode/link/', | ||||
|   barcode_unlink = 'barcode/unlink/', | ||||
|   barcode_generate = 'barcode/generate/', | ||||
|  | ||||
|   // Data import endpoints | ||||
|   import_session_list = 'importer/session/', | ||||
|   import_session_accept_fields = 'importer/session/:id/accept_fields/', | ||||
|   | ||||
| @@ -22,5 +22,5 @@ export function shortenString({ | ||||
|   // Otherwise, shorten it | ||||
|   let N = Math.floor(len / 2 - 1); | ||||
|  | ||||
|   return str.slice(0, N) + '...' + str.slice(-N); | ||||
|   return str.slice(0, N) + ' ... ' + str.slice(-N); | ||||
| } | ||||
|   | ||||
| @@ -19,6 +19,7 @@ import { | ||||
|   IconListDetails, | ||||
|   IconPackages, | ||||
|   IconPlugConnected, | ||||
|   IconQrcode, | ||||
|   IconReport, | ||||
|   IconScale, | ||||
|   IconSitemap, | ||||
| @@ -68,6 +69,10 @@ const ErrorReportTable = Loadable( | ||||
|   lazy(() => import('../../../../tables/settings/ErrorTable')) | ||||
| ); | ||||
|  | ||||
| const BarcodeScanHistoryTable = Loadable( | ||||
|   lazy(() => import('../../../../tables/settings/BarcodeScanHistoryTable')) | ||||
| ); | ||||
|  | ||||
| const ImportSesssionTable = Loadable( | ||||
|   lazy(() => import('../../../../tables/settings/ImportSessionTable')) | ||||
| ); | ||||
| @@ -111,6 +116,12 @@ export default function AdminCenter() { | ||||
|         icon: <IconFileUpload />, | ||||
|         content: <ImportSesssionTable /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'barcode-history', | ||||
|         label: t`Barcode Scans`, | ||||
|         icon: <IconQrcode />, | ||||
|         content: <BarcodeScanHistoryTable /> | ||||
|       }, | ||||
|       { | ||||
|         name: 'background', | ||||
|         label: t`Background Tasks`, | ||||
|   | ||||
| @@ -98,7 +98,9 @@ export default function SystemSettings() { | ||||
|               'BARCODE_INPUT_DELAY', | ||||
|               'BARCODE_WEBCAM_SUPPORT', | ||||
|               'BARCODE_SHOW_TEXT', | ||||
|               'BARCODE_GENERATION_PLUGIN' | ||||
|               'BARCODE_GENERATION_PLUGIN', | ||||
|               'BARCODE_STORE_RESULTS', | ||||
|               'BARCODE_RESULTS_MAX_NUM' | ||||
|             ]} | ||||
|           /> | ||||
|         ) | ||||
|   | ||||
| @@ -243,6 +243,10 @@ export function InvenTreeTable<T extends Record<string, any>>({ | ||||
|     }; | ||||
|   }, [props]); | ||||
|  | ||||
|   const enableSelection: boolean = useMemo(() => { | ||||
|     return tableProps.enableSelection || tableProps.enableBulkDelete || false; | ||||
|   }, [tableProps]); | ||||
|  | ||||
|   // Check if any columns are switchable (can be hidden) | ||||
|   const hasSwitchableColumns: boolean = useMemo(() => { | ||||
|     if (props.enableColumnSwitching == false) { | ||||
| @@ -309,7 +313,6 @@ export function InvenTreeTable<T extends Record<string, any>>({ | ||||
|     columns, | ||||
|     fieldNames, | ||||
|     tableProps.rowActions, | ||||
|     tableProps.enableSelection, | ||||
|     tableState.hiddenColumns, | ||||
|     tableState.selectedRecords | ||||
|   ]); | ||||
| @@ -641,7 +644,7 @@ export function InvenTreeTable<T extends Record<string, any>>({ | ||||
|                   actions={tableProps.barcodeActions ?? []} | ||||
|                 /> | ||||
|               )} | ||||
|               {(tableProps.enableBulkDelete ?? false) && ( | ||||
|               {tableProps.enableBulkDelete && ( | ||||
|                 <ActionButton | ||||
|                   disabled={!tableState.hasSelectedRecords} | ||||
|                   icon={<IconTrash />} | ||||
| @@ -726,12 +729,10 @@ export function InvenTreeTable<T extends Record<string, any>>({ | ||||
|               sortStatus={sortStatus} | ||||
|               onSortStatusChange={handleSortStatusChange} | ||||
|               selectedRecords={ | ||||
|                 tableProps.enableSelection | ||||
|                   ? tableState.selectedRecords | ||||
|                   : undefined | ||||
|                 enableSelection ? tableState.selectedRecords : undefined | ||||
|               } | ||||
|               onSelectedRecordsChange={ | ||||
|                 tableProps.enableSelection ? onSelectedRecordsChange : undefined | ||||
|                 enableSelection ? onSelectedRecordsChange : undefined | ||||
|               } | ||||
|               rowExpansion={tableProps.rowExpansion} | ||||
|               rowStyle={tableProps.rowStyle} | ||||
|   | ||||
							
								
								
									
										290
									
								
								src/frontend/src/tables/settings/BarcodeScanHistoryTable.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										290
									
								
								src/frontend/src/tables/settings/BarcodeScanHistoryTable.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,290 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { | ||||
|   Alert, | ||||
|   Badge, | ||||
|   Divider, | ||||
|   Drawer, | ||||
|   Group, | ||||
|   MantineStyleProp, | ||||
|   Stack, | ||||
|   Table, | ||||
|   Text | ||||
| } from '@mantine/core'; | ||||
| import { useDisclosure } from '@mantine/hooks'; | ||||
| import { IconExclamationCircle } from '@tabler/icons-react'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
|  | ||||
| import { CopyButton } from '../../components/buttons/CopyButton'; | ||||
| import { PassFailButton } from '../../components/buttons/YesNoButton'; | ||||
| import { StylishText } from '../../components/items/StylishText'; | ||||
| import { RenderUser } from '../../components/render/User'; | ||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||
| import { UserRoles } from '../../enums/Roles'; | ||||
| import { shortenString } from '../../functions/tables'; | ||||
| import { useUserFilters } from '../../hooks/UseFilter'; | ||||
| import { useDeleteApiFormModal } from '../../hooks/UseForm'; | ||||
| import { useTable } from '../../hooks/UseTable'; | ||||
| import { apiUrl } from '../../states/ApiState'; | ||||
| import { useGlobalSettingsState } from '../../states/SettingsState'; | ||||
| import { useUserState } from '../../states/UserState'; | ||||
| import { TableColumn } from '../Column'; | ||||
| import { TableFilter } from '../Filter'; | ||||
| import { InvenTreeTable } from '../InvenTreeTable'; | ||||
| import { RowDeleteAction } from '../RowActions'; | ||||
|  | ||||
| /* | ||||
|  * Render detail information for a particular barcode scan result. | ||||
|  */ | ||||
| function BarcodeScanDetail({ scan }: { scan: any }) { | ||||
|   const dataStyle: MantineStyleProp = { | ||||
|     textWrap: 'wrap', | ||||
|     lineBreak: 'auto', | ||||
|     wordBreak: 'break-word' | ||||
|   }; | ||||
|  | ||||
|   const hasResponseData = useMemo(() => { | ||||
|     return scan.response && Object.keys(scan.response).length > 0; | ||||
|   }, [scan.response]); | ||||
|  | ||||
|   const hasContextData = useMemo(() => { | ||||
|     return scan.context && Object.keys(scan.context).length > 0; | ||||
|   }, [scan.context]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Stack gap="xs"> | ||||
|         <Divider /> | ||||
|         <Table> | ||||
|           <Table.Tbody> | ||||
|             <Table.Tr> | ||||
|               <Table.Td colSpan={2}> | ||||
|                 <StylishText size="sm">{t`Barcode Information`}</StylishText> | ||||
|               </Table.Td> | ||||
|             </Table.Tr> | ||||
|             <Table.Tr> | ||||
|               <Table.Th>{t`Barcode`}</Table.Th> | ||||
|               <Table.Td> | ||||
|                 <Text size="sm" style={dataStyle}> | ||||
|                   {scan.data} | ||||
|                 </Text> | ||||
|               </Table.Td> | ||||
|               <Table.Td> | ||||
|                 <CopyButton value={scan.data} size="xs" /> | ||||
|               </Table.Td> | ||||
|             </Table.Tr> | ||||
|             <Table.Tr> | ||||
|               <Table.Th>{t`Timestamp`}</Table.Th> | ||||
|               <Table.Td>{scan.timestamp}</Table.Td> | ||||
|             </Table.Tr> | ||||
|             <Table.Tr> | ||||
|               <Table.Th>{t`User`}</Table.Th> | ||||
|               <Table.Td> | ||||
|                 <RenderUser instance={scan.user_detail} /> | ||||
|               </Table.Td> | ||||
|             </Table.Tr> | ||||
|             <Table.Tr> | ||||
|               <Table.Th>{t`Endpoint`}</Table.Th> | ||||
|               <Table.Td>{scan.endpoint}</Table.Td> | ||||
|             </Table.Tr> | ||||
|             <Table.Tr> | ||||
|               <Table.Th>{t`Result`}</Table.Th> | ||||
|               <Table.Td> | ||||
|                 <PassFailButton value={scan.result} /> | ||||
|               </Table.Td> | ||||
|             </Table.Tr> | ||||
|             {hasContextData && ( | ||||
|               <Table.Tr> | ||||
|                 <Table.Td colSpan={2}> | ||||
|                   <StylishText size="sm">{t`Context`}</StylishText> | ||||
|                 </Table.Td> | ||||
|               </Table.Tr> | ||||
|             )} | ||||
|             {hasContextData && | ||||
|               Object.keys(scan.context).map((key) => ( | ||||
|                 <Table.Tr key={key}> | ||||
|                   <Table.Th>{key}</Table.Th> | ||||
|                   <Table.Td> | ||||
|                     <Text size="sm" style={dataStyle}> | ||||
|                       {scan.context[key]} | ||||
|                     </Text> | ||||
|                   </Table.Td> | ||||
|                   <Table.Td> | ||||
|                     <CopyButton value={scan.context[key]} size="xs" /> | ||||
|                   </Table.Td> | ||||
|                 </Table.Tr> | ||||
|               ))} | ||||
|             {hasResponseData && ( | ||||
|               <Table.Tr> | ||||
|                 <Table.Td colSpan={2}> | ||||
|                   <StylishText size="sm">{t`Response`}</StylishText> | ||||
|                 </Table.Td> | ||||
|               </Table.Tr> | ||||
|             )} | ||||
|             {hasResponseData && | ||||
|               Object.keys(scan.response).map((key) => ( | ||||
|                 <Table.Tr key={key}> | ||||
|                   <Table.Th>{key}</Table.Th> | ||||
|                   <Table.Td> | ||||
|                     <Text size="sm" style={dataStyle}> | ||||
|                       {scan.response[key]} | ||||
|                     </Text> | ||||
|                   </Table.Td> | ||||
|                   <Table.Td> | ||||
|                     <CopyButton value={scan.response[key]} size="xs" /> | ||||
|                   </Table.Td> | ||||
|                 </Table.Tr> | ||||
|               ))} | ||||
|           </Table.Tbody> | ||||
|         </Table> | ||||
|       </Stack> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /* | ||||
|  * Display the barcode scan history table | ||||
|  */ | ||||
| export default function BarcodeScanHistoryTable() { | ||||
|   const user = useUserState(); | ||||
|   const table = useTable('barcode-history'); | ||||
|  | ||||
|   const globalSettings = useGlobalSettingsState(); | ||||
|  | ||||
|   const userFilters = useUserFilters(); | ||||
|  | ||||
|   const [opened, { open, close }] = useDisclosure(false); | ||||
|  | ||||
|   const tableColumns: TableColumn[] = useMemo(() => { | ||||
|     return [ | ||||
|       { | ||||
|         accessor: 'timestamp', | ||||
|         sortable: true, | ||||
|         switchable: false, | ||||
|         render: (record: any) => { | ||||
|           return ( | ||||
|             <Group justify="space-between" wrap="nowrap"> | ||||
|               <Text>{record.timestamp}</Text> | ||||
|               {record.user_detail && ( | ||||
|                 <Badge size="xs">{record.user_detail.username}</Badge> | ||||
|               )} | ||||
|             </Group> | ||||
|           ); | ||||
|         } | ||||
|       }, | ||||
|       { | ||||
|         accessor: 'data', | ||||
|         sortable: false, | ||||
|         switchable: true, | ||||
|         render: (record: any) => { | ||||
|           return ( | ||||
|             <Text | ||||
|               size="xs" | ||||
|               style={{ | ||||
|                 textWrap: 'wrap', | ||||
|                 lineBreak: 'auto', | ||||
|                 wordBreak: 'break-word' | ||||
|               }} | ||||
|             > | ||||
|               {shortenString({ str: record.data, len: 100 })} | ||||
|             </Text> | ||||
|           ); | ||||
|         } | ||||
|       }, | ||||
|       { | ||||
|         accessor: 'endpoint', | ||||
|         sortable: true | ||||
|       }, | ||||
|       { | ||||
|         accessor: 'result', | ||||
|         sortable: true, | ||||
|         render: (record: any) => { | ||||
|           return <PassFailButton value={record.result} />; | ||||
|         } | ||||
|       } | ||||
|     ]; | ||||
|   }, []); | ||||
|  | ||||
|   const filters: TableFilter[] = useMemo(() => { | ||||
|     return [ | ||||
|       { | ||||
|         name: 'user', | ||||
|         label: t`User`, | ||||
|         choices: userFilters.choices, | ||||
|         description: t`Filter by user` | ||||
|       }, | ||||
|       { | ||||
|         name: 'result', | ||||
|         label: t`Result`, | ||||
|         description: t`Filter by result` | ||||
|       } | ||||
|     ]; | ||||
|   }, [userFilters]); | ||||
|  | ||||
|   const canDelete: boolean = useMemo(() => { | ||||
|     return user.isStaff() && user.hasDeleteRole(UserRoles.admin); | ||||
|   }, [user]); | ||||
|  | ||||
|   const [selectedResult, setSelectedResult] = useState<any>({}); | ||||
|  | ||||
|   const deleteResult = useDeleteApiFormModal({ | ||||
|     url: ApiEndpoints.barcode_history, | ||||
|     pk: selectedResult.pk, | ||||
|     title: t`Delete Barcode Scan Record`, | ||||
|     table: table | ||||
|   }); | ||||
|  | ||||
|   const rowActions = useCallback( | ||||
|     (record: any) => { | ||||
|       return [ | ||||
|         RowDeleteAction({ | ||||
|           hidden: !canDelete, | ||||
|           onClick: () => { | ||||
|             setSelectedResult(record); | ||||
|             deleteResult.open(); | ||||
|           } | ||||
|         }) | ||||
|       ]; | ||||
|     }, | ||||
|     [canDelete, user] | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {deleteResult.modal} | ||||
|       <Drawer | ||||
|         opened={opened} | ||||
|         size="xl" | ||||
|         position="right" | ||||
|         title={<StylishText>{t`Barcode Scan Details`}</StylishText>} | ||||
|         onClose={close} | ||||
|       > | ||||
|         <BarcodeScanDetail scan={selectedResult} /> | ||||
|       </Drawer> | ||||
|       <Stack gap="xs"> | ||||
|         {!globalSettings.isSet('BARCODE_STORE_RESULTS') && ( | ||||
|           <Alert | ||||
|             color="red" | ||||
|             icon={<IconExclamationCircle />} | ||||
|             title={t`Logging Disabled`} | ||||
|           > | ||||
|             <Text>{t`Barcode logging is not enabled`}</Text> | ||||
|           </Alert> | ||||
|         )} | ||||
|         <InvenTreeTable | ||||
|           url={apiUrl(ApiEndpoints.barcode_history)} | ||||
|           tableState={table} | ||||
|           columns={tableColumns} | ||||
|           props={{ | ||||
|             tableFilters: filters, | ||||
|             enableBulkDelete: canDelete, | ||||
|             rowActions: rowActions, | ||||
|             onRowClick: (row) => { | ||||
|               setSelectedResult(row); | ||||
|               open(); | ||||
|             } | ||||
|           }} | ||||
|         /> | ||||
|       </Stack> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,8 +1,9 @@ | ||||
| import { t } from '@lingui/macro'; | ||||
| import { Drawer, Text } from '@mantine/core'; | ||||
| import { Drawer, Group, Stack, Table, Text } from '@mantine/core'; | ||||
| import { useDisclosure } from '@mantine/hooks'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
|  | ||||
| import { CopyButton } from '../../components/buttons/CopyButton'; | ||||
| import { StylishText } from '../../components/items/StylishText'; | ||||
| import { ApiEndpoints } from '../../enums/ApiEndpoints'; | ||||
| import { useDeleteApiFormModal } from '../../hooks/UseForm'; | ||||
| @@ -13,6 +14,48 @@ import { TableColumn } from '../Column'; | ||||
| import { InvenTreeTable } from '../InvenTreeTable'; | ||||
| import { RowAction, RowDeleteAction } from '../RowActions'; | ||||
|  | ||||
| function ErrorDetail({ error }: { error: any }) { | ||||
|   return ( | ||||
|     <Stack gap="xs"> | ||||
|       <Table> | ||||
|         <Table.Tbody> | ||||
|           <Table.Tr> | ||||
|             <Table.Th>{t`Message`}</Table.Th> | ||||
|             <Table.Td>{error.info}</Table.Td> | ||||
|           </Table.Tr> | ||||
|           <Table.Tr> | ||||
|             <Table.Th>{t`Timestamp`}</Table.Th> | ||||
|             <Table.Td>{error.when}</Table.Td> | ||||
|           </Table.Tr> | ||||
|           <Table.Tr> | ||||
|             <Table.Th>{t`Path`}</Table.Th> | ||||
|             <Table.Td>{error.path}</Table.Td> | ||||
|           </Table.Tr> | ||||
|           <Table.Tr> | ||||
|             <Table.Th>{t`Traceback`}</Table.Th> | ||||
|             <Table.Td> | ||||
|               <Group justify="right"> | ||||
|                 <CopyButton value={error.data} size="sm" /> | ||||
|               </Group> | ||||
|             </Table.Td> | ||||
|           </Table.Tr> | ||||
|           <Table.Tr> | ||||
|             <Table.Td colSpan={2}> | ||||
|               <Stack gap={3}> | ||||
|                 {error.data.split('\n').map((line: string, index: number) => ( | ||||
|                   <Text size="xs" key={`error-line-${index}`}> | ||||
|                     {line} | ||||
|                   </Text> | ||||
|                 ))} | ||||
|               </Stack> | ||||
|             </Table.Td> | ||||
|           </Table.Tr> | ||||
|         </Table.Tbody> | ||||
|       </Table> | ||||
|     </Stack> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| /* | ||||
|  * Table for display server error information | ||||
|  */ | ||||
| @@ -20,8 +63,6 @@ export default function ErrorReportTable() { | ||||
|   const table = useTable('error-report'); | ||||
|   const user = useUserState(); | ||||
|  | ||||
|   const [error, setError] = useState<string>(''); | ||||
|  | ||||
|   const [opened, { open, close }] = useDisclosure(false); | ||||
|  | ||||
|   const columns: TableColumn[] = useMemo(() => { | ||||
| @@ -43,13 +84,11 @@ export default function ErrorReportTable() { | ||||
|     ]; | ||||
|   }, []); | ||||
|  | ||||
|   const [selectedError, setSelectedError] = useState<number | undefined>( | ||||
|     undefined | ||||
|   ); | ||||
|   const [selectedError, setSelectedError] = useState<any>({}); | ||||
|  | ||||
|   const deleteErrorModal = useDeleteApiFormModal({ | ||||
|     url: ApiEndpoints.error_report_list, | ||||
|     pk: selectedError, | ||||
|     pk: selectedError.pk, | ||||
|     title: t`Delete Error Report`, | ||||
|     preFormContent: ( | ||||
|       <Text c="red">{t`Are you sure you want to delete this error report?`}</Text> | ||||
| @@ -62,7 +101,7 @@ export default function ErrorReportTable() { | ||||
|     return [ | ||||
|       RowDeleteAction({ | ||||
|         onClick: () => { | ||||
|           setSelectedError(record.pk); | ||||
|           setSelectedError(record); | ||||
|           deleteErrorModal.open(); | ||||
|         } | ||||
|       }) | ||||
| @@ -79,13 +118,7 @@ export default function ErrorReportTable() { | ||||
|         title={<StylishText>{t`Error Details`}</StylishText>} | ||||
|         onClose={close} | ||||
|       > | ||||
|         {error.split('\n').map((line: string) => { | ||||
|           return ( | ||||
|             <Text key={line} size="sm"> | ||||
|               {line} | ||||
|             </Text> | ||||
|           ); | ||||
|         })} | ||||
|         <ErrorDetail error={selectedError} /> | ||||
|       </Drawer> | ||||
|       <InvenTreeTable | ||||
|         url={apiUrl(ApiEndpoints.error_report_list)} | ||||
| @@ -96,7 +129,7 @@ export default function ErrorReportTable() { | ||||
|           enableSelection: true, | ||||
|           rowActions: rowActions, | ||||
|           onRowClick: (row) => { | ||||
|             setError(row.data); | ||||
|             setSelectedError(row); | ||||
|             open(); | ||||
|           } | ||||
|         }} | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| export const classicUrl = 'http://127.0.0.1:8000'; | ||||
|  | ||||
| export const apiUrl = `${classicUrl}/api`; | ||||
| export const baseUrl = './platform'; | ||||
| export const loginUrl = `${baseUrl}/login`; | ||||
| export const logoutUrl = `${baseUrl}/logout`; | ||||
|   | ||||
| @@ -1,58 +1,8 @@ | ||||
| import test, { expect } from 'playwright/test'; | ||||
| import test from 'playwright/test'; | ||||
|  | ||||
| import { baseUrl } from './defaults.js'; | ||||
| import { doQuickLogin } from './login.js'; | ||||
|  | ||||
| /* | ||||
|  * Set the value of a global setting in the database | ||||
|  */ | ||||
| const setSettingState = async ({ | ||||
|   request, | ||||
|   setting, | ||||
|   value | ||||
| }: { | ||||
|   request: any; | ||||
|   setting: string; | ||||
|   value: any; | ||||
| }) => { | ||||
|   const url = `http://localhost:8000/api/settings/global/${setting}/`; | ||||
|  | ||||
|   const response = await request.patch(url, { | ||||
|     data: { | ||||
|       value: value | ||||
|     }, | ||||
|     headers: { | ||||
|       // Basic username: password authorization | ||||
|       Authorization: `Basic ${btoa('admin:inventree')}` | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   expect(await response.status()).toBe(200); | ||||
| }; | ||||
|  | ||||
| const setPluginState = async ({ | ||||
|   request, | ||||
|   plugin, | ||||
|   state | ||||
| }: { | ||||
|   request: any; | ||||
|   plugin: string; | ||||
|   state: boolean; | ||||
| }) => { | ||||
|   const url = `http://localhost:8000/api/plugins/${plugin}/activate/`; | ||||
|  | ||||
|   const response = await request.patch(url, { | ||||
|     data: { | ||||
|       active: state | ||||
|     }, | ||||
|     headers: { | ||||
|       // Basic username: password authorization | ||||
|       Authorization: `Basic ${btoa('admin:inventree')}` | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   expect(await response.status()).toBe(200); | ||||
| }; | ||||
| import { setPluginState, setSettingState } from './settings.js'; | ||||
|  | ||||
| test('Plugins - Panels', async ({ page, request }) => { | ||||
|   await doQuickLogin(page, 'admin', 'inventree'); | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { expect, test } from './baseFixtures.js'; | ||||
| import { baseUrl } from './defaults.js'; | ||||
| import { apiUrl, baseUrl } from './defaults.js'; | ||||
| import { doQuickLogin } from './login.js'; | ||||
| import { setSettingState } from './settings.js'; | ||||
|  | ||||
| test('PUI - Admin', async ({ page }) => { | ||||
|   // Note here we login with admin access | ||||
| @@ -85,6 +86,43 @@ test('PUI - Admin', async ({ page }) => { | ||||
|   await page.getByRole('button', { name: 'Submit' }).click(); | ||||
| }); | ||||
|  | ||||
| test('PUI - Admin - Barcode History', async ({ page, request }) => { | ||||
|   // Login with admin credentials | ||||
|   await doQuickLogin(page, 'admin', 'inventree'); | ||||
|  | ||||
|   // Ensure that the "save scans" setting is enabled | ||||
|   await setSettingState({ | ||||
|     request: request, | ||||
|     setting: 'BARCODE_STORE_RESULTS', | ||||
|     value: true | ||||
|   }); | ||||
|  | ||||
|   // Scan some barcodes (via API calls) | ||||
|   const barcodes = ['ABC1234', 'XYZ5678', 'QRS9012']; | ||||
|  | ||||
|   barcodes.forEach(async (barcode) => { | ||||
|     await request.post(`${apiUrl}/barcode/`, { | ||||
|       data: { | ||||
|         barcode: barcode | ||||
|       }, | ||||
|       headers: { | ||||
|         Authorization: `Basic ${btoa('admin:inventree')}` | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   await page.getByRole('button', { name: 'admin' }).click(); | ||||
|   await page.getByRole('menuitem', { name: 'Admin Center' }).click(); | ||||
|   await page.getByRole('tab', { name: 'Barcode Scans' }).click(); | ||||
|  | ||||
|   await page.waitForTimeout(2000); | ||||
|  | ||||
|   // Barcode history is displayed in table | ||||
|   barcodes.forEach(async (barcode) => { | ||||
|     await page.getByText(barcode).first().waitFor(); | ||||
|   }); | ||||
| }); | ||||
|  | ||||
| test('PUI - Admin - Unauthorized', async ({ page }) => { | ||||
|   // Try to access "admin" page with a non-staff user | ||||
|   await doQuickLogin(page, 'allaccess', 'nolimits'); | ||||
|   | ||||
							
								
								
									
										54
									
								
								src/frontend/tests/settings.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/frontend/tests/settings.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| import { expect } from 'playwright/test'; | ||||
|  | ||||
| import { apiUrl } from './defaults'; | ||||
|  | ||||
| /* | ||||
|  * Set the value of a global setting in the database | ||||
|  */ | ||||
| export const setSettingState = async ({ | ||||
|   request, | ||||
|   setting, | ||||
|   value | ||||
| }: { | ||||
|   request: any; | ||||
|   setting: string; | ||||
|   value: any; | ||||
| }) => { | ||||
|   const url = `${apiUrl}/settings/global/${setting}/`; | ||||
|  | ||||
|   const response = await request.patch(url, { | ||||
|     data: { | ||||
|       value: value | ||||
|     }, | ||||
|     headers: { | ||||
|       // Basic username: password authorization | ||||
|       Authorization: `Basic ${btoa('admin:inventree')}` | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   expect(await response.status()).toBe(200); | ||||
| }; | ||||
|  | ||||
| export const setPluginState = async ({ | ||||
|   request, | ||||
|   plugin, | ||||
|   state | ||||
| }: { | ||||
|   request: any; | ||||
|   plugin: string; | ||||
|   state: boolean; | ||||
| }) => { | ||||
|   const url = `${apiUrl}/plugins/${plugin}/activate/`; | ||||
|  | ||||
|   const response = await request.patch(url, { | ||||
|     data: { | ||||
|       active: state | ||||
|     }, | ||||
|     headers: { | ||||
|       // Basic username: password authorization | ||||
|       Authorization: `Basic ${btoa('admin:inventree')}` | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   expect(await response.status()).toBe(200); | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user