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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1115 additions and 495 deletions

View File

@ -2,11 +2,15 @@
# InvenTree API version
INVENTREE_API_VERSION = 75
INVENTREE_API_VERSION = 76
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v76 -> 2022-09-10 : https://github.com/inventree/InvenTree/pull/3640
- Refactor of barcode data on the API
- StockItem.uid renamed to StockItem.barcode_hash
v75 -> 2022-09-05 : https://github.com/inventree/InvenTree/pull/3644
- Adds "pack_size" attribute to SupplierPart API serializer

View File

@ -1,5 +1,6 @@
"""Provides helper functions used throughout the InvenTree project."""
import hashlib
import io
import json
import logging
@ -907,6 +908,23 @@ def remove_non_printable_characters(value: str, remove_ascii=True, remove_unicod
return cleaned
def hash_barcode(barcode_data):
"""Calculate a 'unique' hash for a barcode string.
This hash is used for comparison / lookup.
We first remove any non-printable characters from the barcode data,
as some browsers have issues scanning characters in.
"""
barcode_data = str(barcode_data).strip()
barcode_data = remove_non_printable_characters(barcode_data)
hash = hashlib.md5(str(barcode_data).encode())
return str(hash.hexdigest())
def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = 'object_id'):
"""Lookup method for the GenericForeignKey fields.

View File

@ -636,6 +636,103 @@ class InvenTreeTree(MPTTModel):
return "{path} - {desc}".format(path=self.pathstring, desc=self.description)
class InvenTreeBarcodeMixin(models.Model):
"""A mixin class for adding barcode functionality to a model class.
Two types of barcodes are supported:
- Internal barcodes (QR codes using a strictly defined format)
- External barcodes (assign third party barcode data to a model instance)
The following fields are added to any model which implements this mixin:
- barcode_data : Raw data associated with an assigned barcode
- barcode_hash : A 'hash' of the assigned barcode data used to improve matching
"""
class Meta:
"""Metaclass options for this mixin.
Note: abstract must be true, as this is only a mixin, not a separate table
"""
abstract = True
barcode_data = models.CharField(
blank=True, max_length=500,
verbose_name=_('Barcode Data'),
help_text=_('Third party barcode data'),
)
barcode_hash = models.CharField(
blank=True, max_length=128,
verbose_name=_('Barcode Hash'),
help_text=_('Unique hash of barcode data')
)
@classmethod
def barcode_model_type(cls):
"""Return the model 'type' for creating a custom QR code."""
# By default, use the name of the class
return cls.__name__.lower()
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
)
@property
def barcode(self):
"""Format a minimal barcode string (e.g. for label printing)"""
return self.format_barcode(brief=True)
@classmethod
def lookup_barcode(cls, barcode_hash):
"""Check if a model instance exists with the specified third-party barcode hash."""
return cls.objects.filter(barcode_hash=barcode_hash).first()
def assign_barcode(self, barcode_hash=None, barcode_data=None, raise_error=True):
"""Assign an external (third-party) barcode to this object."""
# Must provide either barcode_hash or barcode_data
if barcode_hash is None and barcode_data is None:
raise ValueError("Provide either 'barcode_hash' or 'barcode_data'")
# If barcode_hash is not provided, create from supplier barcode_data
if barcode_hash is None:
barcode_hash = InvenTree.helpers.hash_barcode(barcode_data)
# Check for existing item
if self.__class__.lookup_barcode(barcode_hash) is not None:
if raise_error:
raise ValidationError(_("Existing barcode found"))
else:
return False
if barcode_data is not None:
self.barcode_data = barcode_data
self.barcode_hash = barcode_hash
self.save()
return True
def unassign_barcode(self):
"""Unassign custom barcode from this model"""
self.barcode_data = ''
self.barcode_hash = ''
self.save()
@receiver(pre_delete, sender=InvenTreeTree, dispatch_uid='tree_pre_delete_log')
def before_delete_tree_item(sender, instance, using, **kwargs):
"""Receives pre_delete signal from InvenTreeTree object.

View File

@ -19,6 +19,7 @@ from djmoney.contrib.exchange.models import Rate, convert_money
from djmoney.money import Money
import InvenTree.format
import InvenTree.helpers
import InvenTree.tasks
from common.models import InvenTreeSetting
from common.settings import currency_codes
@ -848,3 +849,32 @@ class TestOffloadTask(helpers.InvenTreeTestCase):
1, 2, 3, 4, 5,
force_async=True
)
class BarcodeMixinTest(helpers.InvenTreeTestCase):
"""Tests for the InvenTreeBarcodeMixin mixin class"""
def test_barcode_model_type(self):
"""Test that the barcode_model_type property works for each class"""
from part.models import Part
from stock.models import StockItem, StockLocation
self.assertEqual(Part.barcode_model_type(), 'part')
self.assertEqual(StockItem.barcode_model_type(), 'stockitem')
self.assertEqual(StockLocation.barcode_model_type(), 'stocklocation')
def test_bacode_hash(self):
"""Test that the barcode hashing function provides correct results"""
# Test multiple values for the hashing function
# This is to ensure that the hash function is always "backwards compatible"
hashing_tests = {
'abcdefg': '7ac66c0f148de9519b8bd264312c4d64',
'ABCDEFG': 'bb747b3df3130fe1ca4afa93fb7d97c9',
'1234567': 'fcea920f7412b5da7be0cf42b8c93759',
'{"part": 17, "stockitem": 12}': 'c88c11ed0628eb7fef0d59b098b96975',
}
for barcode, hash in hashing_tests.items():
self.assertEqual(InvenTree.helpers.hash_barcode(barcode), hash)

