mirror of
https://github.com/inventree/InvenTree.git
synced 2025-11-06 08:05: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 commit76043ed96b. * Revert "fix: tests" This reverts commit3a2d46ff72.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)},
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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')}"
|
||||
|
||||
@@ -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 }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -451,7 +451,7 @@
|
||||
$("#show-qr-code").click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Part QR Code" escape %}',
|
||||
'{"part": {{ part.pk }} }',
|
||||
'{{ part.barcode }}',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
79
src/backend/InvenTree/plugin/base/barcodes/helper.py
Normal file
79
src/backend/InvenTree/plugin/base/barcodes/helper.py
Normal 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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }}',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@
|
||||
$('#show-qr-code').click(function() {
|
||||
showQRDialog(
|
||||
'{% trans "Stock Location QR Code" escape %}',
|
||||
'{"stocklocation": {{ location.pk }} }'
|
||||
'{{ location.barcode }}'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ButtonMenu({
|
||||
label = ''
|
||||
}: {
|
||||
icon: any;
|
||||
actions: any[];
|
||||
actions: React.ReactNode[];
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
|
||||
@@ -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> </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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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`,
|
||||
|
||||
130
src/frontend/src/components/items/QRCode.tsx
Normal file
130
src/frontend/src/components/items/QRCode.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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/',
|
||||
|
||||
@@ -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'
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user