mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-11 15:34:15 +00:00
Barcode Refactor (#3640)
* define a simple model mixin class for barcode * Adds generic function for assigning a barcode to a model instance * StockItem model now implements the BarcodeMixin class * Implement simple unit tests for new code * Fix unit tests * Data migration for uid field * Remove references to old 'uid' field * Migration for removing old uid field from StockItem model * Bump API version * Change lookup_barcode to be a classmethod * Change barcode_model_type to be a class method * Cleanup for generic barcode scan and assign API: - Raise ValidationError as appropriate - Improved unit testing - Groundwork for future generic implementation * Further unit tests for barcode scanning * Adjust error messages for compatibility * Unit test fix * Fix hash_barcode function - Add unit tests to ensure it produces the same results as before the refactor * Add BarcodeMixin to Part model * Remove old format_barcode function from Part model * Further fixes for unit tests * Add support for assigning arbitrary barcode to Part instance - Simplify barcode API - Add more unit tests * More unit test fixes * Update unit test * Adds generic endpoint for unassigning barcode data * Update web dialog for unlinking a barcode * Template cleanup * Add Barcode mixin to StockLocation class * Add some simple unit tests for new model mixin * Support assigning / unassigning barcodes for StockLocation * remove failing outdated test * Update template to integrate new barcode support for StockLocation * Add BarcodeMixin to SupplierPart model * Adds QR code view for SupplierPart * Major simplification of barcode API endpoints - Separate existing barcode plugin into two separate classes - Simplify and consolidate the response from barcode scanning - Update unit testing * Yet more unit test fixes * Yet yet more unit test fixes
This commit is contained in:
@ -9,8 +9,8 @@ references model objects actually exist in the database.
|
||||
|
||||
import json
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from company.models import SupplierPart
|
||||
from InvenTree.helpers import hash_barcode
|
||||
from part.models import Part
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import BarcodeMixin
|
||||
@ -18,121 +18,89 @@ from stock.models import StockItem, StockLocation
|
||||
|
||||
|
||||
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
"""Builtin BarcodePlugin for matching and generating internal barcodes."""
|
||||
"""Generic base class for handling InvenTree barcodes"""
|
||||
|
||||
NAME = "InvenTreeBarcode"
|
||||
@staticmethod
|
||||
def get_supported_barcode_models():
|
||||
"""Returns a list of database models which support barcode functionality"""
|
||||
|
||||
def validate(self):
|
||||
"""Validate a barcode.
|
||||
return [
|
||||
Part,
|
||||
StockItem,
|
||||
StockLocation,
|
||||
SupplierPart,
|
||||
]
|
||||
|
||||
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 # pragma: no cover
|
||||
|
||||
# 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...
|
||||
def format_matched_response(self, label, model, instance):
|
||||
"""Format a response for the scanned data"""
|
||||
|
||||
for key in ['tool', 'version', 'InvenTree', 'stockitem', 'stocklocation', 'part']:
|
||||
if key in self.data.keys():
|
||||
return True
|
||||
response = {
|
||||
'pk': instance.pk
|
||||
}
|
||||
|
||||
return True
|
||||
# Add in the API URL if available
|
||||
if hasattr(model, 'get_api_url'):
|
||||
response['api_url'] = f"{model.get_api_url()}{instance.pk}/"
|
||||
|
||||
def getStockItem(self):
|
||||
"""Lookup StockItem by 'stockitem' key in barcode data."""
|
||||
for k in self.data.keys():
|
||||
if k.lower() == 'stockitem':
|
||||
# Add in the web URL if available
|
||||
if hasattr(instance, 'get_absolute_url'):
|
||||
response['web_url'] = instance.get_absolute_url()
|
||||
|
||||
data = self.data[k]
|
||||
return {label: response}
|
||||
|
||||
pk = None
|
||||
|
||||
# Initially try casting to an integer
|
||||
try:
|
||||
pk = int(data)
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
pk = None
|
||||
class InvenTreeInternalBarcodePlugin(InvenTreeBarcodePlugin):
|
||||
"""Builtin BarcodePlugin for matching and generating internal barcodes."""
|
||||
|
||||
if pk is None: # pragma: no cover
|
||||
try:
|
||||
pk = self.data[k]['id']
|
||||
except (AttributeError, KeyError):
|
||||
raise ValidationError({k: "id parameter not supplied"})
|
||||
NAME = "InvenTreeInternalBarcode"
|
||||
|
||||
try:
|
||||
item = StockItem.objects.get(pk=pk)
|
||||
return item
|
||||
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
|
||||
raise ValidationError({k: "Stock item does not exist"})
|
||||
def scan(self, barcode_data):
|
||||
"""Scan a barcode against this plugin.
|
||||
|
||||
return None
|
||||
Here we are looking for a dict object which contains a reference to a particular InvenTree database object
|
||||
"""
|
||||
|
||||
def getStockLocation(self):
|
||||
"""Lookup StockLocation by 'stocklocation' key in barcode data."""
|
||||
for k in self.data.keys():
|
||||
if k.lower() == 'stocklocation':
|
||||
if type(barcode_data) is dict:
|
||||
pass
|
||||
elif type(barcode_data) is str:
|
||||
try:
|
||||
barcode_data = json.loads(barcode_data)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
pk = None
|
||||
if type(barcode_data) is not dict:
|
||||
return None
|
||||
|
||||
# First try simple integer lookup
|
||||
# Look for various matches. First good match will be returned
|
||||
for model in self.get_supported_barcode_models():
|
||||
label = model.barcode_model_type()
|
||||
if label in barcode_data:
|
||||
try:
|
||||
pk = int(self.data[k])
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
pk = None
|
||||
instance = model.objects.get(pk=barcode_data[label])
|
||||
return self.format_matched_response(label, model, instance)
|
||||
except (ValueError, model.DoesNotExist):
|
||||
pass
|
||||
|
||||
if pk is None: # pragma: no cover
|
||||
# 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): # pragma: no cover
|
||||
raise ValidationError({k: "Stock location does not exist"})
|
||||
class InvenTreeExternalBarcodePlugin(InvenTreeBarcodePlugin):
|
||||
"""Builtin BarcodePlugin for matching arbitrary external barcodes."""
|
||||
|
||||
return None
|
||||
NAME = "InvenTreeExternalBarcode"
|
||||
|
||||
def getPart(self):
|
||||
"""Lookup Part by 'part' key in barcode data."""
|
||||
for k in self.data.keys():
|
||||
if k.lower() == 'part':
|
||||
def scan(self, barcode_data):
|
||||
"""Scan a barcode against this plugin.
|
||||
|
||||
pk = None
|
||||
Here we are looking for a dict object which contains a reference to a particular InvenTree databse object
|
||||
"""
|
||||
|
||||
# Try integer lookup first
|
||||
try:
|
||||
pk = int(self.data[k])
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
pk = None
|
||||
for model in self.get_supported_barcode_models():
|
||||
label = model.barcode_model_type()
|
||||
|
||||
if pk is None: # pragma: no cover
|
||||
try:
|
||||
pk = self.data[k]['id']
|
||||
except (AttributeError, KeyError):
|
||||
raise ValidationError({k: 'id parameter not supplied'})
|
||||
barcode_hash = hash_barcode(barcode_data)
|
||||
|
||||
try:
|
||||
part = Part.objects.get(pk=pk)
|
||||
return part
|
||||
except (ValueError, Part.DoesNotExist): # pragma: no cover
|
||||
raise ValidationError({k: 'Part does not exist'})
|
||||
instance = model.lookup_barcode(barcode_hash)
|
||||
|
||||
return None
|
||||
if instance is not None:
|
||||
return self.format_matched_response(label, model, instance)
|
||||
|
@ -2,8 +2,8 @@
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import status
|
||||
|
||||
import part.models
|
||||
import stock.models
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
|
||||
|
||||
@ -14,21 +14,24 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'stock'
|
||||
'stock',
|
||||
'company',
|
||||
'supplier_part',
|
||||
]
|
||||
|
||||
def test_errors(self):
|
||||
"""Test all possible error cases for assigment action."""
|
||||
def test_assign_errors(self):
|
||||
"""Test error cases for assigment action."""
|
||||
|
||||
def test_assert_error(barcode_data):
|
||||
response = self.client.post(
|
||||
response = self.post(
|
||||
reverse('api-barcode-link'), format='json',
|
||||
data={
|
||||
'barcode': barcode_data,
|
||||
'stockitem': 521
|
||||
}
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
self.assertIn('error', response.data)
|
||||
|
||||
# test with already existing stock
|
||||
@ -40,11 +43,358 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
|
||||
# test with already existing part location
|
||||
test_assert_error('{"part": 10004}')
|
||||
|
||||
# test with hash
|
||||
test_assert_error('{"blbla": 10004}')
|
||||
def assign(self, data, expected_code=None):
|
||||
"""Peform a 'barcode assign' request"""
|
||||
|
||||
return self.post(
|
||||
reverse('api-barcode-link'),
|
||||
data=data,
|
||||
expected_code=expected_code
|
||||
)
|
||||
|
||||
def unassign(self, data, expected_code=None):
|
||||
"""Perform a 'barcode unassign' request"""
|
||||
|
||||
return self.post(
|
||||
reverse('api-barcode-unlink'),
|
||||
data=data,
|
||||
expected_code=expected_code,
|
||||
)
|
||||
|
||||
def scan(self, data, expected_code=None):
|
||||
"""Perform a 'scan' operation"""
|
||||
|
||||
return self.post(
|
||||
reverse('api-barcode-scan'),
|
||||
data=data,
|
||||
expected_code=expected_code
|
||||
)
|
||||
|
||||
def test_unassign_errors(self):
|
||||
"""Test various error conditions for the barcode unassign endpoint"""
|
||||
|
||||
# Fail without any fields provided
|
||||
response = self.unassign(
|
||||
{},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Missing data: Provide one of', str(response.data['error']))
|
||||
|
||||
# Fail with too many fields provided
|
||||
response = self.unassign(
|
||||
{
|
||||
'stockitem': 'abcde',
|
||||
'part': 'abcde',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Multiple conflicting fields:', str(response.data['error']))
|
||||
|
||||
# Fail with an invalid StockItem instance
|
||||
response = self.unassign(
|
||||
{
|
||||
'stockitem': 'invalid',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('No match found', str(response.data['stockitem']))
|
||||
|
||||
# Fail with an invalid Part instance
|
||||
response = self.unassign(
|
||||
{
|
||||
'part': 'invalid',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('No match found', str(response.data['part']))
|
||||
|
||||
def test_assign_to_stock_item(self):
|
||||
"""Test that we can assign a unique barcode to a StockItem object"""
|
||||
|
||||
# Test without providing any fields
|
||||
response = self.assign(
|
||||
{
|
||||
'barcode': 'abcde',
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Missing data:', str(response.data))
|
||||
|
||||
# Provide too many fields
|
||||
response = self.assign(
|
||||
{
|
||||
'barcode': 'abcdefg',
|
||||
'part': 1,
|
||||
'stockitem': 1,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertIn('Assigned barcode to part instance', str(response.data))
|
||||
self.assertEqual(response.data['part']['pk'], 1)
|
||||
|
||||
bc_data = '{"blbla": 10007}'
|
||||
|
||||
# Assign a barcode to a StockItem instance
|
||||
response = self.assign(
|
||||
data={
|
||||
'barcode': bc_data,
|
||||
'stockitem': 521,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
data = response.data
|
||||
self.assertEqual(data['barcode_data'], bc_data)
|
||||
self.assertEqual(data['stockitem']['pk'], 521)
|
||||
|
||||
# Check that the StockItem instance has actually been updated
|
||||
si = stock.models.StockItem.objects.get(pk=521)
|
||||
|
||||
self.assertEqual(si.barcode_data, bc_data)
|
||||
self.assertEqual(si.barcode_hash, "2f5dba5c83a360599ba7665b2a4131c6")
|
||||
|
||||
# Now test that we cannot assign this barcode to something else
|
||||
response = self.assign(
|
||||
data={
|
||||
'barcode': bc_data,
|
||||
'stockitem': 1,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Barcode matches existing item', str(response.data))
|
||||
|
||||
# Next, test that we can 'unassign' the barcode via the API
|
||||
response = self.unassign(
|
||||
{
|
||||
'stockitem': 521,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
si.refresh_from_db()
|
||||
|
||||
self.assertEqual(si.barcode_data, '')
|
||||
self.assertEqual(si.barcode_hash, '')
|
||||
|
||||
def test_assign_to_part(self):
|
||||
"""Test that we can assign a unique barcode to a Part instance"""
|
||||
|
||||
barcode = 'xyz-123'
|
||||
|
||||
# Test that an initial scan yields no results
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': barcode,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
# Attempt to assign to an invalid part ID
|
||||
response = self.assign(
|
||||
{
|
||||
'barcode': barcode,
|
||||
'part': 99999999,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('No matching part instance found in database', str(response.data))
|
||||
|
||||
# Test assigning to a valid part (should pass)
|
||||
response = self.assign(
|
||||
{
|
||||
'barcode': barcode,
|
||||
'part': 1,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['part']['pk'], 1)
|
||||
self.assertEqual(response.data['success'], 'Assigned barcode to part instance')
|
||||
|
||||
# Check that the Part instance has been updated
|
||||
p = part.models.Part.objects.get(pk=1)
|
||||
self.assertEqual(p.barcode_data, 'xyz-123')
|
||||
self.assertEqual(p.barcode_hash, 'bc39d07e9a395c7b5658c231bf910fae')
|
||||
|
||||
# Scanning the barcode should now reveal the 'Part' instance
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': barcode,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['plugin'], 'InvenTreeExternalBarcode')
|
||||
self.assertEqual(response.data['part']['pk'], 1)
|
||||
|
||||
# Attempting to assign the same barcode to a different part should result in an error
|
||||
response = self.assign(
|
||||
{
|
||||
'barcode': barcode,
|
||||
'part': 2,
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Barcode matches existing item', str(response.data['error']))
|
||||
|
||||
# Now test that we can unassign the barcode data also
|
||||
response = self.unassign(
|
||||
{
|
||||
'part': 1,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
p.refresh_from_db()
|
||||
|
||||
self.assertEqual(p.barcode_data, '')
|
||||
self.assertEqual(p.barcode_hash, '')
|
||||
|
||||
def test_assign_to_location(self):
|
||||
"""Test that we can assign a unique barcode to a StockLocation instance"""
|
||||
|
||||
barcode = '555555555555555555555555'
|
||||
|
||||
# Assign random barcode data to a StockLocation instance
|
||||
response = self.assign(
|
||||
data={
|
||||
'barcode': barcode,
|
||||
'stocklocation': 1,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['stocklocation']['pk'], 1)
|
||||
|
||||
# Check that the StockLocation instance has been updated
|
||||
loc = stock.models.StockLocation.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(loc.barcode_data, barcode)
|
||||
self.assertEqual(loc.barcode_hash, '4aa63f5e55e85c1f842796bf74896dbb')
|
||||
|
||||
# Check that an error is thrown if we try to assign the same value again
|
||||
response = self.assign(
|
||||
data={
|
||||
'barcode': barcode,
|
||||
'stocklocation': 2,
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Barcode matches existing item', str(response.data['error']))
|
||||
|
||||
# Now, unassign the barcode
|
||||
response = self.unassign(
|
||||
{
|
||||
'stocklocation': 1,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
loc.refresh_from_db()
|
||||
self.assertEqual(loc.barcode_data, '')
|
||||
self.assertEqual(loc.barcode_hash, '')
|
||||
|
||||
def test_scan_third_party(self):
|
||||
"""Test scanning of third-party barcodes"""
|
||||
|
||||
# First scanned barcode is for a 'third-party' barcode (which does not exist)
|
||||
response = self.scan({'barcode': 'blbla=10008'}, expected_code=400)
|
||||
self.assertEqual(response.data['error'], 'No match found for barcode data')
|
||||
|
||||
# Next scanned barcode is for a 'third-party' barcode (which does exist)
|
||||
response = self.scan({'barcode': 'blbla=10004'}, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['barcode_data'], 'blbla=10004')
|
||||
self.assertEqual(response.data['plugin'], 'InvenTreeExternalBarcode')
|
||||
|
||||
# Scan for a StockItem instance
|
||||
si = stock.models.StockItem.objects.get(pk=1)
|
||||
|
||||
for barcode in ['abcde', 'ABCDE', '12345']:
|
||||
si.assign_barcode(barcode_data=barcode)
|
||||
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': barcode,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertIn('success', response.data)
|
||||
self.assertEqual(response.data['stockitem']['pk'], 1)
|
||||
|
||||
def test_scan_inventree(self):
|
||||
"""Test scanning of first-party barcodes"""
|
||||
|
||||
# Scan a StockItem object (which does not exist)
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': '{"stockitem": 5}',
|
||||
},
|
||||
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': '{"stockitem": 1}',
|
||||
},
|
||||
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': '{"stocklocation": 5}',
|
||||
},
|
||||
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/')
|
||||
self.assertEqual(response.data['stocklocation']['web_url'], '/stock/location/5/')
|
||||
self.assertEqual(response.data['plugin'], 'InvenTreeInternalBarcode')
|
||||
|
||||
# Scan a Part object
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': '{"part": 5}'
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['part']['pk'], 5)
|
||||
|
||||
# Scan a SupplierPart instance
|
||||
response = self.scan(
|
||||
{
|
||||
'barcode': '{"supplierpart": 1}',
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(response.data['supplierpart']['pk'], 1)
|
||||
self.assertEqual(response.data['plugin'], 'InvenTreeInternalBarcode')
|
||||
|
||||
def test_scan(self):
|
||||
"""Test that a barcode can be scanned."""
|
||||
response = self.client.post(reverse('api-barcode-scan'), format='json', data={'barcode': 'blbla=10004'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn('success', response.data)
|
||||
self.assertIn('barcode_data', response.data)
|
||||
self.assertIn('barcode_hash', response.data)
|
||||
|
Reference in New Issue
Block a user