View File

@ -22,7 +22,7 @@ from mptt.exceptions import InvalidMove
from rest_framework import serializers
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode
from InvenTree.helpers import increment, normalize, MakeBarcode, notify_responsible
from InvenTree.helpers import increment, normalize, notify_responsible
from InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from build.validators import generate_next_build_reference, validate_build_order_reference
@ -110,17 +110,6 @@ class Build(MPTTModel, ReferenceIndexingMixin):
verbose_name = _("Build Order")
verbose_name_plural = _("Build Orders")
def format_barcode(self, **kwargs):
"""Return a JSON string to represent this build as a barcode."""
return MakeBarcode(
"buildorder",
self.pk,
{
"reference": self.title,
"url": self.get_absolute_url(),
}
)
@staticmethod
def filterByDate(queryset, min_date, max_date):
"""Filter by 'minimum and maximum date range'.

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.15 on 2022-09-13 03:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0047_supplierpart_pack_size'),
]
operations = [
migrations.AddField(
model_name='supplierpart',
name='barcode_data',
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
),
migrations.AddField(
model_name='supplierpart',
name='barcode_hash',
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
),
]

View File

@ -21,7 +21,7 @@ import InvenTree.helpers
import InvenTree.validators
from common.settings import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.models import InvenTreeAttachment
from InvenTree.models import InvenTreeAttachment, InvenTreeBarcodeMixin
from InvenTree.status_codes import PurchaseOrderStatus
@ -391,7 +391,7 @@ class SupplierPartManager(models.Manager):
)
class SupplierPart(models.Model):
class SupplierPart(InvenTreeBarcodeMixin, models.Model):
"""Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers.
Attributes:

View File

@ -30,6 +30,22 @@
{% url 'admin:company_supplierpart_change' part.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
{% if barcodes %}
<!-- Barcode actions menu -->
<div class='btn-group' role='group'>
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
<span class='fas fa-qrcode'></span> <span class='caret'></span>
</button>
<ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% if part.barcode_hash %}
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
{% else %}
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
{% endif %}
</ul>
</div>
{% endif %}
{% if roles.purchase_order.change or roles.purchase_order.add or roles.purchase_order.delete %}
<div class='btn-group'>
<button id='supplier-part-actions' title='{% trans "Supplier part actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
@ -100,6 +116,13 @@ src="{% static 'img/blank_image.png' %}"
<td>{% decimal part.available %}<span class='badge bg-dark rounded-pill float-right'>{% render_date part.availability_updated %}</span></td>
</tr>
{% endif %}
{% if part.barcode_hash %}
<tr>
<td><span class='fas fa-barcode'></span></td>
<td>{% trans "Barcode Identifier" %}</td>
<td {% if part.barcode_data %}title='{{ part.barcode_data }}'{% endif %}>{{ part.barcode_hash }}</td>
</tr>
{% endif %}
</table>
{% endblock details %}
@ -241,6 +264,33 @@ src="{% static 'img/blank_image.png' %}"
{% block js_ready %}
{{ block.super }}
{% if barcodes %}
$("#show-qr-code").click(function() {
launchModalForm("{% url 'supplier-part-qr' part.pk %}",
{
no_post: true,
});
});
$("#barcode-link").click(function() {
linkBarcodeDialog(
{
supplierpart: {{ part.pk }},
},
{
title: '{% trans "Link Barcode to Supplier Part" %}',
}
);
});
$("#barcode-unlink").click(function() {
unlinkBarcode({
supplierpart: {{ part.pk }},
});
});
{% endif %}
function reloadPriceBreaks() {
$("#price-break-table").bootstrapTable("refresh");
}

View File

