2
0
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:
Oliver
2022-09-15 14:14:51 +10:00
committed by GitHub
parent 7645492cc2
commit 187707c892
34 changed files with 1105 additions and 485 deletions

View File

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

View File

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