diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py
index 70755eec47..22c65f2417 100644
--- a/InvenTree/InvenTree/api_version.py
+++ b/InvenTree/InvenTree/api_version.py
@@ -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
diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index fcbe9f4c0e..52befbff8f 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -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.
diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py
index 90741d51dd..61fab96f40 100644
--- a/InvenTree/InvenTree/models.py
+++ b/InvenTree/InvenTree/models.py
@@ -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.
diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py
index 823c9a918b..1929ba1325 100644
--- a/InvenTree/InvenTree/tests.py
+++ b/InvenTree/InvenTree/tests.py
@@ -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)
diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index 07bcaf4514..5852fe4965 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -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'.
diff --git a/InvenTree/company/migrations/0048_auto_20220913_0312.py b/InvenTree/company/migrations/0048_auto_20220913_0312.py
new file mode 100644
index 0000000000..6f4aecc77d
--- /dev/null
+++ b/InvenTree/company/migrations/0048_auto_20220913_0312.py
@@ -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'),
+ ),
+ ]
diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py
index aa67de0018..67b48a0e85 100644
--- a/InvenTree/company/models.py
+++ b/InvenTree/company/models.py
@@ -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:
diff --git a/InvenTree/company/templates/company/supplier_part.html b/InvenTree/company/templates/company/supplier_part.html
index 205f2da352..9a058c2d4e 100644
--- a/InvenTree/company/templates/company/supplier_part.html
+++ b/InvenTree/company/templates/company/supplier_part.html
@@ -30,6 +30,22 @@
{% url 'admin:company_supplierpart_change' part.pk as url %}
{% include "admin_button.html" with url=url %}
{% endif %}
+{% if barcodes %}
+
+
+
+
+
+
+
+{% endif %}
{% if roles.purchase_order.change or roles.purchase_order.add or roles.purchase_order.delete %}
@@ -100,6 +116,13 @@ src="{% static 'img/blank_image.png' %}"
{% decimal part.available %}{% render_date part.availability_updated %}
{% endif %}
+ {% if part.barcode_hash %}
+
+
+ {% trans "Barcode Identifier" %}
+ {{ part.barcode_hash }}
+
+ {% endif %}
{% 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");
}
diff --git a/InvenTree/company/urls.py b/InvenTree/company/urls.py
index 1b91cae5be..34aa85a366 100644
--- a/InvenTree/company/urls.py
+++ b/InvenTree/company/urls.py
@@ -25,5 +25,10 @@ manufacturer_part_urls = [
]
supplier_part_urls = [
- re_path(r'^(?P\d+)/', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
+ re_path(r'^(?P\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'),
+ ]))
+
+
]
diff --git a/InvenTree/company/views.py b/InvenTree/company/views.py
index 147e5e407d..96411bb493 100644
--- a/InvenTree/company/views.py
+++ b/InvenTree/company/views.py
@@ -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
diff --git a/InvenTree/label/models.py b/InvenTree/label/models.py
index 13b5ac331e..ee9ad7f3cb 100644
--- a/InvenTree/label/models.py
+++ b/InvenTree/label/models.py
@@ -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(),
diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py
index e5aaaa298c..94cc4078ec 100644
--- a/InvenTree/order/models.py
+++ b/InvenTree/order/models.py
@@ -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)
diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py
index 43311db925..6d23e83628 100644
--- a/InvenTree/order/serializers.py
+++ b/InvenTree/order/serializers.py
@@ -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
diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py
index da8011923b..b7d4fec091 100644
--- a/InvenTree/order/test_api.py
+++ b/InvenTree/order/test_api.py
@@ -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."""
diff --git a/InvenTree/part/migrations/0086_auto_20220912_0007.py b/InvenTree/part/migrations/0086_auto_20220912_0007.py
new file mode 100644
index 0000000000..dfaba36cf2
--- /dev/null
+++ b/InvenTree/part/migrations/0086_auto_20220912_0007.py
@@ -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'),
+ ),
+ ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 03407fd89c..6fe06e49db 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -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"""
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index 9b09a58521..aef2183740 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -45,6 +45,11 @@
{% if barcodes %}
{% trans "Show QR Code" %}
{% endif %}
+ {% if part.barcode_hash %}
+ {% trans "Unink Barcode" %}
+ {% else %}
+ {% trans "Link Barcode" %}
+ {% endif %}
{% if labels_enabled %}
{% trans "Print Label" %}
{% endif %}
@@ -167,6 +172,7 @@
{% trans "Description" %}
{{ part.description }}{% include "clip.html"%}
+
@@ -295,6 +301,13 @@
{{ part.keywords }}{% include "clip.html"%}
{% endif %}
+ {% if part.barcode_hash %}
+
+
+ {% trans "Barcode Identifier" %}
+ {{ part.barcode_hash }}
+
+ {% endif %}
@@ -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 }}]);
diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py
index a33a0ef83b..47b23f9b78 100644
--- a/InvenTree/part/test_part.py
+++ b/InvenTree/part/test_part.py
@@ -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"""
diff --git a/InvenTree/plugin/base/barcodes/api.py b/InvenTree/plugin/base/barcodes/api.py
index 7cda2576c0..21a35a754a 100644
--- a/InvenTree/plugin/base/barcodes/api.py
+++ b/InvenTree/plugin/base/barcodes/api.py
@@ -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'),
]
diff --git a/InvenTree/plugin/base/barcodes/mixins.py b/InvenTree/plugin/base/barcodes/mixins.py
index 5ba90d7157..5ad4794ebd 100644
--- a/InvenTree/plugin/base/barcodes/mixins.py
+++ b/InvenTree/plugin/base/barcodes/mixins.py
@@ -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
diff --git a/InvenTree/plugin/base/barcodes/test_barcode.py b/InvenTree/plugin/base/barcodes/test_barcode.py
index 932ae0d463..c847d0f586 100644
--- a/InvenTree/plugin/base/barcodes/test_barcode.py
+++ b/InvenTree/plugin/base/barcodes/test_barcode.py
@@ -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={
diff --git a/InvenTree/plugin/builtin/barcodes/inventree_barcode.py b/InvenTree/plugin/builtin/barcodes/inventree_barcode.py
index 52e97ddbd6..1b7594870e 100644
--- a/InvenTree/plugin/builtin/barcodes/inventree_barcode.py
+++ b/InvenTree/plugin/builtin/barcodes/inventree_barcode.py
@@ -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':
- }
+ 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)
diff --git a/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py b/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py
index b3fd51c781..1230794625 100644
--- a/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py
+++ b/InvenTree/plugin/builtin/barcodes/test_inventree_barcode.py
@@ -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)
diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml
index fb798e74be..103d224f8d 100644
--- a/InvenTree/stock/fixtures/stock.yaml
+++ b/InvenTree/stock/fixtures/stock.yaml
@@ -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
diff --git a/InvenTree/stock/migrations/0084_auto_20220903_0154.py b/InvenTree/stock/migrations/0084_auto_20220903_0154.py
new file mode 100644
index 0000000000..88a3500b4c
--- /dev/null
+++ b/InvenTree/stock/migrations/0084_auto_20220903_0154.py
@@ -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'),
+ ),
+ ]
diff --git a/InvenTree/stock/migrations/0085_auto_20220903_0225.py b/InvenTree/stock/migrations/0085_auto_20220903_0225.py
new file mode 100644
index 0000000000..75dc0fb0b9
--- /dev/null
+++ b/InvenTree/stock/migrations/0085_auto_20220903_0225.py
@@ -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
+ )
+ ]
diff --git a/InvenTree/stock/migrations/0086_remove_stockitem_uid.py b/InvenTree/stock/migrations/0086_remove_stockitem_uid.py
new file mode 100644
index 0000000000..916558fabe
--- /dev/null
+++ b/InvenTree/stock/migrations/0086_remove_stockitem_uid.py
@@ -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',
+ ),
+ ]
diff --git a/InvenTree/stock/migrations/0087_auto_20220912_2341.py b/InvenTree/stock/migrations/0087_auto_20220912_2341.py
new file mode 100644
index 0000000000..af811e071b
--- /dev/null
+++ b/InvenTree/stock/migrations/0087_auto_20220912_2341.py
@@ -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'),
+ ),
+ ]
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index d015b09aae..3724653eab 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -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: , part_id: }`
-
- 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',
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index 871df5a612..2ab079a5ac 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -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',
diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html
index 197b504e96..c46f39ed39 100644
--- a/InvenTree/stock/templates/stock/item_base.html
+++ b/InvenTree/stock/templates/stock/item_base.html
@@ -44,7 +44,7 @@