@ -25,5 +25,10 @@ manufacturer_part_urls = [
]
supplier_part_urls = [
re_path(r'^(?P<pk>\d+)/', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
re_path(r'^(?P<pk>\d+)/', include([
re_path('^qr_code/?', views.SupplierPartQRCode.as_view(), name='supplier-part-qr'),
re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
]))
]

View File

@ -4,7 +4,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView, ListView
from InvenTree.views import InvenTreeRoleMixin
from InvenTree.views import InvenTreeRoleMixin, QRCodeView
from plugin.views import InvenTreePluginViewMixin
from .models import Company, ManufacturerPart, SupplierPart
@ -112,3 +112,18 @@ class SupplierPartDetail(InvenTreePluginViewMixin, DetailView):
context_object_name = 'part'
queryset = SupplierPart.objects.all()
permission_required = 'purchase_order.view'
class SupplierPartQRCode(QRCodeView):
"""View for displaying a QR code for a StockItem object."""
ajax_form_title = _("Stock Item QR Code")
role_required = 'stock.view'
def get_qr_data(self):
"""Generate QR code data for the StockItem."""
try:
part = SupplierPart.objects.get(id=self.pk)
return part.format_barcode()
except SupplierPart.DoesNotExist:
return None

View File

@ -249,7 +249,8 @@ class StockItemLabel(LabelTemplate):
'revision': stock_item.part.revision,
'quantity': normalize(stock_item.quantity),
'serial': stock_item.serial,
'uid': stock_item.uid,
'barcode_data': stock_item.barcode_data,
'barcode_hash': stock_item.barcode_hash,
'qr_data': stock_item.format_barcode(brief=True),
'qr_url': stock_item.format_barcode(url=True, request=request),
'tests': stock_item.testResultMap(),

View File

@ -450,11 +450,11 @@ class PurchaseOrder(Order):
notes = kwargs.get('notes', '')
# Extract optional barcode field
barcode = kwargs.get('barcode', None)
barcode_hash = kwargs.get('barcode', None)
# Prevent null values for barcode
if barcode is None:
barcode = ''
if barcode_hash is None:
barcode_hash = ''
if self.status != PurchaseOrderStatus.PLACED:
raise ValidationError(
@ -497,7 +497,7 @@ class PurchaseOrder(Order):
batch=batch_code,
serial=sn,
purchase_price=line.purchase_price,
uid=barcode
barcode_hash=barcode_hash
)
stock.save(add_note=False)

View File

@ -497,7 +497,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
if not barcode or barcode.strip() == '':
return None
if stock.models.StockItem.objects.filter(uid=barcode).exists():
if stock.models.StockItem.objects.filter(barcode_hash=barcode).exists():
raise ValidationError(_('Barcode is already in use'))
return barcode

View File

@ -582,11 +582,11 @@ class PurchaseOrderReceiveTest(OrderTest):
"""Tests for checking in items with invalid barcodes:
- Cannot check in "duplicate" barcodes
- Barcodes cannot match UID field for existing StockItem
- Barcodes cannot match 'barcode_hash' field for existing StockItem
"""
# Set stock item barcode
item = StockItem.objects.get(pk=1)
item.uid = 'MY-BARCODE-HASH'
item.barcode_hash = 'MY-BARCODE-HASH'
item.save()
response = self.post(
@ -705,8 +705,8 @@ class PurchaseOrderReceiveTest(OrderTest):
self.assertEqual(stock_2.last().location.pk, 2)
# Barcodes should have been assigned to the stock items
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists())
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists())
self.assertTrue(StockItem.objects.filter(barcode_hash='MY-UNIQUE-BARCODE-123').exists())
self.assertTrue(StockItem.objects.filter(barcode_hash='MY-UNIQUE-BARCODE-456').exists())
def test_batch_code(self):
"""Test that we can supply a 'batch code' when receiving items."""

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.15 on 2022-09-12 00:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0085_partparametertemplate_description'),
]
operations = [
migrations.AddField(
model_name='part',
name='barcode_data',
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
),
migrations.AddField(
model_name='part',
name='barcode_hash',
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
),
]

View File

