2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-11-05 23:55:42 +00:00

Add barcode generation capabilities to plugins (#7648)

* initial implementation of barcode generation using plugins

* implement short QR code scanning

* add PUI qrcode preview

* use barcode generation for CUI show barcode modal

* remove short qr prefix validators and fix short qr detection regex

* catch errors if model with pk is not found for scanning and generating

* improve qrcode templatetag

* fix comments

* fix for python 3.9

* add tests

* fix: tests

* add docs

* fix: tests

* bump api version

* add docs to BarcodeMixin

* fix: test

* added suggestions from code review

* fix: tests

* Add MinLengthValidator to short barcode prefix setting

* fix: tests?

* trigger: ci

* try custom cache

* try custom cache ignore all falsy

* remove debugging

* Revert "Add MinLengthValidator to short barcode prefix setting"

This reverts commit 76043ed96b.

* Revert "fix: tests"

This reverts commit 3a2d46ff72.
This commit is contained in:
Lukas
2024-07-22 03:52:45 +02:00
committed by GitHub
parent 0f6551d70f
commit 16e535f45f
48 changed files with 845 additions and 241 deletions

View File

@@ -1,12 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 225
INVENTREE_API_VERSION = 226
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v226 - 2024-07-15 : https://github.com/inventree/InvenTree/pull/7648
- Adds barcode generation API endpoint
v225 - 2024-07-17 : https://github.com/inventree/InvenTree/pull/7671
- Adds "filters" field to DataImportSession API

View File

@@ -396,38 +396,6 @@ def WrapWithQuotes(text, quote='"'):
return text
def MakeBarcode(cls_name, object_pk: int, object_data=None, **kwargs):
"""Generate a string for a barcode. Adds some global InvenTree parameters.
Args:
cls_name: string describing the object type e.g. 'StockItem'
object_pk (int): ID (Primary Key) of the object in the database
object_data: Python dict object containing extra data which will be rendered to string (must only contain stringable values)
Returns:
json string of the supplied data plus some other data
"""
if object_data is None:
object_data = {}
brief = kwargs.get('brief', True)
data = {}
if brief:
data[cls_name] = object_pk
else:
data['tool'] = 'InvenTree'
data['version'] = InvenTree.version.inventreeVersion()
data['instance'] = InvenTree.version.inventreeInstanceName()
# Ensure PK is included
object_data['id'] = object_pk
data[cls_name] = object_data
return str(json.dumps(data, sort_keys=True))
def GetExportFormats():
"""Return a list of allowable file formats for importing or exporting tabular data."""
return ['csv', 'xlsx', 'tsv', 'json']

View File

@@ -15,9 +15,6 @@ from djmoney.contrib.exchange.models import convert_money
from djmoney.money import Money
from PIL import Image
import InvenTree
import InvenTree.helpers_model
import InvenTree.version
from common.notifications import (
InvenTreeNotificationBodies,
NotificationBody,
@@ -331,9 +328,7 @@ def notify_users(
'instance': instance,
'name': content.name.format(**content_context),
'message': content.message.format(**content_context),
'link': InvenTree.helpers_model.construct_absolute_url(
instance.get_absolute_url()
),
'link': construct_absolute_url(instance.get_absolute_url()),
'template': {'subject': content.name.format(**content_context)},
}

View File

@@ -3,9 +3,7 @@
import logging
from datetime import datetime
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
@@ -934,6 +932,8 @@ class InvenTreeBarcodeMixin(models.Model):
- barcode_data : Raw data associated with an assigned barcode
- barcode_hash : A 'hash' of the assigned barcode data used to improve matching
The barcode_model_type_code() classmethod must be implemented in the model class.
"""
class Meta:
@@ -964,11 +964,25 @@ class InvenTreeBarcodeMixin(models.Model):
# By default, use the name of the class
return cls.__name__.lower()
@classmethod
def barcode_model_type_code(cls):
r"""Return a 'short' code for the model type.
This is used to generate a efficient QR code for the model type.
It is expected to match this pattern: [0-9A-Z $%*+-.\/:]{2}
Note: Due to the shape constrains (45**2=2025 different allowed codes)
this needs to be explicitly implemented in the model class to avoid collisions.
"""
raise NotImplementedError(
'barcode_model_type_code() must be implemented in the model class'
)
def format_barcode(self, **kwargs):
"""Return a JSON string for formatting a QR code for this model instance."""
return InvenTree.helpers.MakeBarcode(
self.__class__.barcode_model_type(), self.pk, **kwargs
)
from plugin.base.barcodes.helper import generate_barcode
return generate_barcode(self)
def format_matched_response(self):
"""Format a standard response for a matched barcode."""
@@ -986,7 +1000,7 @@ class InvenTreeBarcodeMixin(models.Model):
@property
def barcode(self):
"""Format a minimal barcode string (e.g. for label printing)."""
return self.format_barcode(brief=True)
return self.format_barcode()
@classmethod
def lookup_barcode(cls, barcode_hash):

View File

@@ -789,33 +789,6 @@ class TestIncrement(TestCase):
self.assertEqual(result, b)
class TestMakeBarcode(TestCase):
"""Tests for barcode string creation."""
def test_barcode_extended(self):
"""Test creation of barcode with extended data."""
bc = helpers.MakeBarcode(
'part', 3, {'id': 3, 'url': 'www.google.com'}, brief=False
)
self.assertIn('part', bc)
self.assertIn('tool', bc)
self.assertIn('"tool": "InvenTree"', bc)
data = json.loads(bc)
self.assertEqual(data['part']['id'], 3)
self.assertEqual(data['part']['url'], 'www.google.com')
def test_barcode_brief(self):
"""Test creation of simple barcode."""
bc = helpers.MakeBarcode('stockitem', 7)
data = json.loads(bc)
self.assertEqual(len(data), 1)
self.assertEqual(data['stockitem'], 7)
class TestDownloadFile(TestCase):
"""Tests for DownloadFile."""

View File

@@ -115,6 +115,11 @@ class Build(
return defaults
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return "BO"
def save(self, *args, **kwargs):
"""Custom save method for the BuildOrder model"""
self.validate_reference_field(self.reference)

View File

@@ -277,7 +277,7 @@ src="{% static 'img/blank_image.png' %}"
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Build Order QR Code" escape %}',
'{"build": {{ build.pk }} }'
'{{ build.barcode }}'
);
});

View File

@@ -9,12 +9,13 @@ import hmac
import json
import logging
import os
import sys
import uuid
from datetime import timedelta, timezone
from enum import Enum
from io import BytesIO
from secrets import compare_digest
from typing import Any, Callable, Collection, TypedDict, Union
from typing import Any, Callable, TypedDict, Union
from django.apps import apps
from django.conf import settings as django_settings
@@ -49,6 +50,7 @@ import InvenTree.ready
import InvenTree.tasks
import InvenTree.validators
import order.validators
import plugin.base.barcodes.helper
import report.helpers
import users.models
from InvenTree.sanitizer import sanitize_svg
@@ -56,6 +58,17 @@ from plugin import registry
logger = logging.getLogger('inventree')
if sys.version_info >= (3, 11):
from typing import NotRequired
else:
class NotRequired: # pragma: no cover
"""NotRequired type helper is only supported with Python 3.11+."""
def __class_getitem__(cls, item):
"""Return the item."""
return item
class MetaMixin(models.Model):
"""A base class for InvenTree models to include shared meta fields.
@@ -1167,7 +1180,7 @@ class InvenTreeSettingsKeyType(SettingsKeyType):
requires_restart: If True, a server restart is required after changing the setting
"""
requires_restart: bool
requires_restart: NotRequired[bool]
class InvenTreeSetting(BaseInvenTreeSetting):
@@ -1402,6 +1415,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'BARCODE_GENERATION_PLUGIN': {
'name': _('Barcode Generation Plugin'),
'description': _('Plugin to use for internal barcode data generation'),
'choices': plugin.base.barcodes.helper.barcode_plugins,
'default': 'inventreebarcode',
},
'PART_ENABLE_REVISION': {
'name': _('Part Revisions'),
'description': _('Enable revision field for Part'),

View File

@@ -475,6 +475,11 @@ class ManufacturerPart(
"""Return the API URL associated with the ManufacturerPart instance."""
return reverse('api-manufacturer-part-list')
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'MP'
part = models.ForeignKey(
'part.Part',
on_delete=models.CASCADE,
@@ -678,6 +683,11 @@ class SupplierPart(
"""Return custom API filters for this particular instance."""
return {'manufacturer_part': {'part': self.part.pk}}
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'SP'
def clean(self):
"""Custom clean action for the SupplierPart model.

View File

@@ -303,7 +303,7 @@ onPanelLoad('supplier-part-notes', function() {
$("#show-qr-code").click(function() {
showQRDialog(
'{% trans "Supplier Part QR Code" escape %}',
'{"supplierpart": {{ part.pk }} }'
'{{ part.barcode }}'
);
});

View File

@@ -408,6 +408,11 @@ class PurchaseOrder(TotalPriceMixin, Order):
return defaults
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'PO'
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""Filter by 'minimum and maximum date range'.
@@ -880,6 +885,11 @@ class SalesOrder(TotalPriceMixin, Order):
return defaults
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'SO'
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""Filter by "minimum and maximum date range".
@@ -2044,6 +2054,11 @@ class ReturnOrder(TotalPriceMixin, Order):
return defaults
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'RO'
def __str__(self):
"""Render a string representation of this ReturnOrder."""
return f"{self.reference} - {self.customer.name if self.customer else _('no customer')}"

View File

@@ -312,7 +312,7 @@ $("#export-order").click(function() {
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Purchase Order QR Code" escape %}',
'{"purchaseorder": {{ order.pk }} }'
'{{ order.barcode }}'
);
});

View File

@@ -257,7 +257,7 @@ $('#print-order-report').click(function() {
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Return Order QR Code" escape %}',
'{"returnorder": {{ order.pk }} }'
'{{ order.barcode }}'
);
});

View File

@@ -319,7 +319,7 @@ $('#print-order-report').click(function() {
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Sales Order QR Code" escape %}',
'{"salesorder": {{ order.pk }} }'
'{{ order.barcode }}'
);
});

View File

@@ -416,6 +416,11 @@ class Part(
"""Return API query filters for limiting field results against this instance."""
return {'variant_of': {'exclude_tree': self.pk}}
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'PA'
def report_context(self):
"""Return custom report context information."""
return {
@@ -426,11 +431,11 @@ class Part(
'name': self.name,
'parameters': self.parameters_map(),
'part': self,
'qr_data': self.format_barcode(brief=True),
'qr_data': self.barcode,
'qr_url': self.get_absolute_url(),
'revision': self.revision,
'test_template_list': self.getTestTemplates(),
'test_templates': self.getTestTemplates(),
'test_templates': self.getTestTemplateMap(),
}
def get_context_data(self, request, **kwargs):

View File

@@ -451,7 +451,7 @@
$("#show-qr-code").click(function() {
showQRDialog(
'{% trans "Part QR Code" escape %}',
'{"part": {{ part.pk }} }',
'{{ part.barcode }}',
);
});

View File

@@ -169,7 +169,7 @@ class PartTest(TestCase):
self.assertEqual(Part.barcode_model_type(), 'part')
p = Part.objects.get(pk=1)
barcode = p.format_barcode(brief=True)
barcode = p.format_barcode()
self.assertEqual(barcode, '{"part": 1}')
def test_tree(self):
@@ -270,9 +270,8 @@ class PartTest(TestCase):
def test_barcode(self):
"""Test barcode format functionality."""
barcode = self.r1.format_barcode(brief=False)
self.assertIn('InvenTree', barcode)
self.assertIn('"part": {"id": 3}', barcode)
barcode = self.r1.format_barcode()
self.assertEqual('{"part": 3}', barcode)
def test_sell_pricing(self):
"""Check that the sell pricebreaks were loaded."""

View File

@@ -6,16 +6,17 @@ from django.db.models import F
from django.urls import path
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions
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 order.models
import plugin.base.barcodes.helper
import stock.models
from InvenTree.helpers import hash_barcode
from plugin import registry
from plugin.builtin.barcodes.inventree_barcode import InvenTreeInternalBarcodePlugin
from users.models import RuleSet
from . import serializers as barcode_serializers
@@ -129,6 +130,48 @@ class BarcodeScan(BarcodeView):
return Response(result)
@extend_schema_view(
post=extend_schema(responses={200: barcode_serializers.BarcodeSerializer})
)
class BarcodeGenerate(CreateAPIView):
"""Endpoint for generating a barcode for a database object.
The barcode is generated by the selected barcode plugin.
"""
serializer_class = barcode_serializers.BarcodeGenerateSerializer
def queryset(self):
"""This API view does not have a queryset."""
return None
# Default permission classes (can be overridden)
permission_classes = [permissions.IsAuthenticated]
def create(self, request, *args, **kwargs):
"""Perform the barcode generation action."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
model = serializer.validated_data.get('model')
pk = serializer.validated_data.get('pk')
model_cls = plugin.base.barcodes.helper.get_supported_barcode_models_map().get(
model, None
)
if model_cls is None:
raise ValidationError({'error': _('Model is not supported')})
try:
model_instance = model_cls.objects.get(pk=pk)
except model_cls.DoesNotExist:
raise ValidationError({'error': _('Model instance not found')})
barcode_data = plugin.base.barcodes.helper.generate_barcode(model_instance)
return Response({'barcode': barcode_data}, status=status.HTTP_200_OK)
class BarcodeAssign(BarcodeView):
"""Endpoint for assigning a barcode to a stock item.
@@ -161,7 +204,7 @@ class BarcodeAssign(BarcodeView):
valid_labels = []
for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models():
for model in plugin.base.barcodes.helper.get_supported_barcode_models():
label = model.barcode_model_type()
valid_labels.append(label)
@@ -203,7 +246,7 @@ class BarcodeUnassign(BarcodeView):
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
supported_models = InvenTreeInternalBarcodePlugin.get_supported_barcode_models()
supported_models = plugin.base.barcodes.helper.get_supported_barcode_models()
supported_labels = [model.barcode_model_type() for model in supported_models]
model_names = ', '.join(supported_labels)
@@ -567,6 +610,8 @@ class BarcodeSOAllocate(BarcodeView):
barcode_api_urls = [
# 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)
path('link/', BarcodeAssign.as_view(), name='api-barcode-link'),
# Unlink a third-party barcode from an item

View File

@@ -0,0 +1,79 @@
"""Helper functions for barcode generation."""
import logging
from typing import Type, cast
import InvenTree.helpers_model
from InvenTree.models import InvenTreeBarcodeMixin
logger = logging.getLogger('inventree')
def cache(func):
"""Cache the result of a function, but do not cache falsy results."""
cache = {}
def wrapper():
"""Wrapper function for caching."""
if 'default' not in cache:
res = func()
if res:
cache['default'] = res
return res
return cache['default']
return wrapper
def barcode_plugins() -> list:
"""Return a list of plugin choices which can be used for barcode generation."""
try:
from plugin import registry
plugins = registry.with_mixin('barcode', active=True)
except Exception:
plugins = []
return [
(plug.slug, plug.human_name) for plug in plugins if plug.has_barcode_generation
]
def generate_barcode(model_instance: InvenTreeBarcodeMixin):
"""Generate a barcode for a given model instance."""
from common.settings import get_global_setting
from plugin import registry
from plugin.mixins import BarcodeMixin
# Find the selected barcode generation plugin
slug = get_global_setting('BARCODE_GENERATION_PLUGIN', create=False)
plugin = cast(BarcodeMixin, registry.get_plugin(slug))
return plugin.generate(model_instance)
@cache
def get_supported_barcode_models() -> list[Type[InvenTreeBarcodeMixin]]:
"""Returns a list of database models which support barcode functionality."""
return InvenTree.helpers_model.getModelsWithMixin(InvenTreeBarcodeMixin)
@cache
def get_supported_barcode_models_map():
"""Return a mapping of barcode model types to the model class."""
return {
model.barcode_model_type(): model for model in get_supported_barcode_models()
}
@cache
def get_supported_barcode_model_codes_map():
"""Return a mapping of barcode model type codes to the model class."""
return {
model.barcode_model_type_code(): model
for model in get_supported_barcode_models()
}

View File

@@ -10,6 +10,7 @@ from django.db.models import F, Q
from django.utils.translation import gettext_lazy as _
from company.models import Company, SupplierPart
from InvenTree.models import InvenTreeBarcodeMixin
from order.models import PurchaseOrder, PurchaseOrderStatus
from plugin.base.integration.SettingsMixin import SettingsMixin
from stock.models import StockLocation
@@ -53,6 +54,30 @@ class BarcodeMixin:
"""
return None
@property
def has_barcode_generation(self):
"""Does this plugin support barcode generation."""
try:
# Attempt to call the generate method
self.generate(None) # type: ignore
except NotImplementedError:
# If a NotImplementedError is raised, then barcode generation is not supported
return False
except:
pass
return True
def generate(self, model_instance: InvenTreeBarcodeMixin):
"""Generate barcode data for the given model instance.
Arguments:
model_instance: The model instance to generate barcode data for. It is extending the InvenTreeBarcodeMixin.
Returns: The generated barcode data.
"""
raise NotImplementedError('Generate must be implemented by a plugin')
class SupplierBarcodeMixin(BarcodeMixin):
"""Mixin that provides default implementations for scan functions for supplier barcodes.

View File

@@ -6,9 +6,9 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
import order.models
import plugin.base.barcodes.helper
import stock.models
from order.status_codes import PurchaseOrderStatus, SalesOrderStatus
from plugin.builtin.barcodes.inventree_barcode import InvenTreeInternalBarcodePlugin
class BarcodeSerializer(serializers.Serializer):
@@ -23,6 +23,30 @@ class BarcodeSerializer(serializers.Serializer):
)
class BarcodeGenerateSerializer(serializers.Serializer):
"""Serializer for generating a barcode."""
model = serializers.CharField(
required=True, help_text=_('Model name to generate barcode for')
)
pk = serializers.IntegerField(
required=True,
help_text=_('Primary key of model object to generate barcode for'),
)
def validate_model(self, model: str):
"""Validate the provided model."""
supported_models = (
plugin.base.barcodes.helper.get_supported_barcode_models_map()
)
if model not in supported_models.keys():
raise ValidationError(_('Model is not supported'))
return model
class BarcodeAssignMixin(serializers.Serializer):
"""Serializer for linking and unlinking barcode to an internal class."""
@@ -30,7 +54,7 @@ class BarcodeAssignMixin(serializers.Serializer):
"""Generate serializer fields for each supported model type."""
super().__init__(*args, **kwargs)
for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models():
for model in plugin.base.barcodes.helper.get_supported_barcode_models():
self.fields[model.barcode_model_type()] = (
serializers.PrimaryKeyRelatedField(
queryset=model.objects.all(),
@@ -45,7 +69,7 @@ class BarcodeAssignMixin(serializers.Serializer):
"""Return a list of model fields."""
fields = [
model.barcode_model_type()
for model in InvenTreeInternalBarcodePlugin.get_supported_barcode_models()
for model in plugin.base.barcodes.helper.get_supported_barcode_models()
]
return fields

View File

@@ -21,6 +21,7 @@ class BarcodeAPITest(InvenTreeAPITestCase):
super().setUp()
self.scan_url = reverse('api-barcode-scan')
self.generate_url = reverse('api-barcode-generate')
self.assign_url = reverse('api-barcode-link')
self.unassign_url = reverse('api-barcode-unlink')
@@ -30,6 +31,14 @@ class BarcodeAPITest(InvenTreeAPITestCase):
url, data={'barcode': str(barcode)}, expected_code=expected_code
)
def generateBarcode(self, model: str, pk: int, expected_code: int):
"""Post barcode generation and return barcode contents."""
return self.post(
self.generate_url,
data={'model': model, 'pk': pk},
expected_code=expected_code,
)
def test_invalid(self):
"""Test that invalid requests fail."""
# test scan url
@@ -130,7 +139,7 @@ class BarcodeAPITest(InvenTreeAPITestCase):
data = response.data
self.assertIn('error', data)
def test_barcode_generation(self):
def test_barcode_scan(self):
"""Test that a barcode is generated with a scan."""
item = StockItem.objects.get(pk=522)
@@ -145,6 +154,18 @@ class BarcodeAPITest(InvenTreeAPITestCase):
self.assertEqual(pk, item.pk)
def test_barcode_generation(self):
"""Test that a barcode can be generated for a StockItem."""
item = StockItem.objects.get(pk=522)
data = self.generateBarcode('stockitem', item.pk, expected_code=200).data
self.assertEqual(data['barcode'], '{"stockitem": 522}')
def test_barcode_generation_invalid(self):
"""Test barcode generation for invalid model/pk."""
self.generateBarcode('invalidmodel', 1, expected_code=400)
self.generateBarcode('stockitem', 99999999, expected_code=400)
def test_association(self):
"""Test that a barcode can be associated with a StockItem."""
item = StockItem.objects.get(pk=522)

View File

@@ -8,29 +8,45 @@ references model objects actually exist in the database.
"""
import json
import re
from typing import cast
from django.utils.translation import gettext_lazy as _
import plugin.base.barcodes.helper
from InvenTree.helpers import hash_barcode
from InvenTree.helpers_model import getModelsWithMixin
from InvenTree.models import InvenTreeBarcodeMixin
from plugin import InvenTreePlugin
from plugin.mixins import BarcodeMixin
from plugin.mixins import BarcodeMixin, SettingsMixin
class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
class InvenTreeInternalBarcodePlugin(SettingsMixin, BarcodeMixin, InvenTreePlugin):
"""Builtin BarcodePlugin for matching and generating internal barcodes."""
NAME = 'InvenTreeBarcode'
TITLE = _('InvenTree Barcodes')
DESCRIPTION = _('Provides native support for barcodes')
VERSION = '2.0.0'
VERSION = '2.1.0'
AUTHOR = _('InvenTree contributors')
@staticmethod
def get_supported_barcode_models():
"""Returns a list of database models which support barcode functionality."""
return getModelsWithMixin(InvenTreeBarcodeMixin)
SETTINGS = {
'INTERNAL_BARCODE_FORMAT': {
'name': _('Internal Barcode Format'),
'description': _('Select an internal barcode format'),
'choices': [
('json', _('JSON barcodes (human readable)')),
('short', _('Short barcodes (space optimized)')),
],
'default': 'json',
},
'SHORT_BARCODE_PREFIX': {
'name': _('Short Barcode Prefix'),
'description': _(
'Customize the prefix used for short barcodes, may be useful for environments with multiple InvenTree instances'
),
'default': 'INV-',
},
}
def format_matched_response(self, label, model, instance):
"""Format a response for the scanned data."""
@@ -41,8 +57,35 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
Here we are looking for a dict object which contains a reference to a particular InvenTree database object
"""
# Internal Barcodes - Short Format
# Attempt to match the barcode data against the short barcode format
prefix = cast(str, self.get_setting('SHORT_BARCODE_PREFIX'))
if type(barcode_data) is str and (
m := re.match(
f'^{re.escape(prefix)}([0-9A-Z $%*+-.\\/:]{"{2}"})(\\d+)$', barcode_data
)
):
model_type_code, pk = m.groups()
supported_models_map = (
plugin.base.barcodes.helper.get_supported_barcode_model_codes_map()
)
model = supported_models_map.get(model_type_code, None)
if model is None:
return None
label = model.barcode_model_type()
try:
instance = model.objects.get(pk=int(pk))
return self.format_matched_response(label, model, instance)
except (ValueError, model.DoesNotExist):
pass
# Internal Barcodes - JSON Format
# Attempt to coerce the barcode data into a dict object
# This is the internal barcode representation that InvenTree uses
# This is the internal JSON barcode representation that InvenTree uses
barcode_dict = None
if type(barcode_data) is dict:
@@ -53,7 +96,7 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
except json.JSONDecodeError:
pass
supported_models = self.get_supported_barcode_models()
supported_models = plugin.base.barcodes.helper.get_supported_barcode_models()
if barcode_dict is not None and type(barcode_dict) is dict:
# Look for various matches. First good match will be returned
@@ -68,6 +111,7 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
except (ValueError, model.DoesNotExist):
pass
# External Barcodes (Linked barcodes)
# Create hash from raw barcode data
barcode_hash = hash_barcode(barcode_data)
@@ -79,3 +123,18 @@ class InvenTreeInternalBarcodePlugin(BarcodeMixin, InvenTreePlugin):
if instance is not None:
return self.format_matched_response(label, model, instance)
def generate(self, model_instance: InvenTreeBarcodeMixin):
"""Generate a barcode for a given model instance."""
barcode_format = self.get_setting('INTERNAL_BARCODE_FORMAT')
if barcode_format == 'json':
return json.dumps({model_instance.barcode_model_type(): model_instance.pk})
if barcode_format == 'short':
prefix = self.get_setting('SHORT_BARCODE_PREFIX')
model_type_code = model_instance.barcode_model_type_code()
return f'{prefix}{model_type_code}{model_instance.pk}'
return None

View File

@@ -52,6 +52,21 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
reverse('api-barcode-scan'), data=data, expected_code=expected_code
)
def generate(self, model: str, pk: int, expected_code: int):
"""Generate a barcode for a given model instance."""
return self.post(
reverse('api-barcode-generate'),
data={'model': model, 'pk': pk},
expected_code=expected_code,
)
def set_plugin_setting(self, key: str, value: str):
"""Set the internal barcode format for the plugin."""
from plugin import registry
plugin = registry.get_plugin('inventreebarcode')
plugin.set_setting(key, value)
def test_unassign_errors(self):
"""Test various error conditions for the barcode unassign endpoint."""
# Fail without any fields provided
@@ -248,8 +263,8 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
self.assertIn('success', response.data)
self.assertEqual(response.data['stockitem']['pk'], 1)
def test_scan_inventree(self):
"""Test scanning of first-party barcodes."""
def test_scan_inventree_json(self):
"""Test scanning of first-party json barcodes."""
# Scan a StockItem object (which does not exist)
response = self.scan({'barcode': '{"stockitem": 5}'}, expected_code=400)
@@ -290,3 +305,73 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
self.assertIn('success', response.data)
self.assertIn('barcode_data', response.data)
self.assertIn('barcode_hash', response.data)
def test_scan_inventree_short(self):
"""Test scanning of first-party short barcodes."""
# Scan a StockItem object (which does not exist)
response = self.scan({'barcode': 'INV-SI5'}, expected_code=400)
self.assertIn('No match found for barcode data', str(response.data))
# Scan a StockItem object (which does exist)
response = self.scan({'barcode': 'INV-SI1'}, expected_code=200)
self.assertIn('success', response.data)
self.assertIn('stockitem', response.data)
self.assertEqual(response.data['stockitem']['pk'], 1)
# Scan a StockLocation object
response = self.scan({'barcode': 'INV-SL5'}, expected_code=200)
self.assertIn('success', response.data)
self.assertEqual(response.data['stocklocation']['pk'], 5)
self.assertEqual(
response.data['stocklocation']['api_url'], '/api/stock/location/5/'
)
if settings.ENABLE_CLASSIC_FRONTEND:
self.assertEqual(
response.data['stocklocation']['web_url'], '/stock/location/5/'
)
self.assertEqual(response.data['plugin'], 'InvenTreeBarcode')
# Scan a Part object
response = self.scan({'barcode': 'INV-PA5'}, expected_code=200)
self.assertEqual(response.data['part']['pk'], 5)
# Scan a SupplierPart instance with custom prefix
for prefix in ['TEST', '']:
self.set_plugin_setting('SHORT_BARCODE_PREFIX', prefix)
response = self.scan({'barcode': f'{prefix}SP1'}, expected_code=200)
self.assertEqual(response.data['supplierpart']['pk'], 1)
self.assertEqual(response.data['plugin'], 'InvenTreeBarcode')
self.assertIn('success', response.data)
self.assertIn('barcode_data', response.data)
self.assertIn('barcode_hash', response.data)
self.set_plugin_setting('SHORT_BARCODE_PREFIX', 'INV-')
def test_generation_inventree_json(self):
"""Test JSON barcode generation."""
item = stock.models.StockLocation.objects.get(pk=5)
data = self.generate('stocklocation', item.pk, expected_code=200).data
self.assertEqual(data['barcode'], '{"stocklocation": 5}')
def test_generation_inventree_short(self):
"""Test short barcode generation."""
self.set_plugin_setting('INTERNAL_BARCODE_FORMAT', 'short')
item = stock.models.StockLocation.objects.get(pk=5)
# test with default prefix
data = self.generate('stocklocation', item.pk, expected_code=200).data
self.assertEqual(data['barcode'], 'INV-SL5')
# test generation with custom prefix
for prefix in ['TEST', '']:
self.set_plugin_setting('SHORT_BARCODE_PREFIX', prefix)
data = self.generate('stocklocation', item.pk, expected_code=200).data
self.assertEqual(data['barcode'], f'{prefix}SL5')
self.set_plugin_setting('SHORT_BARCODE_PREFIX', 'INV-')
self.set_plugin_setting('INTERNAL_BARCODE_FORMAT', 'json')

View File

@@ -3,12 +3,20 @@
from django import template
import barcode as python_barcode
import qrcode as python_qrcode
import qrcode.constants as ECL
from qrcode.main import QRCode
import report.helpers
register = template.Library()
QR_ECL_LEVEL_MAP = {
'L': ECL.ERROR_CORRECT_L,
'M': ECL.ERROR_CORRECT_M,
'Q': ECL.ERROR_CORRECT_Q,
'H': ECL.ERROR_CORRECT_H,
}
def image_data(img, fmt='PNG'):
"""Convert an image into HTML renderable data.
@@ -22,36 +30,44 @@ def image_data(img, fmt='PNG'):
def qrcode(data, **kwargs):
"""Return a byte-encoded QR code image.
kwargs:
fill_color: Fill color (default = black)
back_color: Background color (default = white)
version: Default = 1
box_size: Default = 20
border: Default = 1
Arguments:
data: Data to encode
Keyword Arguments:
version: QR code version, (None to auto detect) (default = None)
error_correction: Error correction level (L: 7%, M: 15%, Q: 25%, H: 30%) (default = 'M')
box_size: pixel dimensions for one black square pixel in the QR code (default = 20)
border: count white QR square pixels around the qr code, needed as padding (default = 1)
optimize: data will be split into multiple chunks of at least this length using different modes (text, alphanumeric, binary) to optimize the QR code size. Set to `0` to disable. (default = 1)
format: Image format (default = 'PNG')
fill_color: Fill color (default = "black")
back_color: Background color (default = "white")
Returns:
base64 encoded image data
"""
# Construct "default" values
params = {'box_size': 20, 'border': 1, 'version': 1}
# Extract other arguments from kwargs
fill_color = kwargs.pop('fill_color', 'black')
back_color = kwargs.pop('back_color', 'white')
image_format = kwargs.pop('format', 'PNG')
optimize = kwargs.pop('optimize', 1)
img_format = kwargs.pop('format', 'PNG')
params.update(**kwargs)
qr = python_qrcode.QRCode(**params)
qr.add_data(data, optimize=20)
qr.make(fit=True)
# Construct QR code object
qr = QRCode(**{
'box_size': 20,
'border': 1,
'version': None,
**kwargs,
'error_correction': QR_ECL_LEVEL_MAP[kwargs.get('error_correction', 'M')],
})
qr.add_data(data, optimize=optimize)
qr.make(fit=False) # if version is None, it will automatically use fit=True
qri = qr.make_image(fill_color=fill_color, back_color=back_color)
# Render to byte-encoded image
return image_data(qri, fmt=img_format)
return image_data(qri, fmt=image_format)
@register.simple_tag()

View File

@@ -142,11 +142,16 @@ class StockLocation(
"""Return API url."""
return reverse('api-location-list')
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'SL'
def report_context(self):
"""Return report context data for this StockLocation."""
return {
'location': self,
'qr_data': self.format_barcode(brief=True),
'qr_data': self.barcode,
'parent': self.parent,
'stock_location': self,
'stock_items': self.get_stock_items(),
@@ -367,6 +372,11 @@ class StockItem(
"""Custom API instance filters."""
return {'parent': {'exclude_tree': self.pk}}
@classmethod
def barcode_model_type_code(cls):
"""Return the associated barcode model type code for this model."""
return 'SI'
def get_test_keys(self, include_installed=True):
"""Construct a flattened list of test 'keys' for this StockItem."""
keys = []
@@ -397,7 +407,7 @@ class StockItem(
'item': self,
'name': self.part.full_name,
'part': self.part,
'qr_data': self.format_barcode(brief=True),
'qr_data': self.barcode,
'qr_url': self.get_absolute_url(),
'parameters': self.part.parameters_map(),
'quantity': InvenTree.helpers.normalize(self.quantity),

View File

@@ -534,7 +534,7 @@ $('#stock-edit-status').click(function () {
$("#show-qr-code").click(function() {
showQRDialog(
'{% trans "Stock Item QR Code" escape %}',
'{"stockitem": {{ item.pk }} }',
'{{ item.barcode }}',
);
});

View File

@@ -392,7 +392,7 @@
$('#show-qr-code').click(function() {
showQRDialog(
'{% trans "Stock Location QR Code" escape %}',
'{"stocklocation": {{ location.pk }} }'
'{{ location.barcode }}'
);
});

View File

@@ -952,12 +952,6 @@ class StockBarcodeTest(StockTestBase):
self.assertEqual(StockItem.barcode_model_type(), 'stockitem')
# Call format_barcode method
barcode = item.format_barcode(brief=False)
for key in ['tool', 'version', 'instance', 'stockitem']:
self.assertIn(key, barcode)
# Render simple barcode data for the StockItem
barcode = item.barcode
self.assertEqual(barcode, '{"stockitem": 1}')
@@ -968,7 +962,7 @@ class StockBarcodeTest(StockTestBase):
loc = StockLocation.objects.get(pk=1)
barcode = loc.format_barcode(brief=True)
barcode = loc.format_barcode()
self.assertEqual('{"stocklocation": 1}', barcode)

View File

@@ -16,6 +16,7 @@
{% include "InvenTree/settings/setting.html" with key="BARCODE_INPUT_DELAY" icon="fa-hourglass-half" %}
{% 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" %}
</tbody>
</table>

View File

@@ -38,7 +38,6 @@
"@mantine/spotlight": "^7.11.0",
"@mantine/vanilla-extract": "^7.11.0",
"@mdxeditor/editor": "^3.6.1",
"@naisutech/react-tree": "^3.1.0",
"@sentry/react": "^8.13.0",
"@tabler/icons-react": "^3.7.0",
"@tanstack/react-query": "^5.49.2",
@@ -52,6 +51,7 @@
"dayjs": "^1.11.10",
"embla-carousel-react": "^8.1.6",
"html5-qrcode": "^2.3.8",
"qrcode": "^1.5.3",
"mantine-datatable": "^7.11.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -72,6 +72,7 @@
"@lingui/macro": "^4.11.1",
"@playwright/test": "^1.45.0",
"@types/node": "^20.14.9",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-grid-layout": "^1.3.5",

View File

@@ -11,7 +11,7 @@ export function ButtonMenu({
label = ''
}: {
icon: any;
actions: any[];
actions: React.ReactNode[];
label?: string;
tooltip?: string;
}) {

View File

@@ -1,6 +1,13 @@
import { t } from '@lingui/macro';
import { Button, CopyButton as MantineCopyButton } from '@mantine/core';
import { IconCopy } from '@tabler/icons-react';
import {
ActionIcon,
Button,
CopyButton as MantineCopyButton,
Text,
Tooltip
} from '@mantine/core';
import { InvenTreeIcon } from '../../functions/icons';
export function CopyButton({
value,
@@ -9,24 +16,27 @@ export function CopyButton({
value: any;
label?: JSX.Element;
}) {
const ButtonComponent = label ? Button : ActionIcon;
return (
<MantineCopyButton value={value}>
{({ copied, copy }) => (
<Button
color={copied ? 'teal' : 'gray'}
onClick={copy}
title={t`Copy to clipboard`}
variant="subtle"
size="compact-md"
>
<IconCopy size={10} />
{label && (
<>
<div>&nbsp;</div>
{label}
</>
)}
</Button>
<Tooltip label={copied ? t`Copied` : t`Copy`} withArrow>
<ButtonComponent
color={copied ? 'teal' : 'gray'}
onClick={copy}
variant="transparent"
size="sm"
>
{copied ? (
<InvenTreeIcon icon="check" />
) : (
<InvenTreeIcon icon="copy" />
)}
{label && <Text ml={10}>{label}</Text>}
</ButtonComponent>
</Tooltip>
)}
</MantineCopyButton>
);

View File

@@ -1,15 +1,12 @@
import { t } from '@lingui/macro';
import {
ActionIcon,
Anchor,
Badge,
CopyButton,
Paper,
Skeleton,
Stack,
Table,
Text,
Tooltip
Text
} from '@mantine/core';
import { useSuspenseQuery } from '@tanstack/react-query';
import { getValueAtPath } from 'mantine-datatable';
@@ -24,6 +21,7 @@ import { navigateToLink } from '../../functions/navigation';
import { getDetailUrl } from '../../functions/urls';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { CopyButton } from '../buttons/CopyButton';
import { YesNoButton } from '../buttons/YesNoButton';
import { ProgressBar } from '../items/ProgressBar';
import { StylishText } from '../items/StylishText';
@@ -325,26 +323,7 @@ function StatusValue(props: Readonly<FieldProps>) {
}
function CopyField({ value }: { value: string }) {
return (
<CopyButton value={value}>
{({ copied, copy }) => (
<Tooltip label={copied ? t`Copied` : t`Copy`} withArrow>
<ActionIcon
color={copied ? 'teal' : 'gray'}
onClick={copy}
variant="transparent"
size="sm"
>
{copied ? (
<InvenTreeIcon icon="check" />
) : (
<InvenTreeIcon icon="copy" />
)}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
);
return <CopyButton value={value} />;
}
export function DetailsTableField({

View File

@@ -25,12 +25,18 @@ const tags: Tag[] = [
description: 'Generate a QR code image',
args: ['data'],
kwargs: {
fill_color: 'Fill color (default = black)',
back_color: 'Background color (default = white)',
version: 'Version (default = 1)',
box_size: 'Box size (default = 20)',
border: 'Border width (default = 1)',
format: 'Format (default = PNG)'
version: 'QR code version, (None to auto detect) (default = None)',
error_correction:
"Error correction level (L: 7%, M: 15%, Q: 25%, H: 30%) (default = 'Q')",
box_size:
'pixel dimensions for one black square pixel in the QR code (default = 20)',
border:
'count white QR square pixels around the qr code, needed as padding (default = 1)',
optimize:
'data will be split into multiple chunks of at least this length using different modes (text, alphanumeric, binary) to optimize the QR code size. Set to `0` to disable. (default = 1)',
format: "Image format (default = 'PNG')",
fill_color: 'Fill color (default = "black")',
back_color: 'Background color (default = "white")'
},
returns: 'base64 encoded qr code image data'
},

View File

@@ -6,6 +6,7 @@ import {
Menu,
Tooltip
} from '@mantine/core';
import { modals } from '@mantine/modals';
import {
IconCopy,
IconEdit,
@@ -16,9 +17,11 @@ import {
} from '@tabler/icons-react';
import { ReactNode, useMemo } from 'react';
import { ModelType } from '../../enums/ModelType';
import { identifierString } from '../../functions/conversion';
import { InvenTreeIcon } from '../../functions/icons';
import { notYetImplemented } from '../../functions/notifications';
import { InvenTreeQRCode } from './QRCode';
export type ActionDropdownItem = {
icon: ReactNode;
@@ -128,11 +131,20 @@ export function BarcodeActionDropdown({
// Common action button for viewing a barcode
export function ViewBarcodeAction({
hidden = false,
onClick
model,
pk
}: {
hidden?: boolean;
onClick?: () => void;
model: ModelType;
pk: number;
}): ActionDropdownItem {
const onClick = () => {
modals.open({
title: t`View Barcode`,
children: <InvenTreeQRCode model={model} pk={pk} />
});
};
return {
icon: <IconQrcode />,
name: t`View`,

View File

@@ -0,0 +1,130 @@
import { Trans, t } from '@lingui/macro';
import {
Box,
Code,
Group,
Image,
Select,
Skeleton,
Stack,
Text
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import QR from 'qrcode';
import { useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { CopyButton } from '../buttons/CopyButton';
type QRCodeProps = {
ecl?: 'L' | 'M' | 'Q' | 'H';
margin?: number;
data?: string;
};
export const QRCode = ({ data, ecl = 'Q', margin = 1 }: QRCodeProps) => {
const [qrCode, setQRCode] = useState<string>();
useEffect(() => {
if (!data) return setQRCode(undefined);
QR.toString(data, { errorCorrectionLevel: ecl, type: 'svg', margin }).then(
(svg) => {
setQRCode(`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`);
}
);
}, [data, ecl]);
return (
<Box>
{qrCode ? (
<Image src={qrCode} alt="QR Code" />
) : (
<Skeleton height={500} />
)}
</Box>
);
};
type InvenTreeQRCodeProps = {
model: ModelType;
pk: number;
showEclSelector?: boolean;
} & Omit<QRCodeProps, 'data'>;
export const InvenTreeQRCode = ({
showEclSelector = true,
model,
pk,
ecl: eclProp = 'Q',
...props
}: InvenTreeQRCodeProps) => {
const settings = useGlobalSettingsState();
const [ecl, setEcl] = useState(eclProp);
useEffect(() => {
if (eclProp) setEcl(eclProp);
}, [eclProp]);
const { data } = useQuery({
queryKey: ['qr-code', model, pk],
queryFn: async () => {
const res = await api.post(apiUrl(ApiEndpoints.generate_barcode), {
model,
pk
});
return res.data?.barcode as string;
}
});
const eclOptions = useMemo(
() => [
{ value: 'L', label: t`Low (7%)` },
{ value: 'M', label: t`Medium (15%)` },
{ value: 'Q', label: t`Quartile (25%)` },
{ value: 'H', label: t`High (30%)` }
],
[]
);
return (
<Stack>
<QRCode data={data} ecl={ecl} {...props} />
{data && settings.getSetting('BARCODE_SHOW_TEXT', 'false') && (
<Group
justify={showEclSelector ? 'space-between' : 'center'}
align="flex-start"
px={16}
>
<Stack gap={4} pt={2}>
<Text size="sm" fw={500}>
<Trans>Barcode Data:</Trans>
</Text>
<Group>
<Code>{data}</Code>
<CopyButton value={data} />
</Group>
</Stack>
{showEclSelector && (
<Select
allowDeselect={false}
label={t`Select Error Correction Level`}
value={ecl}
onChange={(v) =>
setEcl(v as Exclude<QRCodeProps['ecl'], undefined>)
}
data={eclOptions}
/>
)}
</Group>
)}
</Stack>
);
};

View File

@@ -38,6 +38,7 @@ export enum ApiEndpoints {
settings_global_list = 'settings/global/',
settings_user_list = 'settings/user/',
barcode = 'barcode/',
generate_barcode = 'barcode/generate/',
news = 'news/',
global_status = 'generic/status/',
version = 'version/',

View File

@@ -98,7 +98,8 @@ export default function SystemSettings() {
'BARCODE_ENABLE',
'BARCODE_INPUT_DELAY',
'BARCODE_WEBCAM_SUPPORT',
'BARCODE_SHOW_TEXT'
'BARCODE_SHOW_TEXT',
'BARCODE_GENERATION_PLUGIN'
]}
/>
)

View File

@@ -370,7 +370,10 @@ export default function BuildDetail() {
tooltip={t`Barcode Actions`}
icon={<IconQrcode />}
actions={[
ViewBarcodeAction({}),
ViewBarcodeAction({
model: ModelType.build,
pk: build.pk
}),
LinkBarcodeAction({
hidden: build?.barcode_hash
}),

View File

@@ -894,7 +894,10 @@ export default function PartDetail() {
<AdminButton model={ModelType.part} pk={part.pk} />,
<BarcodeActionDropdown
actions={[
ViewBarcodeAction({}),
ViewBarcodeAction({
model: ModelType.part,
pk: part.pk
}),
LinkBarcodeAction({
hidden: part?.barcode_hash || !user.hasChangeRole(UserRoles.part)
}),

View File

@@ -292,7 +292,10 @@ export default function PurchaseOrderDetail() {
<AdminButton model={ModelType.purchaseorder} pk={order.pk} />,
<BarcodeActionDropdown
actions={[
ViewBarcodeAction({}),
ViewBarcodeAction({
model: ModelType.purchaseorder,
pk: order.pk
}),
LinkBarcodeAction({
hidden: order?.barcode_hash
}),

View File

@@ -276,23 +276,28 @@ export default function Stock() {
variant="outline"
size="lg"
/>,
<BarcodeActionDropdown
actions={[
ViewBarcodeAction({}),
LinkBarcodeAction({}),
UnlinkBarcodeAction({}),
{
name: 'Scan in stock items',
icon: <InvenTreeIcon icon="stock" />,
tooltip: 'Scan items'
},
{
name: 'Scan in container',
icon: <InvenTreeIcon icon="unallocated_stock" />,
tooltip: 'Scan container'
}
]}
/>,
location.pk ? (
<BarcodeActionDropdown
actions={[
ViewBarcodeAction({
model: ModelType.stocklocation,
pk: location.pk
}),
LinkBarcodeAction({}),
UnlinkBarcodeAction({}),
{
name: 'Scan in stock items',
icon: <InvenTreeIcon icon="stock" />,
tooltip: 'Scan items'
},
{
name: 'Scan in container',
icon: <InvenTreeIcon icon="unallocated_stock" />,
tooltip: 'Scan container'
}
]}
/>
) : null,
<PrintingActions
modelType={ModelType.stocklocation}
items={[location.pk ?? 0]}

View File

@@ -428,7 +428,10 @@ export default function StockDetail() {
<AdminButton model={ModelType.stockitem} pk={stockitem.pk} />,
<BarcodeActionDropdown
actions={[
ViewBarcodeAction({}),
ViewBarcodeAction({
model: ModelType.stockitem,
pk: stockitem.pk
}),
LinkBarcodeAction({
hidden:
stockitem?.barcode_hash || !user.hasChangeRole(UserRoles.stock)

View File

@@ -106,7 +106,7 @@ export type InvenTreeTableProps<T = any> = {
enableReports?: boolean;
afterBulkDelete?: () => void;
pageSize?: number;
barcodeActions?: any[];
barcodeActions?: React.ReactNode[];
tableFilters?: TableFilter[];
tableActions?: React.ReactNode[];
rowExpansion?: any;

View File

@@ -796,7 +796,7 @@
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.1.tgz#4ffb0055f7ef676ebc3a5a91fb621393294e2f43"
integrity sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==
"@emotion/is-prop-valid@1.2.2", "@emotion/is-prop-valid@^1.2.0":
"@emotion/is-prop-valid@1.2.2":
version "1.2.2"
resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz#d4175076679c6a26faa92b03bb786f9e52612337"
integrity sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==
@@ -1822,15 +1822,6 @@
dependencies:
moo "^0.5.1"
"@naisutech/react-tree@^3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@naisutech/react-tree/-/react-tree-3.1.0.tgz#a83820425b53a1ec7a39804ff8bd9024f0a953f4"
integrity sha512-6p1l3ZIaTmbgiAf/mpFELvqwl51LDhr+09f7L+C27DBLWjtleezCMoUuiSLhrJgpixCPNL13PuI3q2yn+0AGvA==
dependencies:
"@emotion/is-prop-valid" "^1.2.0"
nanoid "^4.0.0"
react-draggable "^4.4.5"
"@open-draft/deferred-promise@^2.1.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz#4a822d10f6f0e316be4d67b4d4f8c9a124b073bd"
@@ -2598,6 +2589,13 @@
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6"
integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==
"@types/qrcode@^1.5.5":
version "1.5.5"
resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.5.tgz#993ff7c6b584277eee7aac0a20861eab682f9dac"
integrity sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==
dependencies:
"@types/node" "*"
"@types/react-dom@^18.3.0":
version "18.3.0"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.0.tgz#0cbc818755d87066ab6ca74fbedb2547d74a82b0"
@@ -3455,6 +3453,11 @@ diff@^5.1.0:
resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531"
integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==
dijkstrajs@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23"
integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==
dom-helpers@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
@@ -3507,6 +3510,11 @@ emoji-regex@^8.0.0:
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
encode-utf8@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda"
integrity sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==
error-ex@^1.3.1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
@@ -4952,11 +4960,6 @@ nanoid@^3.3.7:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==
nanoid@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e"
integrity sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==
next-tick@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb"
@@ -5236,6 +5239,11 @@ playwright@1.45.0:
optionalDependencies:
fsevents "2.3.2"
pngjs@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
pofile@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.1.4.tgz#eab7e29f5017589b2a61b2259dff608c0cad76a2"
@@ -5302,6 +5310,16 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
qrcode@^1.5.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.3.tgz#03afa80912c0dccf12bc93f615a535aad1066170"
integrity sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==
dependencies:
dijkstrajs "^1.0.1"
encode-utf8 "^1.0.3"
pngjs "^5.0.0"
yargs "^15.3.1"
ramda@^0.27.1:
version "0.27.2"
resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.2.tgz#84463226f7f36dc33592f6f4ed6374c48306c3f1"
@@ -6280,7 +6298,7 @@ yargs-parser@^18.1.2:
camelcase "^5.0.0"
decamelize "^1.2.0"
yargs@^15.0.2:
yargs@^15.0.2, yargs@^15.3.1:
version "15.4.1"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==