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 %} +<!-- 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"); } 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<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'), + ])) + + ] 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 %} <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 }}]); 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': <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) 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: <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', 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 @@ <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() { diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 13f153fc65..f56913b761 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -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 () { diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 172d94fe52..5e2fd72761 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -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): diff --git a/InvenTree/templates/js/translated/barcode.js b/InvenTree/templates/js/translated/barcode.js index 3c2211e710..818be0106b 100644 --- a/InvenTree/templates/js/translated/barcode.js +++ b/InvenTree/templates/js/translated/barcode.js @@ -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(); },