@ -43,7 +43,7 @@ from InvenTree import helpers, validators
from InvenTree.fields import InvenTreeNotesField, InvenTreeURLField
from InvenTree.helpers import decimal2money, decimal2string, normalize
from InvenTree.models import (DataImportMixin, InvenTreeAttachment,
InvenTreeTree)
InvenTreeBarcodeMixin, InvenTreeTree)
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus)
from order import models as OrderModels
@ -300,7 +300,7 @@ class PartManager(TreeManager):
@cleanup.ignore
class Part(MetadataMixin, MPTTModel):
class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
"""The Part object represents an abstract part, the 'concept' of an actual entity.
An actual physical instance of a Part is a StockItem which is treated separately.
@ -941,18 +941,6 @@ class Part(MetadataMixin, MPTTModel):
responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), related_name='parts_responible')
def format_barcode(self, **kwargs):
"""Return a JSON string for formatting a barcode for this Part object."""
return helpers.MakeBarcode(
"part",
self.id,
{
"name": self.full_name,
"url": reverse('api-part-detail', kwargs={'pk': self.id}),
},
**kwargs
)
@property
def category_path(self):
"""Return the category path of this Part instance"""

View File

@ -45,6 +45,11 @@
{% if barcodes %}
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% endif %}
{% if part.barcode_hash %}
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unink Barcode" %}</a></li>
{% else %}
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
{% endif %}
{% if labels_enabled %}
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% endif %}
@ -167,6 +172,7 @@
<td>{% trans "Description" %}</td>
<td>{{ part.description }}{% include "clip.html"%}</td>
</tr>
</table>
<!-- Part info messages -->
@ -295,6 +301,13 @@
<td>{{ part.keywords }}{% include "clip.html"%}</td>
</tr>
{% endif %}
{% if part.barcode_hash %}
<tr>
<td><span class='fas fa-barcode'></span></td>
<td>{% trans "Barcode Identifier" %}</td>
<td {% if part.barcode_data %}title='{{ part.barcode_data }}'{% endif %}>{{ part.barcode_hash }}</td>
</tr>
{% endif %}
</table>
</div>
<div class='col-sm-6'>
@ -391,6 +404,7 @@
}
);
{% if barcodes %}
$("#show-qr-code").click(function() {
launchModalForm(
"{% url 'part-qr' part.id %}",
@ -400,6 +414,24 @@
);
});
$('#barcode-unlink').click(function() {
unlinkBarcode({
part: {{ part.pk }},
});
});
$('#barcode-link').click(function() {
linkBarcodeDialog(
{
part: {{ part.pk }},
},
{
title: '{% trans "Link Barcode to Part" %}',
}
);
});
{% endif %}
{% if labels_enabled %}
$('#print-label').click(function() {
printPartLabels([{{ part.pk }}]);

View File

@ -144,6 +144,15 @@ class PartTest(TestCase):
Part.objects.rebuild()
def test_barcode_mixin(self):
"""Test the barcode mixin functionality"""
self.assertEqual(Part.barcode_model_type(), 'part')
p = Part.objects.get(pk=1)
barcode = p.format_barcode(brief=True)
self.assertEqual(barcode, '{"part": 1}')
def test_tree(self):
"""Test that the part variant tree is working properly"""
chair = Part.objects.get(pk=10000)
@ -243,7 +252,7 @@ class PartTest(TestCase):
"""Test barcode format functionality"""
barcode = self.r1.format_barcode(brief=False)
self.assertIn('InvenTree', barcode)
self.assertIn(self.r1.name, barcode)
self.assertIn('"part": {"id": 3}', barcode)
def test_copy(self):
"""Test that we can 'deep copy' a Part instance"""

View File

@ -1,7 +1,7 @@
"""API endpoints for barcode plugins."""
from django.urls import path, re_path, reverse
from django.urls import path, re_path
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions
@ -9,11 +9,10 @@ from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
from InvenTree.helpers import hash_barcode
from plugin import registry
from plugin.base.barcodes.mixins import hash_barcode
from plugin.builtin.barcodes.inventree_barcode import InvenTreeBarcodePlugin
from stock.models import StockItem
from stock.serializers import StockItemSerializer
from plugin.builtin.barcodes.inventree_barcode import (
InvenTreeExternalBarcodePlugin, InvenTreeInternalBarcodePlugin)
class BarcodeScan(APIView):
@ -51,85 +50,40 @@ class BarcodeScan(APIView):
if 'barcode' not in data:
raise ValidationError({'barcode': _('Must provide barcode_data parameter')})
plugins = registry.with_mixin('barcode')
# Ensure that the default barcode handlers are run first
plugins = [
InvenTreeInternalBarcodePlugin(),
InvenTreeExternalBarcodePlugin(),
] + registry.with_mixin('barcode')
barcode_data = data.get('barcode')
# Ensure that the default barcode handler is installed
plugins.append(InvenTreeBarcodePlugin())
barcode_hash = hash_barcode(barcode_data)
# Look for a barcode plugin which knows how to deal with this barcode
plugin = None
for current_plugin in plugins:
current_plugin.init(barcode_data)
if current_plugin.validate():
plugin = current_plugin
break
match_found = False
response = {}
for current_plugin in plugins:
result = current_plugin.scan(barcode_data)
if result is not None:
plugin = current_plugin
response = result
break
response['plugin'] = plugin.name if plugin else None
response['barcode_data'] = barcode_data
response['barcode_hash'] = barcode_hash
# 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:
result_hash = hash_barcode(barcode_data)
response['hash'] = result_hash
response['plugin'] = None
# Try to look for a matching StockItem
try:
item = StockItem.objects.get(uid=result_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:
# A plugin has not been found!
if plugin is None:
response['error'] = _('No match found for barcode data')
raise ValidationError(response)
else:
response['success'] = _('Match found for barcode data')
return Response(response)
return Response(response)
class BarcodeAssign(APIView):
@ -148,97 +102,134 @@ class BarcodeAssign(APIView):
Checks inputs and assign barcode (hash) to StockItem.
"""
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')})
# Here we only check against 'InvenTree' plugins
plugins = [
InvenTreeInternalBarcodePlugin(),
InvenTreeExternalBarcodePlugin(),
]
plugins = registry.with_mixin('barcode')
# First check if the provided barcode matches an existing database entry
for plugin in plugins:
result = plugin.scan(barcode_data)
plugin = None
if result is not None:
result["error"] = _("Barcode matches existing item")
result["plugin"] = plugin.name
result["barcode_data"] = barcode_data
for current_plugin in plugins:
current_plugin.init(barcode_data)
raise ValidationError(result)
if current_plugin.validate():
plugin = current_plugin
break
barcode_hash = hash_barcode(barcode_data)
match_found = False
valid_labels = []
response = {}
for model in InvenTreeExternalBarcodePlugin.get_supported_barcode_models():
label = model.barcode_model_type()
valid_labels.append(label)
response['barcode_data'] = barcode_data
if label in data:
try:
instance = model.objects.get(pk=data[label])
# Matching plugin was found
if plugin is not None:
instance.assign_barcode(
barcode_data=barcode_data,
barcode_hash=barcode_hash,
)
result_hash = plugin.hash()
response['hash'] = result_hash
response['plugin'] = plugin.name
return Response({
'success': f"Assigned barcode to {label} instance",
label: {
'pk': instance.pk,
},
"barcode_data": barcode_data,
"barcode_hash": barcode_hash,
})
# Ensure that the barcode does not already match a database entry
except (ValueError, model.DoesNotExist):
raise ValidationError({
'error': f"No matching {label} instance found in database",
})
if plugin.getStockItem() is not None:
match_found = True
response['error'] = _('Barcode already matches Stock Item')
# If we got here, it means that no valid model types were provided
raise ValidationError({
'error': f"Missing data: provide one of '{valid_labels}'",
})
if plugin.getStockLocation() is not None:
match_found = True
response['error'] = _('Barcode already matches Stock Location')
if plugin.getPart() is not None:
match_found = True
response['error'] = _('Barcode already matches Part')
class BarcodeUnassign(APIView):
"""Endpoint for unlinking / unassigning a custom barcode from a database object"""
if not match_found:
item = plugin.getStockItemByHash()
permission_classes = [
permissions.IsAuthenticated,
]
if item is not None:
response['error'] = _('Barcode hash already matches Stock Item')
match_found = True
def post(self, request, *args, **kwargs):
"""Respond to a barcode unassign POST request"""
else:
result_hash = hash_barcode(barcode_data)
# The following database models support assignment of third-party barcodes
supported_models = InvenTreeExternalBarcodePlugin.get_supported_barcode_models()
response['hash'] = result_hash
response['plugin'] = None
supported_labels = [model.barcode_model_type() for model in supported_models]
model_names = ', '.join(supported_labels)
# Lookup stock item by hash
try:
item = StockItem.objects.get(uid=result_hash)
response['error'] = _('Barcode hash already matches Stock Item')
match_found = True
except StockItem.DoesNotExist:
pass
data = request.data
if not match_found:
response['success'] = _('Barcode associated with Stock Item')
matched_labels = []
# Save the barcode hash
item.uid = response['hash']
item.save()
for label in supported_labels:
if label in data:
matched_labels.append(label)
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
response['stockitem'] = serializer.data
if len(matched_labels) == 0:
raise ValidationError({
'error': f"Missing data: Provide one of '{model_names}'"
})
return Response(response)
if len(matched_labels) > 1:
raise ValidationError({
'error': f"Multiple conflicting fields: '{model_names}'",
})
# At this stage, we know that we have received a single valid field
for model in supported_models:
label = model.barcode_model_type()
if label in data:
try:
instance = model.objects.get(pk=data[label])
except (ValueError, model.DoesNotExist):
raise ValidationError({
label: _('No match found for provided value')
})
# Unassign the barcode data from the model instance
instance.unassign_barcode()
return Response({
'success': 'Barcode unassigned from {label} instance',
})
# If we get to this point, something has gone wrong!
raise ValidationError({
'error': 'Could not unassign barcode',
})
barcode_api_urls = [
# Link a barcode to a part
# 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-pary barcode from an item
path('unlink/', BarcodeUnassign.as_view(), name='api-barcode-unlink'),
# Catch-all performs barcode 'scan'
re_path(r'^.*$', BarcodeScan.as_view(), name='api-barcode-scan'),
]

