From 187707c89259ded328af0b63ce19a8e61ba53d5c Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 15 Sep 2022 14:14:51 +1000 Subject: [PATCH] Barcode Refactor (#3640) * define a simple model mixin class for barcode * Adds generic function for assigning a barcode to a model instance * StockItem model now implements the BarcodeMixin class * Implement simple unit tests for new code * Fix unit tests * Data migration for uid field * Remove references to old 'uid' field * Migration for removing old uid field from StockItem model * Bump API version * Change lookup_barcode to be a classmethod * Change barcode_model_type to be a class method * Cleanup for generic barcode scan and assign API: - Raise ValidationError as appropriate - Improved unit testing - Groundwork for future generic implementation * Further unit tests for barcode scanning * Adjust error messages for compatibility * Unit test fix * Fix hash_barcode function - Add unit tests to ensure it produces the same results as before the refactor * Add BarcodeMixin to Part model * Remove old format_barcode function from Part model * Further fixes for unit tests * Add support for assigning arbitrary barcode to Part instance - Simplify barcode API - Add more unit tests * More unit test fixes * Update unit test * Adds generic endpoint for unassigning barcode data * Update web dialog for unlinking a barcode * Template cleanup * Add Barcode mixin to StockLocation class * Add some simple unit tests for new model mixin * Support assigning / unassigning barcodes for StockLocation * remove failing outdated test * Update template to integrate new barcode support for StockLocation * Add BarcodeMixin to SupplierPart model * Adds QR code view for SupplierPart * Major simplification of barcode API endpoints - Separate existing barcode plugin into two separate classes - Simplify and consolidate the response from barcode scanning - Update unit testing * Yet more unit test fixes * Yet yet more unit test fixes --- InvenTree/InvenTree/api_version.py | 6 +- InvenTree/InvenTree/helpers.py | 18 + InvenTree/InvenTree/models.py | 97 +++++ InvenTree/InvenTree/tests.py | 30 ++ InvenTree/build/models.py | 13 +- .../migrations/0048_auto_20220913_0312.py | 23 ++ InvenTree/company/models.py | 4 +- .../templates/company/supplier_part.html | 50 +++ InvenTree/company/urls.py | 7 +- InvenTree/company/views.py | 17 +- InvenTree/label/models.py | 3 +- InvenTree/order/models.py | 8 +- InvenTree/order/serializers.py | 2 +- InvenTree/order/test_api.py | 8 +- .../migrations/0086_auto_20220912_0007.py | 23 ++ InvenTree/part/models.py | 16 +- InvenTree/part/templates/part/part_base.html | 32 ++ InvenTree/part/test_part.py | 11 +- InvenTree/plugin/base/barcodes/api.py | 249 ++++++------ InvenTree/plugin/base/barcodes/mixins.py | 103 +---- .../plugin/base/barcodes/test_barcode.py | 38 +- .../builtin/barcodes/inventree_barcode.py | 166 ++++---- .../barcodes/test_inventree_barcode.py | 378 +++++++++++++++++- InvenTree/stock/fixtures/stock.yaml | 4 +- .../migrations/0084_auto_20220903_0154.py | 23 ++ .../migrations/0085_auto_20220903_0225.py | 48 +++ .../migrations/0086_remove_stockitem_uid.py | 17 + .../migrations/0087_auto_20220912_2341.py | 23 ++ InvenTree/stock/models.py | 61 +-- InvenTree/stock/serializers.py | 4 +- .../stock/templates/stock/item_base.html | 21 +- InvenTree/stock/templates/stock/location.html | 33 ++ InvenTree/stock/tests.py | 49 ++- InvenTree/templates/js/translated/barcode.js | 25 +- 34 files changed, 1115 insertions(+), 495 deletions(-) create mode 100644 InvenTree/company/migrations/0048_auto_20220913_0312.py create mode 100644 InvenTree/part/migrations/0086_auto_20220912_0007.py create mode 100644 InvenTree/stock/migrations/0084_auto_20220903_0154.py create mode 100644 InvenTree/stock/migrations/0085_auto_20220903_0225.py create mode 100644 InvenTree/stock/migrations/0086_remove_stockitem_uid.py create mode 100644 InvenTree/stock/migrations/0087_auto_20220912_2341.py 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 %}
@@ -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 @@