mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 11:36:44 +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:
parent
f7e0edb7a6
commit
6002103129
@ -56,3 +56,11 @@ If no match is found for the scanned barcode, the following error message is dis
|
|||||||
## App Integration
|
## App Integration
|
||||||
|
|
||||||
Barcode scanning is a key feature of the [companion mobile app](../app/barcode.md).
|
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_WEBCAM_SUPPORT") }}
|
||||||
{{ globalsetting("BARCODE_SHOW_TEXT") }}
|
{{ globalsetting("BARCODE_SHOW_TEXT") }}
|
||||||
{{ globalsetting("BARCODE_GENERATION_PLUGIN") }}
|
{{ globalsetting("BARCODE_GENERATION_PLUGIN") }}
|
||||||
|
{{ globalsetting("BARCODE_STORE_RESULTS") }}
|
||||||
|
{{ globalsetting("BARCODE_RESULTS_MAX_NUM") }}
|
||||||
|
|
||||||
### Pricing and Currency
|
### Pricing and Currency
|
||||||
|
|
||||||
|
@ -1,13 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v256 - 2024-09-19 : https://github.com/inventree/InvenTree/pull/7704
|
||||||
- Adjustments for "stocktake" (stock history) API endpoints
|
- Adjustments for "stocktake" (stock history) API endpoints
|
||||||
|
|
||||||
|
@ -180,5 +180,9 @@ class RetrieveUpdateDestroyAPI(CleanMixin, generics.RetrieveUpdateDestroyAPIView
|
|||||||
"""View for retrieve, update and destroy API."""
|
"""View for retrieve, update and destroy API."""
|
||||||
|
|
||||||
|
|
||||||
|
class RetrieveDestroyAPI(generics.RetrieveDestroyAPIView):
|
||||||
|
"""View for retrieve and destroy API."""
|
||||||
|
|
||||||
|
|
||||||
class UpdateAPI(CleanMixin, generics.UpdateAPIView):
|
class UpdateAPI(CleanMixin, generics.UpdateAPIView):
|
||||||
"""View for update API."""
|
"""View for update API."""
|
||||||
|
@ -590,6 +590,9 @@ for key in db_keys:
|
|||||||
# Check that required database configuration options are specified
|
# Check that required database configuration options are specified
|
||||||
required_keys = ['ENGINE', 'NAME']
|
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:
|
for key in required_keys:
|
||||||
if key not in db_config: # pragma: no cover
|
if key not in db_config: # pragma: no cover
|
||||||
error_msg = f'Missing required database configuration value {key}'
|
error_msg = f'Missing required database configuration value {key}'
|
||||||
|
@ -35,6 +35,15 @@ class AttachmentAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('content_type', 'comment')
|
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)
|
@admin.register(common.models.ProjectCode)
|
||||||
class ProjectCodeAdmin(ImportExportModelAdmin):
|
class ProjectCodeAdmin(ImportExportModelAdmin):
|
||||||
"""Admin settings for ProjectCode."""
|
"""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 build.validators
|
||||||
import common.currency
|
import common.currency
|
||||||
import common.validators
|
import common.validators
|
||||||
|
import InvenTree.exceptions
|
||||||
import InvenTree.fields
|
import InvenTree.fields
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
import InvenTree.models
|
import InvenTree.models
|
||||||
@ -1398,6 +1399,18 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
|||||||
'default': True,
|
'default': True,
|
||||||
'validator': bool,
|
'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': {
|
'BARCODE_INPUT_DELAY': {
|
||||||
'name': _('Barcode Input Delay'),
|
'name': _('Barcode Input Delay'),
|
||||||
'description': _('Barcode input processing delay time'),
|
'description': _('Barcode input processing delay time'),
|
||||||
@ -3445,3 +3458,67 @@ class InvenTreeCustomUserStateModel(models.Model):
|
|||||||
})
|
})
|
||||||
|
|
||||||
return super().clean()
|
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
|
import logging
|
||||||
|
|
||||||
from django.db.models import F
|
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.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 drf_spectacular.utils import extend_schema, extend_schema_view
|
||||||
from rest_framework import permissions, status
|
from rest_framework import permissions, status
|
||||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
from rest_framework.generics import CreateAPIView
|
from rest_framework.generics import CreateAPIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
import common.models
|
||||||
import order.models
|
import order.models
|
||||||
import plugin.base.barcodes.helper
|
import plugin.base.barcodes.helper
|
||||||
import stock.models
|
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.helpers import hash_barcode
|
||||||
|
from InvenTree.mixins import ListAPI, RetrieveDestroyAPI
|
||||||
|
from InvenTree.permissions import IsStaffOrReadOnly
|
||||||
from plugin import registry
|
from plugin import registry
|
||||||
from users.models import RuleSet
|
from users.models import RuleSet
|
||||||
|
|
||||||
@ -30,6 +38,70 @@ class BarcodeView(CreateAPIView):
|
|||||||
# Default serializer class (can be overridden)
|
# Default serializer class (can be overridden)
|
||||||
serializer_class = barcode_serializers.BarcodeSerializer
|
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):
|
def queryset(self):
|
||||||
"""This API view does not have a queryset."""
|
"""This API view does not have a queryset."""
|
||||||
return None
|
return None
|
||||||
@ -40,7 +112,13 @@ class BarcodeView(CreateAPIView):
|
|||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""Handle create method - override default create."""
|
"""Handle create method - override default create."""
|
||||||
serializer = self.get_serializer(data=request.data)
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
|
||||||
|
try:
|
||||||
serializer.is_valid(raise_exception=True)
|
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
|
data = serializer.validated_data
|
||||||
|
|
||||||
barcode = str(data.pop('barcode')).strip()
|
barcode = str(data.pop('barcode')).strip()
|
||||||
@ -119,15 +197,19 @@ class BarcodeScan(BarcodeView):
|
|||||||
kwargs:
|
kwargs:
|
||||||
Any custom fields passed by the specific serializer
|
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:
|
if response['plugin'] is None:
|
||||||
result['error'] = _('No match found for barcode data')
|
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')
|
# Log the scan result
|
||||||
return Response(result)
|
self.log_scan(request, response, True)
|
||||||
|
|
||||||
|
return Response(response)
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
@ -333,7 +415,7 @@ class BarcodePOAllocate(BarcodeView):
|
|||||||
supplier_parts = company.models.SupplierPart.objects.filter(supplier=supplier)
|
supplier_parts = company.models.SupplierPart.objects.filter(supplier=supplier)
|
||||||
|
|
||||||
if not part and not supplier_part and not manufacturer_part:
|
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)):
|
if part and (part_id := part.get('pk', None)):
|
||||||
supplier_parts = supplier_parts.filter(part__pk=part_id)
|
supplier_parts = supplier_parts.filter(part__pk=part_id)
|
||||||
@ -349,12 +431,10 @@ class BarcodePOAllocate(BarcodeView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if supplier_parts.count() == 0:
|
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:
|
if supplier_parts.count() > 1:
|
||||||
raise ValidationError({
|
raise ValidationError(_('Multiple matching supplier parts found'))
|
||||||
'error': _('Multiple matching supplier parts found')
|
|
||||||
})
|
|
||||||
|
|
||||||
# At this stage, we have a single matching supplier part
|
# At this stage, we have a single matching supplier part
|
||||||
return supplier_parts.first()
|
return supplier_parts.first()
|
||||||
@ -364,25 +444,32 @@ class BarcodePOAllocate(BarcodeView):
|
|||||||
# The purchase order is provided as part of the request
|
# The purchase order is provided as part of the request
|
||||||
purchase_order = kwargs.get('purchase_order')
|
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:
|
if response['plugin'] is None:
|
||||||
result['error'] = _('No match found for barcode data')
|
response['error'] = _('No matching plugin found for barcode data')
|
||||||
raise ValidationError(result)
|
|
||||||
|
|
||||||
|
else:
|
||||||
|
try:
|
||||||
supplier_part = self.get_supplier_part(
|
supplier_part = self.get_supplier_part(
|
||||||
purchase_order,
|
purchase_order,
|
||||||
part=result.get('part', None),
|
part=response.get('part', None),
|
||||||
supplier_part=result.get('supplierpart', None),
|
supplier_part=response.get('supplierpart', None),
|
||||||
manufacturer_part=result.get('manufacturerpart', None),
|
manufacturer_part=response.get('manufacturerpart', None),
|
||||||
)
|
)
|
||||||
|
response['success'] = _('Matched supplier part')
|
||||||
result['success'] = _('Matched supplier part')
|
response['supplierpart'] = supplier_part.format_matched_response()
|
||||||
result['supplierpart'] = supplier_part.format_matched_response()
|
except ValidationError as e:
|
||||||
|
response['error'] = str(e)
|
||||||
|
|
||||||
# TODO: Determine the 'quantity to order' for the supplier part
|
# 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):
|
class BarcodePOReceive(BarcodeView):
|
||||||
@ -427,6 +514,7 @@ class BarcodePOReceive(BarcodeView):
|
|||||||
if result := internal_barcode_plugin.scan(barcode):
|
if result := internal_barcode_plugin.scan(barcode):
|
||||||
if 'stockitem' in result:
|
if 'stockitem' in result:
|
||||||
response['error'] = _('Item has already been received')
|
response['error'] = _('Item has already been received')
|
||||||
|
self.log_scan(request, response, False)
|
||||||
raise ValidationError(response)
|
raise ValidationError(response)
|
||||||
|
|
||||||
# Now, look just for "supplier-barcode" plugins
|
# Now, look just for "supplier-barcode" plugins
|
||||||
@ -464,10 +552,12 @@ class BarcodePOReceive(BarcodeView):
|
|||||||
# A plugin has not been found!
|
# A plugin has not been found!
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
response['error'] = _('No match for supplier barcode')
|
response['error'] = _('No match for supplier barcode')
|
||||||
|
|
||||||
|
self.log_scan(request, response, 'success' in response)
|
||||||
|
|
||||||
|
if 'error' in response:
|
||||||
raise ValidationError(response)
|
raise ValidationError(response)
|
||||||
elif 'error' in response:
|
|
||||||
raise ValidationError(response)
|
|
||||||
else:
|
|
||||||
return Response(response)
|
return Response(response)
|
||||||
|
|
||||||
|
|
||||||
@ -489,7 +579,11 @@ class BarcodeSOAllocate(BarcodeView):
|
|||||||
serializer_class = barcode_serializers.BarcodeSOAllocateSerializer
|
serializer_class = barcode_serializers.BarcodeSOAllocateSerializer
|
||||||
|
|
||||||
def get_line_item(self, stock_item, **kwargs):
|
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)
|
# Extract sales order object (required field)
|
||||||
sales_order = kwargs['sales_order']
|
sales_order = kwargs['sales_order']
|
||||||
|
|
||||||
@ -506,22 +600,24 @@ class BarcodeSOAllocate(BarcodeView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if lines.count() > 1:
|
if lines.count() > 1:
|
||||||
raise ValidationError({'error': _('Multiple matching line items found')})
|
raise ValidationError(_('Multiple matching line items found'))
|
||||||
|
|
||||||
if lines.count() == 0:
|
if lines.count() == 0:
|
||||||
raise ValidationError({'error': _('No matching line item found')})
|
raise ValidationError(_('No matching line item found'))
|
||||||
|
|
||||||
return lines.first()
|
return lines.first()
|
||||||
|
|
||||||
def get_shipment(self, **kwargs):
|
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']
|
sales_order = kwargs['sales_order']
|
||||||
|
|
||||||
if shipment := kwargs.get('shipment', None):
|
if shipment := kwargs.get('shipment', None):
|
||||||
if shipment.order != sales_order:
|
if shipment.order != sales_order:
|
||||||
raise ValidationError({
|
raise ValidationError(_('Shipment does not match sales order'))
|
||||||
'error': _('Shipment does not match sales order')
|
|
||||||
})
|
|
||||||
|
|
||||||
return shipment
|
return shipment
|
||||||
|
|
||||||
@ -536,37 +632,55 @@ class BarcodeSOAllocate(BarcodeView):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def handle_barcode(self, barcode: str, request, **kwargs):
|
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)
|
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)
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
stock_item_id = result['stockitem'].get('pk', None)
|
stock_item_id = response['stockitem'].get('pk', None)
|
||||||
stock_item = stock.models.StockItem.objects.get(pk=stock_item_id)
|
stock_item = stock.models.StockItem.objects.get(pk=stock_item_id)
|
||||||
except (ValueError, stock.models.StockItem.DoesNotExist):
|
except Exception:
|
||||||
result['error'] = _('Barcode does not match an existing stock item')
|
response['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
|
# 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)
|
|
||||||
|
|
||||||
|
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 is not None and line_item is not None:
|
||||||
if stock_item.part != line_item.part:
|
if stock_item.part != line_item.part:
|
||||||
result['error'] = _('Stock item does not match line item')
|
response['error'] = _('Stock item does not match line item')
|
||||||
raise ValidationError(result)
|
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)
|
quantity = kwargs.get('quantity', None)
|
||||||
|
|
||||||
@ -574,11 +688,12 @@ class BarcodeSOAllocate(BarcodeView):
|
|||||||
if stock_item.serialized:
|
if stock_item.serialized:
|
||||||
quantity = 1
|
quantity = 1
|
||||||
|
|
||||||
if quantity is None:
|
elif quantity is None:
|
||||||
quantity = line_item.quantity - line_item.shipped
|
quantity = line_item.quantity - line_item.shipped
|
||||||
quantity = min(quantity, stock_item.unallocated_quantity())
|
quantity = min(quantity, stock_item.unallocated_quantity())
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
|
**response,
|
||||||
'stock_item': stock_item.pk if stock_item else None,
|
'stock_item': stock_item.pk if stock_item else None,
|
||||||
'part': stock_item.part.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,
|
'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 is not None and quantity is not None:
|
||||||
if stock_item.unallocated_quantity() < quantity:
|
if stock_item.unallocated_quantity() < quantity:
|
||||||
response['error'] = _('Insufficient stock available')
|
response['error'] = _('Insufficient stock available')
|
||||||
raise ValidationError(response)
|
|
||||||
|
|
||||||
# If we have sufficient information, we can allocate the stock item
|
# 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]):
|
elif all(
|
||||||
|
x is not None for x in [line_item, sales_order, shipment, quantity]
|
||||||
|
):
|
||||||
order.models.SalesOrderAllocation.objects.create(
|
order.models.SalesOrderAllocation.objects.create(
|
||||||
line=line_item, shipment=shipment, item=stock_item, quantity=quantity
|
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')
|
||||||
|
|
||||||
return Response(response)
|
else:
|
||||||
|
|
||||||
response['error'] = _('Not enough information')
|
response['error'] = _('Not enough information')
|
||||||
response['action_required'] = True
|
response['action_required'] = True
|
||||||
|
|
||||||
|
self.log_scan(request, response, 'success' in response)
|
||||||
|
|
||||||
|
if 'error' in response:
|
||||||
raise ValidationError(response)
|
raise ValidationError(response)
|
||||||
|
else:
|
||||||
|
return Response(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_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
|
# Generate a barcode for a database object
|
||||||
path('generate/', BarcodeGenerate.as_view(), name='api-barcode-generate'),
|
path('generate/', BarcodeGenerate.as_view(), name='api-barcode-generate'),
|
||||||
# Link a third-party barcode to an item (e.g. Part / StockItem / etc)
|
# 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
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
import common.models
|
||||||
import order.models
|
import order.models
|
||||||
import plugin.base.barcodes.helper
|
import plugin.base.barcodes.helper
|
||||||
import stock.models
|
import stock.models
|
||||||
|
from InvenTree.serializers import UserSerializer
|
||||||
from order.status_codes import PurchaseOrderStatus, SalesOrderStatus
|
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):
|
class BarcodeSerializer(serializers.Serializer):
|
||||||
"""Generic serializer for receiving barcode data."""
|
"""Generic serializer for receiving barcode data."""
|
||||||
|
|
||||||
|
@ -4,6 +4,8 @@ from django.urls import reverse
|
|||||||
|
|
||||||
import company.models
|
import company.models
|
||||||
import order.models
|
import order.models
|
||||||
|
from common.models import BarcodeScanResult
|
||||||
|
from common.settings import set_global_setting
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
@ -89,6 +91,11 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
|||||||
"""Test that we can lookup a stock item based on ID."""
|
"""Test that we can lookup a stock item based on ID."""
|
||||||
item = StockItem.objects.first()
|
item = StockItem.objects.first()
|
||||||
|
|
||||||
|
# Save barcode scan results to database
|
||||||
|
set_global_setting('BARCODE_STORE_RESULTS', True)
|
||||||
|
|
||||||
|
n = BarcodeScanResult.objects.count()
|
||||||
|
|
||||||
response = self.post(
|
response = self.post(
|
||||||
self.scan_url, {'barcode': item.format_barcode()}, expected_code=200
|
self.scan_url, {'barcode': item.format_barcode()}, expected_code=200
|
||||||
)
|
)
|
||||||
@ -97,6 +104,20 @@ class BarcodeAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertIn('barcode_data', response.data)
|
self.assertIn('barcode_data', response.data)
|
||||||
self.assertEqual(response.data['stockitem']['pk'], item.pk)
|
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):
|
def test_invalid_item(self):
|
||||||
"""Test response for invalid stock item."""
|
"""Test response for invalid stock item."""
|
||||||
response = self.post(
|
response = self.post(
|
||||||
@ -309,7 +330,7 @@ class SOAllocateTest(InvenTreeAPITestCase):
|
|||||||
'123456789', sales_order=self.sales_order.pk, expected_code=400
|
'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
|
# Test with a barcode that matches a *different* stock item
|
||||||
item = StockItem.objects.exclude(pk=self.stock_item.pk).first()
|
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_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_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_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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
@ -241,6 +241,7 @@ class RuleSet(models.Model):
|
|||||||
'plugin_pluginconfig',
|
'plugin_pluginconfig',
|
||||||
'plugin_pluginsetting',
|
'plugin_pluginsetting',
|
||||||
'plugin_notificationusersetting',
|
'plugin_notificationusersetting',
|
||||||
|
'common_barcodescanresult',
|
||||||
'common_newsfeedentry',
|
'common_newsfeedentry',
|
||||||
'taggit_tag',
|
'taggit_tag',
|
||||||
'taggit_taggeditem',
|
'taggit_taggeditem',
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Button,
|
Button,
|
||||||
CopyButton as MantineCopyButton,
|
CopyButton as MantineCopyButton,
|
||||||
|
MantineSize,
|
||||||
Text,
|
Text,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
@ -11,10 +12,14 @@ import { InvenTreeIcon } from '../../functions/icons';
|
|||||||
|
|
||||||
export function CopyButton({
|
export function CopyButton({
|
||||||
value,
|
value,
|
||||||
label
|
label,
|
||||||
|
content,
|
||||||
|
size
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
value: any;
|
value: any;
|
||||||
label?: JSX.Element;
|
label?: string;
|
||||||
|
content?: JSX.Element;
|
||||||
|
size?: MantineSize;
|
||||||
}>) {
|
}>) {
|
||||||
const ButtonComponent = label ? Button : ActionIcon;
|
const ButtonComponent = label ? Button : ActionIcon;
|
||||||
|
|
||||||
@ -26,15 +31,19 @@ export function CopyButton({
|
|||||||
color={copied ? 'teal' : 'gray'}
|
color={copied ? 'teal' : 'gray'}
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
variant="transparent"
|
variant="transparent"
|
||||||
size="sm"
|
size={size ?? 'sm'}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<InvenTreeIcon icon="check" />
|
<InvenTreeIcon icon="check" />
|
||||||
) : (
|
) : (
|
||||||
<InvenTreeIcon icon="copy" />
|
<InvenTreeIcon icon="copy" />
|
||||||
)}
|
)}
|
||||||
|
{content}
|
||||||
{label && <Text ml={10}>{label}</Text>}
|
{label && (
|
||||||
|
<Text p={size ?? 'sm'} size={size ?? 'sm'}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</ButtonComponent>
|
</ButtonComponent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
@ -75,7 +75,7 @@ export const InvenTreeQRCode = ({
|
|||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['qr-code', mdl_prop.model, mdl_prop.pk],
|
queryKey: ['qr-code', mdl_prop.model, mdl_prop.pk],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await api.post(apiUrl(ApiEndpoints.generate_barcode), {
|
const res = await api.post(apiUrl(ApiEndpoints.barcode_generate), {
|
||||||
model: mdl_prop.model,
|
model: mdl_prop.model,
|
||||||
pk: mdl_prop.pk
|
pk: mdl_prop.pk
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Trans } from '@lingui/macro';
|
import { Trans, t } from '@lingui/macro';
|
||||||
import {
|
import {
|
||||||
Anchor,
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
@ -178,10 +178,7 @@ export function AboutInvenTreeModal({
|
|||||||
</Table>
|
</Table>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<CopyButton
|
<CopyButton value={copyval} label={t`Copy version information`} />
|
||||||
value={copyval}
|
|
||||||
label={<Trans>Copy version information</Trans>}
|
|
||||||
/>
|
|
||||||
<Space />
|
<Space />
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
|
@ -134,7 +134,7 @@ function BasePanelGroup({
|
|||||||
(panel) =>
|
(panel) =>
|
||||||
!panel.hidden && (
|
!panel.hidden && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={`tooltip-${panel.name}`}
|
label={panel.label ?? panel.name}
|
||||||
key={panel.name}
|
key={panel.name}
|
||||||
disabled={expanded}
|
disabled={expanded}
|
||||||
position="right"
|
position="right"
|
||||||
|
@ -39,10 +39,6 @@ export enum ApiEndpoints {
|
|||||||
api_search = 'search/',
|
api_search = 'search/',
|
||||||
settings_global_list = 'settings/global/',
|
settings_global_list = 'settings/global/',
|
||||||
settings_user_list = 'settings/user/',
|
settings_user_list = 'settings/user/',
|
||||||
barcode = 'barcode/',
|
|
||||||
barcode_link = 'barcode/link/',
|
|
||||||
barcode_unlink = 'barcode/unlink/',
|
|
||||||
generate_barcode = 'barcode/generate/',
|
|
||||||
news = 'news/',
|
news = 'news/',
|
||||||
global_status = 'generic/status/',
|
global_status = 'generic/status/',
|
||||||
custom_state_list = 'generic/status/custom/',
|
custom_state_list = 'generic/status/custom/',
|
||||||
@ -54,6 +50,13 @@ export enum ApiEndpoints {
|
|||||||
content_type_list = 'contenttype/',
|
content_type_list = 'contenttype/',
|
||||||
icons = 'icons/',
|
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
|
// Data import endpoints
|
||||||
import_session_list = 'importer/session/',
|
import_session_list = 'importer/session/',
|
||||||
import_session_accept_fields = 'importer/session/:id/accept_fields/',
|
import_session_accept_fields = 'importer/session/:id/accept_fields/',
|
||||||
|
@ -22,5 +22,5 @@ export function shortenString({
|
|||||||
// Otherwise, shorten it
|
// Otherwise, shorten it
|
||||||
let N = Math.floor(len / 2 - 1);
|
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,
|
IconListDetails,
|
||||||
IconPackages,
|
IconPackages,
|
||||||
IconPlugConnected,
|
IconPlugConnected,
|
||||||
|
IconQrcode,
|
||||||
IconReport,
|
IconReport,
|
||||||
IconScale,
|
IconScale,
|
||||||
IconSitemap,
|
IconSitemap,
|
||||||
@ -68,6 +69,10 @@ const ErrorReportTable = Loadable(
|
|||||||
lazy(() => import('../../../../tables/settings/ErrorTable'))
|
lazy(() => import('../../../../tables/settings/ErrorTable'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const BarcodeScanHistoryTable = Loadable(
|
||||||
|
lazy(() => import('../../../../tables/settings/BarcodeScanHistoryTable'))
|
||||||
|
);
|
||||||
|
|
||||||
const ImportSesssionTable = Loadable(
|
const ImportSesssionTable = Loadable(
|
||||||
lazy(() => import('../../../../tables/settings/ImportSessionTable'))
|
lazy(() => import('../../../../tables/settings/ImportSessionTable'))
|
||||||
);
|
);
|
||||||
@ -111,6 +116,12 @@ export default function AdminCenter() {
|
|||||||
icon: <IconFileUpload />,
|
icon: <IconFileUpload />,
|
||||||
content: <ImportSesssionTable />
|
content: <ImportSesssionTable />
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'barcode-history',
|
||||||
|
label: t`Barcode Scans`,
|
||||||
|
icon: <IconQrcode />,
|
||||||
|
content: <BarcodeScanHistoryTable />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'background',
|
name: 'background',
|
||||||
label: t`Background Tasks`,
|
label: t`Background Tasks`,
|
||||||
|
@ -98,7 +98,9 @@ export default function SystemSettings() {
|
|||||||
'BARCODE_INPUT_DELAY',
|
'BARCODE_INPUT_DELAY',
|
||||||
'BARCODE_WEBCAM_SUPPORT',
|
'BARCODE_WEBCAM_SUPPORT',
|
||||||
'BARCODE_SHOW_TEXT',
|
'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]);
|
}, [props]);
|
||||||
|
|
||||||
|
const enableSelection: boolean = useMemo(() => {
|
||||||
|
return tableProps.enableSelection || tableProps.enableBulkDelete || false;
|
||||||
|
}, [tableProps]);
|
||||||
|
|
||||||
// Check if any columns are switchable (can be hidden)
|
// Check if any columns are switchable (can be hidden)
|
||||||
const hasSwitchableColumns: boolean = useMemo(() => {
|
const hasSwitchableColumns: boolean = useMemo(() => {
|
||||||
if (props.enableColumnSwitching == false) {
|
if (props.enableColumnSwitching == false) {
|
||||||
@ -309,7 +313,6 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
columns,
|
columns,
|
||||||
fieldNames,
|
fieldNames,
|
||||||
tableProps.rowActions,
|
tableProps.rowActions,
|
||||||
tableProps.enableSelection,
|
|
||||||
tableState.hiddenColumns,
|
tableState.hiddenColumns,
|
||||||
tableState.selectedRecords
|
tableState.selectedRecords
|
||||||
]);
|
]);
|
||||||
@ -641,7 +644,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
actions={tableProps.barcodeActions ?? []}
|
actions={tableProps.barcodeActions ?? []}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(tableProps.enableBulkDelete ?? false) && (
|
{tableProps.enableBulkDelete && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
disabled={!tableState.hasSelectedRecords}
|
disabled={!tableState.hasSelectedRecords}
|
||||||
icon={<IconTrash />}
|
icon={<IconTrash />}
|
||||||
@ -726,12 +729,10 @@ export function InvenTreeTable<T extends Record<string, any>>({
|
|||||||
sortStatus={sortStatus}
|
sortStatus={sortStatus}
|
||||||
onSortStatusChange={handleSortStatusChange}
|
onSortStatusChange={handleSortStatusChange}
|
||||||
selectedRecords={
|
selectedRecords={
|
||||||
tableProps.enableSelection
|
enableSelection ? tableState.selectedRecords : undefined
|
||||||
? tableState.selectedRecords
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
onSelectedRecordsChange={
|
onSelectedRecordsChange={
|
||||||
tableProps.enableSelection ? onSelectedRecordsChange : undefined
|
enableSelection ? onSelectedRecordsChange : undefined
|
||||||
}
|
}
|
||||||
rowExpansion={tableProps.rowExpansion}
|
rowExpansion={tableProps.rowExpansion}
|
||||||
rowStyle={tableProps.rowStyle}
|
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 { 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 { useDisclosure } from '@mantine/hooks';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { CopyButton } from '../../components/buttons/CopyButton';
|
||||||
import { StylishText } from '../../components/items/StylishText';
|
import { StylishText } from '../../components/items/StylishText';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
import { useDeleteApiFormModal } from '../../hooks/UseForm';
|
import { useDeleteApiFormModal } from '../../hooks/UseForm';
|
||||||
@ -13,6 +14,48 @@ import { TableColumn } from '../Column';
|
|||||||
import { InvenTreeTable } from '../InvenTreeTable';
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
import { RowAction, RowDeleteAction } from '../RowActions';
|
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
|
* Table for display server error information
|
||||||
*/
|
*/
|
||||||
@ -20,8 +63,6 @@ export default function ErrorReportTable() {
|
|||||||
const table = useTable('error-report');
|
const table = useTable('error-report');
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
|
||||||
const [error, setError] = useState<string>('');
|
|
||||||
|
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
const columns: TableColumn[] = useMemo(() => {
|
const columns: TableColumn[] = useMemo(() => {
|
||||||
@ -43,13 +84,11 @@ export default function ErrorReportTable() {
|
|||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [selectedError, setSelectedError] = useState<number | undefined>(
|
const [selectedError, setSelectedError] = useState<any>({});
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteErrorModal = useDeleteApiFormModal({
|
const deleteErrorModal = useDeleteApiFormModal({
|
||||||
url: ApiEndpoints.error_report_list,
|
url: ApiEndpoints.error_report_list,
|
||||||
pk: selectedError,
|
pk: selectedError.pk,
|
||||||
title: t`Delete Error Report`,
|
title: t`Delete Error Report`,
|
||||||
preFormContent: (
|
preFormContent: (
|
||||||
<Text c="red">{t`Are you sure you want to delete this error report?`}</Text>
|
<Text c="red">{t`Are you sure you want to delete this error report?`}</Text>
|
||||||
@ -62,7 +101,7 @@ export default function ErrorReportTable() {
|
|||||||
return [
|
return [
|
||||||
RowDeleteAction({
|
RowDeleteAction({
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setSelectedError(record.pk);
|
setSelectedError(record);
|
||||||
deleteErrorModal.open();
|
deleteErrorModal.open();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -79,13 +118,7 @@ export default function ErrorReportTable() {
|
|||||||
title={<StylishText>{t`Error Details`}</StylishText>}
|
title={<StylishText>{t`Error Details`}</StylishText>}
|
||||||
onClose={close}
|
onClose={close}
|
||||||
>
|
>
|
||||||
{error.split('\n').map((line: string) => {
|
<ErrorDetail error={selectedError} />
|
||||||
return (
|
|
||||||
<Text key={line} size="sm">
|
|
||||||
{line}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<InvenTreeTable
|
<InvenTreeTable
|
||||||
url={apiUrl(ApiEndpoints.error_report_list)}
|
url={apiUrl(ApiEndpoints.error_report_list)}
|
||||||
@ -96,7 +129,7 @@ export default function ErrorReportTable() {
|
|||||||
enableSelection: true,
|
enableSelection: true,
|
||||||
rowActions: rowActions,
|
rowActions: rowActions,
|
||||||
onRowClick: (row) => {
|
onRowClick: (row) => {
|
||||||
setError(row.data);
|
setSelectedError(row);
|
||||||
open();
|
open();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
export const classicUrl = 'http://127.0.0.1:8000';
|
export const classicUrl = 'http://127.0.0.1:8000';
|
||||||
|
|
||||||
|
export const apiUrl = `${classicUrl}/api`;
|
||||||
export const baseUrl = './platform';
|
export const baseUrl = './platform';
|
||||||
export const loginUrl = `${baseUrl}/login`;
|
export const loginUrl = `${baseUrl}/login`;
|
||||||
export const logoutUrl = `${baseUrl}/logout`;
|
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 { baseUrl } from './defaults.js';
|
||||||
import { doQuickLogin } from './login.js';
|
import { doQuickLogin } from './login.js';
|
||||||
|
import { setPluginState, setSettingState } from './settings.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);
|
|
||||||
};
|
|
||||||
|
|
||||||
test('Plugins - Panels', async ({ page, request }) => {
|
test('Plugins - Panels', async ({ page, request }) => {
|
||||||
await doQuickLogin(page, 'admin', 'inventree');
|
await doQuickLogin(page, 'admin', 'inventree');
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from './baseFixtures.js';
|
import { expect, test } from './baseFixtures.js';
|
||||||
import { baseUrl } from './defaults.js';
|
import { apiUrl, baseUrl } from './defaults.js';
|
||||||
import { doQuickLogin } from './login.js';
|
import { doQuickLogin } from './login.js';
|
||||||
|
import { setSettingState } from './settings.js';
|
||||||
|
|
||||||
test('PUI - Admin', async ({ page }) => {
|
test('PUI - Admin', async ({ page }) => {
|
||||||
// Note here we login with admin access
|
// Note here we login with admin access
|
||||||
@ -85,6 +86,43 @@ test('PUI - Admin', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Submit' }).click();
|
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 }) => {
|
test('PUI - Admin - Unauthorized', async ({ page }) => {
|
||||||
// Try to access "admin" page with a non-staff user
|
// Try to access "admin" page with a non-staff user
|
||||||
await doQuickLogin(page, 'allaccess', 'nolimits');
|
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);
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user