View File

@ -1,33 +1,8 @@
"""Plugin mixin classes for barcode plugin."""
import hashlib
import string
from part.serializers import PartSerializer
from stock.models import StockItem
from stock.serializers import LocationSerializer, StockItemSerializer
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))
result_hash = hashlib.md5(str(barcode_data).encode())
return str(result_hash.hexdigest())
class BarcodeMixin:
"""Mixin that enables barcode handeling.
"""Mixin that enables barcode handling.
Custom barcode plugins should use and extend this mixin as necessary.
"""
@ -49,72 +24,16 @@ class BarcodeMixin:
"""Does this plugin have everything needed to process a barcode."""
return True
def init(self, barcode_data):
"""Initialize the BarcodePlugin instance.
def scan(self, barcode_data):
"""Scan a barcode against this plugin.
Args:
barcode_data: The raw barcode data
This method is explicitly called from the /scan/ API endpoint,
and thus it is expected that any barcode which matches this barcode will return a result.
If this plugin finds a match against the provided barcode, it should return a dict object
with the intended result.
Default return value is None
"""
self.data = barcode_data
def getStockItem(self):
"""Attempt to retrieve a StockItem associated with this barcode.
Default implementation returns None
"""
return None # pragma: no cover
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 # pragma: no cover
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 # pragma: no cover
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 # pragma: no cover
return None

View File

