2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-16 20:15:44 +00:00

Rename "barcode" module to "barcodes" to prevent import shadowing

- Add 'barcode' support
This commit is contained in:
Oliver Walters
2021-02-22 15:15:25 +11:00
parent 23da591c22
commit caf4c293d9
10 changed files with 32 additions and 11 deletions

View File

240
InvenTree/barcodes/api.py Normal file
View File

@ -0,0 +1,240 @@
# -*- coding: utf-8 -*-
from django.urls import reverse
from django.conf.urls import url
from django.utils.translation import ugettext as _
from rest_framework.exceptions import ValidationError
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from stock.models import StockItem
from stock.serializers import StockItemSerializer
from barcodes.barcode import load_barcode_plugins, hash_barcode
class BarcodeScan(APIView):
"""
Endpoint for handling generic barcode scan requests.
Barcode data are decoded by the client application,
and sent to this endpoint (as a JSON object) for validation.
A barcode could follow the internal InvenTree barcode format,
or it could match to a third-party barcode format (e.g. Digikey).
When a barcode is sent to the server, the following parameters must be provided:
- barcode: The raw barcode data
plugins:
Third-party barcode formats may be supported using 'plugins'
(more information to follow)
hashing:
Barcode hashes are calculated using MD5
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
"""
Respond to a barcode POST request
"""
data = request.data
if 'barcode' not in data:
raise ValidationError({'barcode': _('Must provide barcode_data parameter')})
plugins = load_barcode_plugins()
barcode_data = data.get('barcode')
# Look for a barcode plugin which knows how to deal with this barcode
plugin = None
for plugin_class in plugins:
plugin_instance = plugin_class(barcode_data)
if plugin_instance.validate():
plugin = plugin_instance
break
match_found = False
response = {}
response['barcode_data'] = barcode_data
# A plugin has been found!
if plugin is not None:
# Try to associate with a stock item
item = plugin.getStockItem()
if item is None:
item = plugin.getStockItemByHash()
if item is not None:
response['stockitem'] = plugin.renderStockItem(item)
response['url'] = reverse('stock-item-detail', kwargs={'pk': item.id})
match_found = True
# Try to associate with a stock location
loc = plugin.getStockLocation()
if loc is not None:
response['stocklocation'] = plugin.renderStockLocation(loc)
response['url'] = reverse('stock-location-detail', kwargs={'pk': loc.id})
match_found = True
# Try to associate with a part
part = plugin.getPart()
if part is not None:
response['part'] = plugin.renderPart(part)
response['url'] = reverse('part-detail', kwargs={'pk': part.id})
match_found = True
response['hash'] = plugin.hash()
response['plugin'] = plugin.name
# No plugin is found!
# However, the hash of the barcode may still be associated with a StockItem!
else:
hash = hash_barcode(barcode_data)
response['hash'] = hash
response['plugin'] = None
# Try to look for a matching StockItem
try:
item = StockItem.objects.get(uid=hash)
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
response['stockitem'] = serializer.data
response['url'] = reverse('stock-item-detail', kwargs={'pk': item.id})
match_found = True
except StockItem.DoesNotExist:
pass
if not match_found:
response['error'] = _('No match found for barcode data')
else:
response['success'] = _('Match found for barcode data')
return Response(response)
class BarcodeAssign(APIView):
"""
Endpoint for assigning a barcode to a stock item.
- This only works if the barcode is not already associated with an object in the database
- If the barcode does not match an object, then the barcode hash is assigned to the StockItem
"""
permission_classes = [
permissions.IsAuthenticated
]
def post(self, request, *args, **kwargs):
data = request.data
if 'barcode' not in data:
raise ValidationError({'barcode': _('Must provide barcode_data parameter')})
if 'stockitem' not in data:
raise ValidationError({'stockitem': _('Must provide stockitem parameter')})
barcode_data = data['barcode']
try:
item = StockItem.objects.get(pk=data['stockitem'])
except (ValueError, StockItem.DoesNotExist):
raise ValidationError({'stockitem': _('No matching stock item found')})
plugins = load_barcode_plugins()
plugin = None
for plugin_class in plugins:
plugin_instance = plugin_class(barcode_data)
if plugin_instance.validate():
plugin = plugin_instance
break
match_found = False
response = {}
response['barcode_data'] = barcode_data
# Matching plugin was found
if plugin is not None:
hash = plugin.hash()
response['hash'] = hash
response['plugin'] = plugin.name
# Ensure that the barcode does not already match a database entry
if plugin.getStockItem() is not None:
match_found = True
response['error'] = _('Barcode already matches StockItem object')
if plugin.getStockLocation() is not None:
match_found = True
response['error'] = _('Barcode already matches StockLocation object')
if plugin.getPart() is not None:
match_found = True
response['error'] = _('Barcode already matches Part object')
if not match_found:
item = plugin.getStockItemByHash()
if item is not None:
response['error'] = _('Barcode hash already matches StockItem object')
match_found = True
else:
hash = hash_barcode(barcode_data)
response['hash'] = hash
response['plugin'] = None
# Lookup stock item by hash
try:
item = StockItem.objects.get(uid=hash)
response['error'] = _('Barcode hash already matches StockItem object')
match_found = True
except StockItem.DoesNotExist:
pass
if not match_found:
response['success'] = _('Barcode associated with StockItem')
# Save the barcode hash
item.uid = response['hash']
item.save()
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
response['stockitem'] = serializer.data
return Response(response)
barcode_api_urls = [
url(r'^link/$', BarcodeAssign.as_view(), name='api-barcode-link'),
# Catch-all performs barcode 'scan'
url(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
]

View File

@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
import string
import hashlib
import logging
from InvenTree import plugins as InvenTreePlugins
from barcodes import plugins as BarcodePlugins
from stock.models import StockItem
from stock.serializers import StockItemSerializer, LocationSerializer
from part.serializers import PartSerializer
logger = logging.getLogger(__name__)
def hash_barcode(barcode_data):
"""
Calculate an MD5 hash of barcode data.
HACK: Remove any 'non printable' characters from the hash,
as it seems browers will remove special control characters...
TODO: Work out a way around this!
"""
barcode_data = str(barcode_data).strip()
printable_chars = filter(lambda x: x in string.printable, barcode_data)
barcode_data = ''.join(list(printable_chars))
hash = hashlib.md5(str(barcode_data).encode())
return str(hash.hexdigest())
class BarcodePlugin:
"""
Base class for barcode handling.
Custom barcode plugins should extend this class as necessary.
"""
# Override the barcode plugin name for each sub-class
PLUGIN_NAME = ""
@property
def name(self):
return self.PLUGIN_NAME
def __init__(self, barcode_data):
"""
Initialize the BarcodePlugin instance
Args:
barcode_data - The raw barcode data
"""
self.data = barcode_data
def getStockItem(self):
"""
Attempt to retrieve a StockItem associated with this barcode.
Default implementation returns None
"""
return None
def getStockItemByHash(self):
"""
Attempt to retrieve a StockItem associated with this barcode,
based on the barcode hash.
"""
try:
item = StockItem.objects.get(uid=self.hash())
return item
except StockItem.DoesNotExist:
return None
def renderStockItem(self, item):
"""
Render a stock item to JSON response
"""
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
return serializer.data
def getStockLocation(self):
"""
Attempt to retrieve a StockLocation associated with this barcode.
Default implementation returns None
"""
return None
def renderStockLocation(self, loc):
"""
Render a stock location to a JSON response
"""
serializer = LocationSerializer(loc)
return serializer.data
def getPart(self):
"""
Attempt to retrieve a Part associated with this barcode.
Default implementation returns None
"""
return None
def renderPart(self, part):
"""
Render a part to JSON response
"""
serializer = PartSerializer(part)
return serializer.data
def hash(self):
"""
Calculate a hash for the barcode data.
This is supposed to uniquely identify the barcode contents,
at least within the bardcode sub-type.
The default implementation simply returns an MD5 hash of the barcode data,
encoded to a string.
This may be sufficient for most applications, but can obviously be overridden
by a subclass.
"""
return hash_barcode(self.data)
def validate(self):
"""
Default implementation returns False
"""
return False
def load_barcode_plugins(debug=False):
"""
Function to load all barcode plugins
"""
logger.debug("Loading barcode plugins")
plugins = InvenTreePlugins.get_plugins(BarcodePlugins, BarcodePlugin)
if debug:
if len(plugins) > 0:
logger.info(f"Discovered {len(plugins)} barcode plugins")
for p in plugins:
logger.debug(" - {p}".format(p=p.PLUGIN_NAME))
else:
logger.debug("No barcode plugins found")
return plugins

View File

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
"""
DigiKey barcode decoding
"""
from barcodes.barcode import BarcodePlugin
class DigikeyBarcodePlugin(BarcodePlugin):
PLUGIN_NAME = "DigikeyBarcode"
def validate(self):
"""
TODO: Validation of Digikey barcodes.
"""
return False

View File

@ -0,0 +1,143 @@
"""
The InvenTreeBarcodePlugin validates barcodes generated by InvenTree itself.
It can be used as a template for developing third-party barcode plugins.
The data format is very simple, and maps directly to database objects,
via the "id" parameter.
Parsing an InvenTree barcode simply involves validating that the
references model objects actually exist in the database.
"""
# -*- coding: utf-8 -*-
import json
from barcodes.barcode import BarcodePlugin
from stock.models import StockItem, StockLocation
from part.models import Part
from rest_framework.exceptions import ValidationError
class InvenTreeBarcodePlugin(BarcodePlugin):
PLUGIN_NAME = "InvenTreeBarcode"
def validate(self):
"""
An "InvenTree" barcode must be a jsonnable-dict with the following tags:
{
'tool': 'InvenTree',
'version': <anything>
}
"""
# The data must either be dict or be able to dictified
if type(self.data) is dict:
pass
elif type(self.data) is str:
try:
self.data = json.loads(self.data)
if type(self.data) is not dict:
return False
except json.JSONDecodeError:
return False
else:
return False
# If any of the following keys are in the JSON data,
# let's go ahead and assume that the code is a valid InvenTree one...
for key in ['tool', 'version', 'InvenTree', 'stockitem', 'location', 'part']:
if key in self.data.keys():
return True
return True
def getStockItem(self):
for k in self.data.keys():
if k.lower() == 'stockitem':
data = self.data[k]
pk = None
# Initially try casting to an integer
try:
pk = int(data)
except (TypeError, ValueError):
pk = None
if pk is None:
try:
pk = self.data[k]['id']
except (AttributeError, KeyError):
raise ValidationError({k: "id parameter not supplied"})
try:
item = StockItem.objects.get(pk=pk)
return item
except (ValueError, StockItem.DoesNotExist):
raise ValidationError({k, "Stock item does not exist"})
return None
def getStockLocation(self):
for k in self.data.keys():
if k.lower() == 'stocklocation':
pk = None
# First try simple integer lookup
try:
pk = int(self.data[k])
except (TypeError, ValueError):
pk = None
if pk is None:
# Lookup by 'id' field
try:
pk = self.data[k]['id']
except (AttributeError, KeyError):
raise ValidationError({k: "id parameter not supplied"})
try:
loc = StockLocation.objects.get(pk=pk)
return loc
except (ValueError, StockLocation.DoesNotExist):
raise ValidationError({k, "Stock location does not exist"})
return None
def getPart(self):
for k in self.data.keys():
if k.lower() == 'part':
pk = None
# Try integer lookup first
try:
pk = int(self.data[k])
except (TypeError, ValueError):
pk = None
if pk is None:
try:
pk = self.data[k]['id']
except (AttributeError, KeyError):
raise ValidationError({k, 'id parameter not supplied'})
try:
part = Part.objects.get(pk=pk)
return part
except (ValueError, Part.DoesNotExist):
raise ValidationError({k, 'Part does not exist'})
return None

146
InvenTree/barcodes/tests.py Normal file
View File

@ -0,0 +1,146 @@
# -*- coding: utf-8 -*-
"""
Unit tests for Barcode endpoints
"""
from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status
from stock.models import StockItem
class BarcodeAPITest(APITestCase):
fixtures = [
'category',
'part',
'location',
'stock'
]
def setUp(self):
# Create a user for auth
user = get_user_model()
user.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password')
self.scan_url = reverse('api-barcode-scan')
self.assign_url = reverse('api-barcode-link')
def postBarcode(self, url, barcode):
return self.client.post(url, format='json', data={'barcode': str(barcode)})
def test_invalid(self):
response = self.client.post(self.scan_url, format='json', data={})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_empty(self):
response = self.postBarcode(self.scan_url, '')
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data
self.assertIn('error', data)
self.assertIn('barcode_data', data)
self.assertIn('hash', data)
self.assertIn('plugin', data)
self.assertIsNone(data['plugin'])
def test_integer_barcode(self):
response = self.postBarcode(self.scan_url, '123456789')
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data
self.assertIn('error', data)
self.assertIn('barcode_data', data)
self.assertIn('hash', data)
self.assertIn('plugin', data)
self.assertIsNone(data['plugin'])
def test_array_barcode(self):
response = self.postBarcode(self.scan_url, "['foo', 'bar']")
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.data
self.assertIn('error', data)
self.assertIn('barcode_data', data)
self.assertIn('hash', data)
self.assertIn('plugin', data)
self.assertIsNone(data['plugin'])
def test_barcode_generation(self):
item = StockItem.objects.get(pk=522)
response = self.postBarcode(self.scan_url, item.format_barcode())
data = response.data
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('stockitem', data)
pk = data['stockitem']['pk']
self.assertEqual(pk, item.pk)
def test_association(self):
"""
Test that a barcode can be associated with a StockItem
"""
item = StockItem.objects.get(pk=522)
self.assertEqual(len(item.uid), 0)
barcode_data = 'A-TEST-BARCODE-STRING'
response = self.client.post(
self.assign_url, format='json',
data={
'barcode': barcode_data,
'stockitem': item.pk
}
)
data = response.data
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('success', data)
hash = data['hash']
# Read the item out from the database again
item = StockItem.objects.get(pk=522)
self.assertEqual(hash, item.uid)
# Ensure that the same UID cannot be assigned to a different stock item!
response = self.client.post(
self.assign_url, format='json',
data={
'barcode': barcode_data,
'stockitem': 521
}
)
data = response.data
self.assertIn('error', data)
self.assertNotIn('success', data)