initial implementation of barcode generation using plugins

This commit is contained in:
wolflu05 2024-07-14 13:22:04 +02:00
parent 0db280ad74
commit 24bd5d0962
No known key found for this signature in database
GPG Key ID: 9099EFC7C5EB963C
16 changed files with 276 additions and 67 deletions

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

@ -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

@ -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,13 @@ from plugin import registry
logger = logging.getLogger('inventree')
if sys.version_info >= (3, 11):
from typing import NotRequired
else:
class NotRequired:
"""NotRequired type helper is only supported with Python 3.11+."""
class MetaMixin(models.Model):
"""A base class for InvenTree models to include shared meta fields.
@ -1167,7 +1176,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 +1411,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

@ -474,6 +474,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,
@ -676,6 +681,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

@ -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'.
@ -871,6 +876,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".
@ -2035,6 +2045,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

@ -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

@ -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,45 @@ 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')})
model_instance = model_cls.objects.get(pk=pk)
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 +201,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 +243,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 +607,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,48 @@
"""Helper functions for barcode generation."""
import logging
from typing import Type
import InvenTree.helpers_model
from InvenTree.models import InvenTreeBarcodeMixin
logger = logging.getLogger('inventree')
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
# Find the selected barcode generation plugin
slug = get_global_setting('BARCODE_GENERATION_PLUGIN', create=False)
plugin = registry.get_plugin(slug)
return plugin.generate(model_instance)
def get_supported_barcode_models() -> list[Type[InvenTreeBarcodeMixin]]:
"""Returns a list of database models which support barcode functionality."""
return InvenTree.helpers_model.getModelsWithMixin(InvenTreeBarcodeMixin)
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()
}

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

@ -9,28 +9,44 @@ references model objects actually exist in the database.
import json
from django.core.validators import MaxLengthValidator, MinLengthValidator
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 (require more space)')),
('short', _('Short barcodes (made for optimized space)')),
],
'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'
),
'validator': [str, MinLengthValidator(4), MaxLengthValidator(4)],
'default': 'INV-',
},
}
def format_matched_response(self, label, model, instance):
"""Format a response for the scanned data."""
@ -53,7 +69,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
@ -79,3 +95,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

@ -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

@ -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

@ -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'
]}
/>
)