@ -52,16 +52,11 @@ class BarcodeAPITest(InvenTreeAPITestCase):
"""
response = self.postBarcode(self.scan_url, '')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.status_code, 400)
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_find_part(self):
"""Test that we can lookup a part based on ID."""
response = self.client.post(
@ -92,8 +87,7 @@ class BarcodeAPITest(InvenTreeAPITestCase):
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data['part'], 'Part does not exist')
self.assertIn('error', response.data)
def test_find_stock_item(self):
"""Test that we can lookup a stock item based on ID."""
@ -125,8 +119,7 @@ class BarcodeAPITest(InvenTreeAPITestCase):
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data['stockitem'], 'Stock item does not exist')
self.assertIn('error', response.data)
def test_find_location(self):
"""Test that we can lookup a stock location based on ID."""
@ -158,37 +151,26 @@ class BarcodeAPITest(InvenTreeAPITestCase):
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data['stocklocation'], 'Stock location does not exist')
self.assertIn('error', response.data)
def test_integer_barcode(self):
"""Test scan of an integer barcode."""
response = self.postBarcode(self.scan_url, '123456789')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.status_code, 400)
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):
"""Test scan of barcode with string encoded array."""
response = self.postBarcode(self.scan_url, "['foo', 'bar']")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.status_code, 400)
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):
"""Test that a barcode is generated with a scan."""
item = StockItem.objects.get(pk=522)
@ -208,7 +190,7 @@ class BarcodeAPITest(InvenTreeAPITestCase):
"""Test that a barcode can be associated with a StockItem."""
item = StockItem.objects.get(pk=522)
self.assertEqual(len(item.uid), 0)
self.assertEqual(len(item.barcode_hash), 0)
barcode_data = 'A-TEST-BARCODE-STRING'
@ -226,14 +208,14 @@ class BarcodeAPITest(InvenTreeAPITestCase):
self.assertIn('success', data)
result_hash = data['hash']
result_hash = data['barcode_hash']
# Read the item out from the database again
item = StockItem.objects.get(pk=522)
self.assertEqual(result_hash, item.uid)
self.assertEqual(result_hash, item.barcode_hash)
# Ensure that the same UID cannot be assigned to a different stock item!
# Ensure that the same barcode hash cannot be assigned to a different stock item!
response = self.client.post(
self.assign_url, format='json',
data={

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):
"""Generic base class for handling InvenTree barcodes"""
@staticmethod
def get_supported_barcode_models():
"""Returns a list of database models which support barcode functionality"""
return [
Part,
StockItem,
StockLocation,
SupplierPart,
]
def format_matched_response(self, label, model, instance):
"""Format a response for the scanned data"""
response = {
'pk': instance.pk
}
# Add in the API URL if available
if hasattr(model, 'get_api_url'):
response['api_url'] = f"{model.get_api_url()}{instance.pk}/"
# Add in the web URL if available
if hasattr(instance, 'get_absolute_url'):
response['web_url'] = instance.get_absolute_url()
return {label: response}
class InvenTreeInternalBarcodePlugin(InvenTreeBarcodePlugin):
"""Builtin BarcodePlugin for matching and generating internal barcodes."""
NAME = "InvenTreeBarcode"
NAME = "InvenTreeInternalBarcode"
def validate(self):
"""Validate a barcode.
def scan(self, barcode_data):
"""Scan a barcode against this plugin.
An "InvenTree" barcode must be a jsonnable-dict with the following tags:
{
'tool': 'InvenTree',
'version': <anything>
}
Here we are looking for a dict object which contains a reference to a particular InvenTree database object
"""
# The data must either be dict or be able to dictified
if type(self.data) is dict:
if type(barcode_data) is dict:
pass
elif type(self.data) is str:
elif type(barcode_data) is str:
try:
self.data = json.loads(self.data)
if type(self.data) is not dict:
return False
barcode_data = json.loads(barcode_data)
except json.JSONDecodeError:
return False
return None
else:
return False # pragma: no cover
return None
# 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...
if type(barcode_data) is not dict:
return None
for key in ['tool', 'version', 'InvenTree', 'stockitem', 'stocklocation', 'part']:
if key in self.data.keys():
return True
return True
def getStockItem(self):
"""Lookup StockItem by 'stockitem' key in barcode data."""
for k in self.data.keys():
if k.lower() == 'stockitem':
data = self.data[k]
pk = None
# Initially try casting to an integer
# 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(data)
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
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): # pragma: no cover
raise ValidationError({k: "Stock item does not exist"})
class InvenTreeExternalBarcodePlugin(InvenTreeBarcodePlugin):
"""Builtin BarcodePlugin for matching arbitrary external barcodes."""
return None
NAME = "InvenTreeExternalBarcode"
def getStockLocation(self):
"""Lookup StockLocation by 'stocklocation' key in barcode data."""
for k in self.data.keys():
if k.lower() == 'stocklocation':
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
"""
# First try simple integer lookup
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
# Lookup by 'id' field
try:
pk = self.data[k]['id']
except (AttributeError, KeyError):
raise ValidationError({k: "id parameter not supplied"})
barcode_hash = hash_barcode(barcode_data)
try:
loc = StockLocation.objects.get(pk=pk)
return loc
except (ValueError, StockLocation.DoesNotExist): # pragma: no cover
raise ValidationError({k: "Stock location does not exist"})
instance = model.lookup_barcode(barcode_hash)
return None
def getPart(self):
"""Lookup Part by 'part' key in barcode data."""
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): # pragma: no cover
pk = None
if pk is None: # pragma: no cover
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): # pragma: no cover
raise ValidationError({k: 'Part does not exist'})
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,
)
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.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')
self.assertIn('success', response.data)
self.assertIn('barcode_data', response.data)
self.assertIn('barcode_hash', response.data)

View File

@ -222,7 +222,7 @@
lft: 0
rght: 0
expiry_date: "1990-10-10"
uid: 9e5ae7fc20568ed4814c10967bba8b65
barcode_hash: 9e5ae7fc20568ed4814c10967bba8b65
- model: stock.stockitem
pk: 521
@ -236,7 +236,7 @@
lft: 0
rght: 0
status: 60
uid: 1be0dfa925825c5c6c79301449e50c2d
barcode_hash: 1be0dfa925825c5c6c79301449e50c2d
- model: stock.stockitem
pk: 522

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.15 on 2022-09-03 01:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0083_stocklocation_icon'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='barcode_data',
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
),
migrations.AddField(
model_name='stockitem',
name='barcode_hash',
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
),
]

View File

@ -0,0 +1,48 @@
# Generated by Django 3.2.15 on 2022-09-03 02:25
from django.db import migrations
def uid_to_barcode(apps, schama_editor):
"""Migrate old 'uid' field to new 'barcode_hash' field"""
StockItem = apps.get_model('stock', 'stockitem')
# Find all StockItem objects with non-empty UID field
items = StockItem.objects.exclude(uid=None).exclude(uid='')
for item in items:
item.barcode_hash = item.uid
item.save()
if items.count() > 0:
print(f"Updated barcode data for {items.count()} StockItem objects")
def barcode_to_uid(apps, schema_editor):
"""Migrate new 'barcode_hash' field to old 'uid' field"""
StockItem = apps.get_model('stock', 'stockitem')
# Find all StockItem objects with non-empty UID field
items = StockItem.objects.exclude(barcode_hash=None).exclude(barcode_hash='')
for item in items:
item.uid = item.barcode_hash
item.save()
if items.count() > 0:
print(f"Updated barcode data for {items.count()} StockItem objects")
class Migration(migrations.Migration):
dependencies = [
('stock', '0084_auto_20220903_0154'),
]
operations = [
migrations.RunPython(
uid_to_barcode,
reverse_code=barcode_to_uid
)
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.15 on 2022-09-03 02:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('stock', '0085_auto_20220903_0225'),
]
operations = [
migrations.RemoveField(
model_name='stockitem',
name='uid',
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.15 on 2022-09-12 23:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0086_remove_stockitem_uid'),
]
operations = [
migrations.AddField(
model_name='stocklocation',
name='barcode_data',
field=models.CharField(blank=True, help_text='Third party barcode data', max_length=500, verbose_name='Barcode Data'),
),
migrations.AddField(
model_name='stocklocation',
name='barcode_hash',
field=models.CharField(blank=True, help_text='Unique hash of barcode data', max_length=128, verbose_name='Barcode Hash'),
),
]

View File

@ -30,7 +30,8 @@ import report.models
from company import models as CompanyModels
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
InvenTreeURLField)
from InvenTree.models import InvenTreeAttachment, InvenTreeTree, extract_int
from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin,
InvenTreeTree, extract_int)
from InvenTree.status_codes import StockHistoryCode, StockStatus
from part import models as PartModels
from plugin.events import trigger_event
@ -38,7 +39,7 @@ from plugin.models import MetadataMixin
from users.models import Owner
class StockLocation(MetadataMixin, InvenTreeTree):
class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
"""Organization tree for StockItem objects.
A "StockLocation" can be considered a warehouse, or storage location
@ -126,27 +127,6 @@ class StockLocation(MetadataMixin, InvenTreeTree):
"""Return url for instance."""
return reverse('stock-location-detail', kwargs={'pk': self.id})
def format_barcode(self, **kwargs):
"""Return a JSON string for formatting a barcode for this StockLocation object."""
return InvenTree.helpers.MakeBarcode(
'stocklocation',
self.pk,
{
"name": self.name,
"url": reverse('api-location-detail', kwargs={'pk': self.id}),
},
**kwargs
)
@property
def barcode(self) -> str:
"""Get Brief payload data (e.g. for labels).
Returns:
str: Brief pyload data
"""
return self.format_barcode(brief=True)
def get_stock_items(self, cascade=True):
"""Return a queryset for all stock items under this category.
@ -221,12 +201,11 @@ def generate_batch_code():
return Template(batch_template).render(context)
class StockItem(MetadataMixin, MPTTModel):
class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
"""A StockItem object represents a quantity of physical instances of a part.
Attributes:
parent: Link to another StockItem from which this StockItem was created
uid: Field containing a unique-id which is mapped to a third-party identifier (e.g. a barcode)
part: Link to the master abstract part that this StockItem is an instance of
supplier_part: Link to a specific SupplierPart (optional)
location: Where this StockItem is located
@ -552,38 +531,6 @@ class StockItem(MetadataMixin, MPTTModel):
"""Returns part name."""
return self.part.full_name
def format_barcode(self, **kwargs):
"""Return a JSON string for formatting a barcode for this StockItem.
Can be used to perform lookup of a stockitem using barcode.
Contains the following data:
`{ type: 'StockItem', stock_id: <pk>, part_id: <part_pk> }`
Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change)
"""
return InvenTree.helpers.MakeBarcode(
"stockitem",
self.id,
{
"request": kwargs.get('request', None),
"item_url": reverse('stock-item-detail', kwargs={'pk': self.id}),
"url": reverse('api-stock-detail', kwargs={'pk': self.id}),
},
**kwargs
)
@property
def barcode(self):
"""Get Brief payload data (e.g. for labels).
Returns:
str: Brief pyload data
"""
return self.format_barcode(brief=True)
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship
parent = TreeForeignKey(
'self',

View File

@ -62,7 +62,7 @@ class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
'quantity',
'serial',
'supplier_part',
'uid',
'barcode_hash',
]
def validate_serial(self, value):
@ -245,7 +245,7 @@ class StockItemSerializer(InvenTree.serializers.InvenTreeModelSerializer):
'supplier_part',
'supplier_part_detail',
'tracking_items',
'uid',
'barcode_hash',
'updated',
'purchase_price',
'purchase_price_currency',

