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:
		| @@ -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', | ||||
|   | ||||
		Reference in New Issue
	
	Block a user