Barcode Refactor (#3640)

* define a simple model mixin class for barcode

* Adds generic function for assigning a barcode to a model instance

* StockItem model now implements the BarcodeMixin class

* Implement simple unit tests for new code

* Fix unit tests

* Data migration for uid field

* Remove references to old 'uid' field

* Migration for removing old uid field from StockItem model

* Bump API version

* Change lookup_barcode to be a classmethod

* Change barcode_model_type to be a class method

* Cleanup for generic barcode scan and assign API:

- Raise ValidationError as appropriate
- Improved unit testing
- Groundwork for future generic implementation

* Further unit tests for barcode scanning

* Adjust error messages for compatibility

* Unit test fix

* Fix hash_barcode function

- Add unit tests to ensure it produces the same results as before the refactor

* Add BarcodeMixin to Part model

* Remove old format_barcode function from Part model

* Further fixes for unit tests

* Add support for assigning arbitrary barcode to Part instance

- Simplify barcode API
- Add more unit tests

* More unit test fixes

* Update unit test

* Adds generic endpoint for unassigning barcode data

* Update web dialog for unlinking a barcode

* Template cleanup

* Add Barcode mixin to StockLocation class

* Add some simple unit tests for new model mixin

* Support assigning / unassigning barcodes for StockLocation

* remove failing outdated test

* Update template to integrate new barcode support for StockLocation

* Add BarcodeMixin to SupplierPart model

* Adds QR code view for SupplierPart

* Major simplification of barcode API endpoints

- Separate existing barcode plugin into two separate classes
- Simplify and consolidate the response from barcode scanning
- Update unit testing

* Yet more unit test fixes

* Yet yet more unit test fixes
This commit is contained in:
Oliver 2022-09-15 14:14:51 +10:00 committed by GitHub
parent 7645492cc2
commit 187707c892
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1115 additions and 495 deletions

View File

@ -2,11 +2,15 @@
# InvenTree API version # InvenTree API version
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 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 v75 -> 2022-09-05 : https://github.com/inventree/InvenTree/pull/3644
- Adds "pack_size" attribute to SupplierPart API serializer - Adds "pack_size" attribute to SupplierPart API serializer

View File

@ -1,5 +1,6 @@
"""Provides helper functions used throughout the InvenTree project.""" """Provides helper functions used throughout the InvenTree project."""
import hashlib
import io import io
import json import json
import logging import logging
@ -907,6 +908,23 @@ def remove_non_printable_characters(value: str, remove_ascii=True, remove_unicod
return cleaned 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'): def get_objectreference(obj, type_ref: str = 'content_type', object_ref: str = 'object_id'):
"""Lookup method for the GenericForeignKey fields. """Lookup method for the GenericForeignKey fields.

View File

@ -636,6 +636,103 @@ class InvenTreeTree(MPTTModel):
return "{path} - {desc}".format(path=self.pathstring, desc=self.description) 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') @receiver(pre_delete, sender=InvenTreeTree, dispatch_uid='tree_pre_delete_log')
def before_delete_tree_item(sender, instance, using, **kwargs): def before_delete_tree_item(sender, instance, using, **kwargs):
"""Receives pre_delete signal from InvenTreeTree object. """Receives pre_delete signal from InvenTreeTree object.

View File

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

View File

@ -22,7 +22,7 @@ from mptt.exceptions import InvalidMove
from rest_framework import serializers from rest_framework import serializers
from InvenTree.status_codes import BuildStatus, StockStatus, StockHistoryCode 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 InvenTree.models import InvenTreeAttachment, ReferenceIndexingMixin
from build.validators import generate_next_build_reference, validate_build_order_reference 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 = _("Build Order")
verbose_name_plural = _("Build Orders") 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 @staticmethod
def filterByDate(queryset, min_date, max_date): def filterByDate(queryset, min_date, max_date):
"""Filter by 'minimum and maximum date range'. """Filter by 'minimum and maximum date range'.

View File

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

View File

@ -21,7 +21,7 @@ import InvenTree.helpers
import InvenTree.validators import InvenTree.validators
from common.settings import currency_code_default from common.settings import currency_code_default
from InvenTree.fields import InvenTreeURLField, RoundingDecimalField from InvenTree.fields import InvenTreeURLField, RoundingDecimalField
from InvenTree.models import InvenTreeAttachment from InvenTree.models import InvenTreeAttachment, InvenTreeBarcodeMixin
from InvenTree.status_codes import PurchaseOrderStatus 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. """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: Attributes:

View File

@ -30,6 +30,22 @@
{% url 'admin:company_supplierpart_change' part.pk as url %} {% url 'admin:company_supplierpart_change' part.pk as url %}
{% include "admin_button.html" with url=url %} {% include "admin_button.html" with url=url %}
{% endif %} {% 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 %} {% if roles.purchase_order.change or roles.purchase_order.add or roles.purchase_order.delete %}
<div class='btn-group'> <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'> <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> <td>{% decimal part.available %}<span class='badge bg-dark rounded-pill float-right'>{% render_date part.availability_updated %}</span></td>
</tr> </tr>
{% endif %} {% 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> </table>
{% endblock details %} {% endblock details %}
@ -241,6 +264,33 @@ src="{% static 'img/blank_image.png' %}"
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ 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() { function reloadPriceBreaks() {
$("#price-break-table").bootstrapTable("refresh"); $("#price-break-table").bootstrapTable("refresh");
} }

View File

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

View File

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

View File

@ -249,7 +249,8 @@ class StockItemLabel(LabelTemplate):
'revision': stock_item.part.revision, 'revision': stock_item.part.revision,
'quantity': normalize(stock_item.quantity), 'quantity': normalize(stock_item.quantity),
'serial': stock_item.serial, '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_data': stock_item.format_barcode(brief=True),
'qr_url': stock_item.format_barcode(url=True, request=request), 'qr_url': stock_item.format_barcode(url=True, request=request),
'tests': stock_item.testResultMap(), 'tests': stock_item.testResultMap(),

View File

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

View File

@ -497,7 +497,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
if not barcode or barcode.strip() == '': if not barcode or barcode.strip() == '':
return None 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')) raise ValidationError(_('Barcode is already in use'))
return barcode return barcode

View File

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

View File

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

View File

@ -43,7 +43,7 @@ from InvenTree import helpers, validators
from InvenTree.fields import InvenTreeNotesField, InvenTreeURLField from InvenTree.fields import InvenTreeNotesField, InvenTreeURLField
from InvenTree.helpers import decimal2money, decimal2string, normalize from InvenTree.helpers import decimal2money, decimal2string, normalize
from InvenTree.models import (DataImportMixin, InvenTreeAttachment, from InvenTree.models import (DataImportMixin, InvenTreeAttachment,
InvenTreeTree) InvenTreeBarcodeMixin, InvenTreeTree)
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus) SalesOrderStatus)
from order import models as OrderModels from order import models as OrderModels
@ -300,7 +300,7 @@ class PartManager(TreeManager):
@cleanup.ignore @cleanup.ignore
class Part(MetadataMixin, MPTTModel): class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
"""The Part object represents an abstract part, the 'concept' of an actual entity. """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. 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') 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 @property
def category_path(self): def category_path(self):
"""Return the category path of this Part instance""" """Return the category path of this Part instance"""

View File

@ -45,6 +45,11 @@
{% if barcodes %} {% if barcodes %}
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li> <li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
{% endif %} {% 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 %} {% if labels_enabled %}
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li> <li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% endif %} {% endif %}
@ -167,6 +172,7 @@
<td>{% trans "Description" %}</td> <td>{% trans "Description" %}</td>
<td>{{ part.description }}{% include "clip.html"%}</td> <td>{{ part.description }}{% include "clip.html"%}</td>
</tr> </tr>
</table> </table>
<!-- Part info messages --> <!-- Part info messages -->
@ -295,6 +301,13 @@
<td>{{ part.keywords }}{% include "clip.html"%}</td> <td>{{ part.keywords }}{% include "clip.html"%}</td>
</tr> </tr>
{% endif %} {% 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> </table>
</div> </div>
<div class='col-sm-6'> <div class='col-sm-6'>
@ -391,6 +404,7 @@
} }
); );
{% if barcodes %}
$("#show-qr-code").click(function() { $("#show-qr-code").click(function() {
launchModalForm( launchModalForm(
"{% url 'part-qr' part.id %}", "{% 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 %} {% if labels_enabled %}
$('#print-label').click(function() { $('#print-label').click(function() {
printPartLabels([{{ part.pk }}]); printPartLabels([{{ part.pk }}]);

View File

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

View File

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

View File

@ -1,33 +1,8 @@
"""Plugin mixin classes for barcode plugin.""" """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: class BarcodeMixin:
"""Mixin that enables barcode handeling. """Mixin that enables barcode handling.
Custom barcode plugins should use and extend this mixin as necessary. 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.""" """Does this plugin have everything needed to process a barcode."""
return True return True
def init(self, barcode_data): def scan(self, barcode_data):
"""Initialize the BarcodePlugin instance. """Scan a barcode against this plugin.
Args: This method is explicitly called from the /scan/ API endpoint,
barcode_data: The raw barcode data 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 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

View File

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

View File

@ -9,8 +9,8 @@ references model objects actually exist in the database.
import json 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 part.models import Part
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.mixins import BarcodeMixin from plugin.mixins import BarcodeMixin
@ -18,121 +18,89 @@ from stock.models import StockItem, StockLocation
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin): 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.""" """Builtin BarcodePlugin for matching and generating internal barcodes."""
NAME = "InvenTreeBarcode" NAME = "InvenTreeInternalBarcode"
def validate(self): def scan(self, barcode_data):
"""Validate a barcode. """Scan a barcode against this plugin.
An "InvenTree" barcode must be a jsonnable-dict with the following tags: Here we are looking for a dict object which contains a reference to a particular InvenTree database object
{
'tool': 'InvenTree',
'version': <anything>
}
""" """
# The data must either be dict or be able to dictified
if type(self.data) is dict: if type(barcode_data) is dict:
pass pass
elif type(self.data) is str: elif type(barcode_data) is str:
try: try:
self.data = json.loads(self.data) barcode_data = json.loads(barcode_data)
if type(self.data) is not dict:
return False
except json.JSONDecodeError: except json.JSONDecodeError:
return False return None
else: else:
return False # pragma: no cover
# If any of the following keys are in the JSON data,
# let's go ahead and assume that the code is a valid InvenTree one...
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
try:
pk = int(data)
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:
item = StockItem.objects.get(pk=pk)
return item
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
raise ValidationError({k: "Stock item does not exist"})
return None return None
def getStockLocation(self): if type(barcode_data) is not dict:
"""Lookup StockLocation by 'stocklocation' key in barcode data."""
for k in self.data.keys():
if k.lower() == 'stocklocation':
pk = None
# First try simple integer lookup
try:
pk = int(self.data[k])
except (TypeError, ValueError): # pragma: no cover
pk = None
if pk is None: # pragma: no cover
# Lookup by 'id' field
try:
pk = self.data[k]['id']
except (AttributeError, KeyError):
raise ValidationError({k: "id parameter not supplied"})
try:
loc = StockLocation.objects.get(pk=pk)
return loc
except (ValueError, StockLocation.DoesNotExist): # pragma: no cover
raise ValidationError({k: "Stock location does not exist"})
return None return None
def getPart(self): # Look for various matches. First good match will be returned
"""Lookup Part by 'part' key in barcode data.""" for model in self.get_supported_barcode_models():
for k in self.data.keys(): label = model.barcode_model_type()
if k.lower() == 'part': if label in barcode_data:
pk = None
# Try integer lookup first
try: try:
pk = int(self.data[k]) instance = model.objects.get(pk=barcode_data[label])
except (TypeError, ValueError): # pragma: no cover return self.format_matched_response(label, model, instance)
pk = None 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: class InvenTreeExternalBarcodePlugin(InvenTreeBarcodePlugin):
part = Part.objects.get(pk=pk) """Builtin BarcodePlugin for matching arbitrary external barcodes."""
return part
except (ValueError, Part.DoesNotExist): # pragma: no cover
raise ValidationError({k: 'Part does not exist'})
return None NAME = "InvenTreeExternalBarcode"
def scan(self, barcode_data):
"""Scan a barcode against this plugin.
Here we are looking for a dict object which contains a reference to a particular InvenTree databse object
"""
for model in self.get_supported_barcode_models():
label = model.barcode_model_type()
barcode_hash = hash_barcode(barcode_data)
instance = model.lookup_barcode(barcode_hash)
if instance is not None:
return self.format_matched_response(label, model, instance)

View File

@ -2,8 +2,8 @@
from django.urls import reverse from django.urls import reverse
from rest_framework import status import part.models
import stock.models
from InvenTree.api_tester import InvenTreeAPITestCase from InvenTree.api_tester import InvenTreeAPITestCase
@ -14,21 +14,24 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
'category', 'category',
'part', 'part',
'location', 'location',
'stock' 'stock',
'company',
'supplier_part',
] ]
def test_errors(self): def test_assign_errors(self):
"""Test all possible error cases for assigment action.""" """Test error cases for assigment action."""
def test_assert_error(barcode_data): def test_assert_error(barcode_data):
response = self.client.post( response = self.post(
reverse('api-barcode-link'), format='json', reverse('api-barcode-link'), format='json',
data={ data={
'barcode': barcode_data, 'barcode': barcode_data,
'stockitem': 521 'stockitem': 521
} },
expected_code=400
) )
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('error', response.data) self.assertIn('error', response.data)
# test with already existing stock # test with already existing stock
@ -40,11 +43,358 @@ class TestInvenTreeBarcode(InvenTreeAPITestCase):
# test with already existing part location # test with already existing part location
test_assert_error('{"part": 10004}') test_assert_error('{"part": 10004}')
# test with hash def assign(self, data, expected_code=None):
test_assert_error('{"blbla": 10004}') """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.assertIn('success', response.data)
self.assertEqual(response.data['plugin'], 'InvenTreeExternalBarcode')
self.assertEqual(response.data['part']['pk'], 1)
# Attempting to assign the same barcode to a different part should result in an error
response = self.assign(
{
'barcode': barcode,
'part': 2,
},
expected_code=400,
)
self.assertIn('Barcode matches existing item', str(response.data['error']))
# Now test that we can unassign the barcode data also
response = self.unassign(
{
'part': 1,
},
expected_code=200,
)
p.refresh_from_db()
self.assertEqual(p.barcode_data, '')
self.assertEqual(p.barcode_hash, '')
def test_assign_to_location(self):
"""Test that we can assign a unique barcode to a StockLocation instance"""
barcode = '555555555555555555555555'
# Assign random barcode data to a StockLocation instance
response = self.assign(
data={
'barcode': barcode,
'stocklocation': 1,
},
expected_code=200,
)
self.assertIn('success', response.data)
self.assertEqual(response.data['stocklocation']['pk'], 1)
# Check that the StockLocation instance has been updated
loc = stock.models.StockLocation.objects.get(pk=1)
self.assertEqual(loc.barcode_data, barcode)
self.assertEqual(loc.barcode_hash, '4aa63f5e55e85c1f842796bf74896dbb')
# Check that an error is thrown if we try to assign the same value again
response = self.assign(
data={
'barcode': barcode,
'stocklocation': 2,
},
expected_code=400
)
self.assertIn('Barcode matches existing item', str(response.data['error']))
# Now, unassign the barcode
response = self.unassign(
{
'stocklocation': 1,
},
expected_code=200,
)
loc.refresh_from_db()
self.assertEqual(loc.barcode_data, '')
self.assertEqual(loc.barcode_hash, '')
def test_scan_third_party(self):
"""Test scanning of third-party barcodes"""
# First scanned barcode is for a 'third-party' barcode (which does not exist)
response = self.scan({'barcode': 'blbla=10008'}, expected_code=400)
self.assertEqual(response.data['error'], 'No match found for barcode data')
# Next scanned barcode is for a 'third-party' barcode (which does exist)
response = self.scan({'barcode': 'blbla=10004'}, expected_code=200)
self.assertEqual(response.data['barcode_data'], 'blbla=10004')
self.assertEqual(response.data['plugin'], 'InvenTreeExternalBarcode')
# Scan for a StockItem instance
si = stock.models.StockItem.objects.get(pk=1)
for barcode in ['abcde', 'ABCDE', '12345']:
si.assign_barcode(barcode_data=barcode)
response = self.scan(
{
'barcode': barcode,
},
expected_code=200,
)
self.assertIn('success', response.data)
self.assertEqual(response.data['stockitem']['pk'], 1)
def test_scan_inventree(self):
"""Test scanning of first-party barcodes"""
# Scan a StockItem object (which does not exist)
response = self.scan(
{
'barcode': '{"stockitem": 5}',
},
expected_code=400,
)
self.assertIn('No match found for barcode data', str(response.data))
# Scan a StockItem object (which does exist)
response = self.scan(
{
'barcode': '{"stockitem": 1}',
},
expected_code=200
)
self.assertIn('success', response.data)
self.assertIn('stockitem', response.data)
self.assertEqual(response.data['stockitem']['pk'], 1)
# Scan a StockLocation object
response = self.scan(
{
'barcode': '{"stocklocation": 5}',
},
expected_code=200,
)
self.assertIn('success', response.data)
self.assertEqual(response.data['stocklocation']['pk'], 5)
self.assertEqual(response.data['stocklocation']['api_url'], '/api/stock/location/5/')
self.assertEqual(response.data['stocklocation']['web_url'], '/stock/location/5/')
self.assertEqual(response.data['plugin'], 'InvenTreeInternalBarcode')
# Scan a Part object
response = self.scan(
{
'barcode': '{"part": 5}'
},
expected_code=200,
)
self.assertEqual(response.data['part']['pk'], 5)
# Scan a SupplierPart instance
response = self.scan(
{
'barcode': '{"supplierpart": 1}',
},
expected_code=200
)
self.assertEqual(response.data['supplierpart']['pk'], 1)
self.assertEqual(response.data['plugin'], 'InvenTreeInternalBarcode')
self.assertIn('success', response.data)
self.assertIn('barcode_data', response.data)
self.assertIn('barcode_hash', response.data)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,7 +30,8 @@ import report.models
from company import models as CompanyModels from company import models as CompanyModels
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField, from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
InvenTreeURLField) 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 InvenTree.status_codes import StockHistoryCode, StockStatus
from part import models as PartModels from part import models as PartModels
from plugin.events import trigger_event from plugin.events import trigger_event
@ -38,7 +39,7 @@ from plugin.models import MetadataMixin
from users.models import Owner from users.models import Owner
class StockLocation(MetadataMixin, InvenTreeTree): class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
"""Organization tree for StockItem objects. """Organization tree for StockItem objects.
A "StockLocation" can be considered a warehouse, or storage location A "StockLocation" can be considered a warehouse, or storage location
@ -126,27 +127,6 @@ class StockLocation(MetadataMixin, InvenTreeTree):
"""Return url for instance.""" """Return url for instance."""
return reverse('stock-location-detail', kwargs={'pk': self.id}) 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): def get_stock_items(self, cascade=True):
"""Return a queryset for all stock items under this category. """Return a queryset for all stock items under this category.
@ -221,12 +201,11 @@ def generate_batch_code():
return Template(batch_template).render(context) 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. """A StockItem object represents a quantity of physical instances of a part.
Attributes: Attributes:
parent: Link to another StockItem from which this StockItem was created 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 part: Link to the master abstract part that this StockItem is an instance of
supplier_part: Link to a specific SupplierPart (optional) supplier_part: Link to a specific SupplierPart (optional)
location: Where this StockItem is located location: Where this StockItem is located
@ -552,38 +531,6 @@ class StockItem(MetadataMixin, MPTTModel):
"""Returns part name.""" """Returns part name."""
return self.part.full_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 # Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship
parent = TreeForeignKey( parent = TreeForeignKey(
'self', 'self',

View File

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

View File

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

View File

@ -48,6 +48,11 @@
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button> <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'> <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> <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 %} {% if labels_enabled %}
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li> <li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
{% endif %} {% endif %}
@ -135,6 +140,13 @@
</td> </td>
</tr> </tr>
{% endif %} {% 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> </table>
{% endblock details_left %} {% endblock details_left %}
@ -335,6 +347,7 @@
adjustLocationStock('move'); adjustLocationStock('move');
}); });
{% if barcodes %}
$('#show-qr-code').click(function() { $('#show-qr-code').click(function() {
launchModalForm("{% url 'stock-location-qr' location.id %}", 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 %} {% endif %}
$('#item-create').click(function () { $('#item-create').click(function () {

View File

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

View File

@ -352,19 +352,17 @@ function barcodeScanDialog() {
/* /*
* Dialog for linking a particular barcode to a stock item. * Dialog for linking a particular barcode to a database model instsance
*/ */
function linkBarcodeDialog(stockitem) { function linkBarcodeDialog(data, options={}) {
var modal = '#modal-form'; var modal = '#modal-form';
barcodeDialog( barcodeDialog(
'{% trans "Link Barcode to Stock Item" %}', options.title,
{ {
url: '/api/barcode/link/', url: '/api/barcode/link/',
data: { data: data,
stockitem: stockitem,
},
onScan: function() { onScan: function() {
$(modal).modal('hide'); $(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>`; 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( showQuestionDialog(
'{% trans "Unlink Barcode" %}', '{% trans "Unlink Barcode" %}',
@ -391,13 +389,10 @@ function unlinkBarcode(stockitem) {
accept_text: '{% trans "Unlink" %}', accept_text: '{% trans "Unlink" %}',
accept: function() { accept: function() {
inventreePut( inventreePut(
`/api/stock/${stockitem}/`, '/api/barcode/unlink/',
data,
{ {
// Clear the UID field method: 'POST',
uid: '',
},
{
method: 'PATCH',
success: function() { success: function() {
location.reload(); location.reload();
}, },