View File

@ -44,7 +44,7 @@
<ul class='dropdown-menu' role='menu'>
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% if roles.stock.change %}
{% if item.uid %}
{% if item.barcode_hash %}
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
{% else %}
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
@ -155,11 +155,11 @@
</td>
</tr>
{% if item.uid %}
{% if item.barcode_hash %}
<tr>
<td><span class='fas fa-barcode'></span></td>
<td>{% trans "Barcode Identifier" %}</td>
<td>{{ item.uid }}</td>
<td {% if item.barcode_data %}title='{{ item.barcode_data }}'{% endif %}>{{ item.barcode_hash }}</td>
</tr>
{% endif %}
{% if item.batch %}
@ -529,12 +529,22 @@ $("#show-qr-code").click(function() {
});
});
{% if barcodes %}
$("#barcode-link").click(function() {
linkBarcodeDialog({{ item.id }});
linkBarcodeDialog(
{
stockitem: {{ item.pk }},
},
{
title: '{% trans "Link Barcode to Stock Item" %}',
}
);
});
$("#barcode-unlink").click(function() {
unlinkBarcode({{ item.id }});
unlinkBarcode({
stockitem: {{ item.pk }},
});
});
$("#barcode-scan-into-location").click(function() {
@ -545,6 +555,7 @@ $("#barcode-scan-into-location").click(function() {
}
});
});
{% endif %}
{% if plugins_enabled %}
$('#locate-item-button').click(function() {

View File

@ -48,6 +48,11 @@
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
<ul class='dropdown-menu'>
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% if location.barcode_hash %}
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
{% else %}
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
{% endif %}
{% if labels_enabled %}
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% endif %}
@ -135,6 +140,13 @@
</td>
</tr>
{% endif %}
{% if location and location.barcode_hash %}
<tr>
<td><span class='fas fa-barcode'></span></td>
<td>{% trans "Barcode Identifier" %}</td>
<td {% if location.barcode_data %}title='{{ location.barcode_data }}'{% endif %}>{{ location.barcode_hash }}</td>
</tr>
{% endif %}
</table>
{% endblock details_left %}
@ -335,6 +347,7 @@
adjustLocationStock('move');
});
{% if barcodes %}
$('#show-qr-code').click(function() {
launchModalForm("{% url 'stock-location-qr' location.id %}",
{
@ -342,6 +355,26 @@
});
});
$("#barcode-link").click(function() {
linkBarcodeDialog(
{
stocklocation: {{ location.pk }},
},
{
title: '{% trans "Link Barcode to Stock Location" %}',
}
);
});
$("#barcode-unlink").click(function() {
unlinkBarcode({
stocklocation: {{ location.pk }},
});
});
{% endif %}
{% endif %}
$('#item-create').click(function () {

View File

@ -14,8 +14,8 @@ from .models import (StockItem, StockItemTestResult, StockItemTracking,
StockLocation)
class StockTest(InvenTreeTestCase):
"""Tests to ensure that the stock location tree functions correcly."""
class StockTestBase(InvenTreeTestCase):
"""Base class for running Stock tests"""
fixtures = [
'category',
@ -44,6 +44,10 @@ class StockTest(InvenTreeTestCase):
Part.objects.rebuild()
StockItem.objects.rebuild()
class StockTest(StockTestBase):
"""Tests to ensure that the stock location tree functions correcly."""
def test_link(self):
"""Test the link URL field validation"""
@ -151,12 +155,6 @@ class StockTest(InvenTreeTestCase):
self.assertEqual(self.home.get_absolute_url(), '/stock/location/1/')
def test_barcode(self):
"""Test format_barcode."""
barcode = self.office.format_barcode(brief=False)
self.assertIn('"name": "Office"', barcode)
def test_strings(self):
"""Test str function."""
it = StockItem.objects.get(pk=1)
@ -724,7 +722,38 @@ class StockTest(InvenTreeTestCase):
self.assertEqual(C22.get_ancestors().count(), 1)
class VariantTest(StockTest):
class StockBarcodeTest(StockTestBase):
"""Run barcode tests for the stock app"""
def test_stock_item_barcode_basics(self):
"""Simple tests for the StockItem barcode integration"""
item = StockItem.objects.get(pk=1)
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}')
def test_location_barcode_basics(self):
"""Simple tests for the StockLocation barcode integration"""
self.assertEqual(StockLocation.barcode_model_type(), 'stocklocation')
loc = StockLocation.objects.get(pk=1)
barcode = loc.format_barcode(brief=True)
self.assertEqual('{"stocklocation": 1}', barcode)
class VariantTest(StockTestBase):
"""Tests for calculation stock counts against templates / variants."""
def test_variant_stock(self):
@ -805,7 +834,7 @@ class VariantTest(StockTest):
item.save()
class TestResultTest(StockTest):
class TestResultTest(StockTestBase):
"""Tests for the StockItemTestResult model."""
def test_test_count(self):

View File

@ -352,19 +352,17 @@ function barcodeScanDialog() {
/*
* Dialog for linking a particular barcode to a stock item.
* Dialog for linking a particular barcode to a database model instsance
*/
function linkBarcodeDialog(stockitem) {
function linkBarcodeDialog(data, options={}) {
var modal = '#modal-form';
barcodeDialog(
'{% trans "Link Barcode to Stock Item" %}',
options.title,
{
url: '/api/barcode/link/',
data: {
stockitem: stockitem,
},
data: data,
onScan: function() {
$(modal).modal('hide');
@ -376,13 +374,13 @@ function linkBarcodeDialog(stockitem) {
/*
* Remove barcode association from a device.
* Remove barcode association from a database model instance.
*/
function unlinkBarcode(stockitem) {
function unlinkBarcode(data, options={}) {
var html = `<b>{% trans "Unlink Barcode" %}</b><br>`;
html += '{% trans "This will remove the association between this stock item and the barcode" %}';
html += '{% trans "This will remove the link to the associated barcode" %}';
showQuestionDialog(
'{% trans "Unlink Barcode" %}',
@ -391,13 +389,10 @@ function unlinkBarcode(stockitem) {
accept_text: '{% trans "Unlink" %}',
accept: function() {
inventreePut(
`/api/stock/${stockitem}/`,
'/api/barcode/unlink/',
data,
{
// Clear the UID field
uid: '',
},
{
method: 'PATCH',
method: 'POST',
success: function() {
location.reload();
},