mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
7645492cc2
commit
187707c892
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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'.
|
||||
|
23
InvenTree/company/migrations/0048_auto_20220913_0312.py
Normal file
23
InvenTree/company/migrations/0048_auto_20220913_0312.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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:
|
||||
|
@ -30,6 +30,22 @@
|
||||
{% url 'admin:company_supplierpart_change' part.pk as url %}
|
||||
{% include "admin_button.html" with url=url %}
|
||||
{% endif %}
|
||||
{% if barcodes %}
|
||||
<!-- Barcode actions menu -->
|
||||
<div class='btn-group' role='group'>
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
|
||||
<span class='fas fa-qrcode'></span> <span class='caret'></span>
|
||||
</button>
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if part.barcode_hash %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
|
||||
{% else %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if roles.purchase_order.change or roles.purchase_order.add or roles.purchase_order.delete %}
|
||||
<div class='btn-group'>
|
||||
<button id='supplier-part-actions' title='{% trans "Supplier part actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'>
|
||||
@ -100,6 +116,13 @@ src="{% static 'img/blank_image.png' %}"
|
||||
<td>{% decimal part.available %}<span class='badge bg-dark rounded-pill float-right'>{% render_date part.availability_updated %}</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.barcode_hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-barcode'></span></td>
|
||||
<td>{% trans "Barcode Identifier" %}</td>
|
||||
<td {% if part.barcode_data %}title='{{ part.barcode_data }}'{% endif %}>{{ part.barcode_hash }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
{% endblock details %}
|
||||
@ -241,6 +264,33 @@ src="{% static 'img/blank_image.png' %}"
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if barcodes %}
|
||||
|
||||
$("#show-qr-code").click(function() {
|
||||
launchModalForm("{% url 'supplier-part-qr' part.pk %}",
|
||||
{
|
||||
no_post: true,
|
||||
});
|
||||
});
|
||||
|
||||
$("#barcode-link").click(function() {
|
||||
linkBarcodeDialog(
|
||||
{
|
||||
supplierpart: {{ part.pk }},
|
||||
},
|
||||
{
|
||||
title: '{% trans "Link Barcode to Supplier Part" %}',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#barcode-unlink").click(function() {
|
||||
unlinkBarcode({
|
||||
supplierpart: {{ part.pk }},
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
function reloadPriceBreaks() {
|
||||
$("#price-break-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
@ -25,5 +25,10 @@ manufacturer_part_urls = [
|
||||
]
|
||||
|
||||
supplier_part_urls = [
|
||||
re_path(r'^(?P<pk>\d+)/', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path('^qr_code/?', views.SupplierPartQRCode.as_view(), name='supplier-part-qr'),
|
||||
re_path('^.*$', views.SupplierPartDetail.as_view(template_name='company/supplier_part.html'), name='supplier-part-detail'),
|
||||
]))
|
||||
|
||||
|
||||
]
|
||||
|
@ -4,7 +4,7 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
from InvenTree.views import InvenTreeRoleMixin
|
||||
from InvenTree.views import InvenTreeRoleMixin, QRCodeView
|
||||
from plugin.views import InvenTreePluginViewMixin
|
||||
|
||||
from .models import Company, ManufacturerPart, SupplierPart
|
||||
@ -112,3 +112,18 @@ class SupplierPartDetail(InvenTreePluginViewMixin, DetailView):
|
||||
context_object_name = 'part'
|
||||
queryset = SupplierPart.objects.all()
|
||||
permission_required = 'purchase_order.view'
|
||||
|
||||
|
||||
class SupplierPartQRCode(QRCodeView):
|
||||
"""View for displaying a QR code for a StockItem object."""
|
||||
|
||||
ajax_form_title = _("Stock Item QR Code")
|
||||
role_required = 'stock.view'
|
||||
|
||||
def get_qr_data(self):
|
||||
"""Generate QR code data for the StockItem."""
|
||||
try:
|
||||
part = SupplierPart.objects.get(id=self.pk)
|
||||
return part.format_barcode()
|
||||
except SupplierPart.DoesNotExist:
|
||||
return None
|
||||
|
@ -249,7 +249,8 @@ class StockItemLabel(LabelTemplate):
|
||||
'revision': stock_item.part.revision,
|
||||
'quantity': normalize(stock_item.quantity),
|
||||
'serial': stock_item.serial,
|
||||
'uid': stock_item.uid,
|
||||
'barcode_data': stock_item.barcode_data,
|
||||
'barcode_hash': stock_item.barcode_hash,
|
||||
'qr_data': stock_item.format_barcode(brief=True),
|
||||
'qr_url': stock_item.format_barcode(url=True, request=request),
|
||||
'tests': stock_item.testResultMap(),
|
||||
|
@ -450,11 +450,11 @@ class PurchaseOrder(Order):
|
||||
notes = kwargs.get('notes', '')
|
||||
|
||||
# Extract optional barcode field
|
||||
barcode = kwargs.get('barcode', None)
|
||||
barcode_hash = kwargs.get('barcode', None)
|
||||
|
||||
# Prevent null values for barcode
|
||||
if barcode is None:
|
||||
barcode = ''
|
||||
if barcode_hash is None:
|
||||
barcode_hash = ''
|
||||
|
||||
if self.status != PurchaseOrderStatus.PLACED:
|
||||
raise ValidationError(
|
||||
@ -497,7 +497,7 @@ class PurchaseOrder(Order):
|
||||
batch=batch_code,
|
||||
serial=sn,
|
||||
purchase_price=line.purchase_price,
|
||||
uid=barcode
|
||||
barcode_hash=barcode_hash
|
||||
)
|
||||
|
||||
stock.save(add_note=False)
|
||||
|
@ -497,7 +497,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
if not barcode or barcode.strip() == '':
|
||||
return None
|
||||
|
||||
if stock.models.StockItem.objects.filter(uid=barcode).exists():
|
||||
if stock.models.StockItem.objects.filter(barcode_hash=barcode).exists():
|
||||
raise ValidationError(_('Barcode is already in use'))
|
||||
|
||||
return barcode
|
||||
|
@ -582,11 +582,11 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
"""Tests for checking in items with invalid barcodes:
|
||||
|
||||
- Cannot check in "duplicate" barcodes
|
||||
- Barcodes cannot match UID field for existing StockItem
|
||||
- Barcodes cannot match 'barcode_hash' field for existing StockItem
|
||||
"""
|
||||
# Set stock item barcode
|
||||
item = StockItem.objects.get(pk=1)
|
||||
item.uid = 'MY-BARCODE-HASH'
|
||||
item.barcode_hash = 'MY-BARCODE-HASH'
|
||||
item.save()
|
||||
|
||||
response = self.post(
|
||||
@ -705,8 +705,8 @@ class PurchaseOrderReceiveTest(OrderTest):
|
||||
self.assertEqual(stock_2.last().location.pk, 2)
|
||||
|
||||
# Barcodes should have been assigned to the stock items
|
||||
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-123').exists())
|
||||
self.assertTrue(StockItem.objects.filter(uid='MY-UNIQUE-BARCODE-456').exists())
|
||||
self.assertTrue(StockItem.objects.filter(barcode_hash='MY-UNIQUE-BARCODE-123').exists())
|
||||
self.assertTrue(StockItem.objects.filter(barcode_hash='MY-UNIQUE-BARCODE-456').exists())
|
||||
|
||||
def test_batch_code(self):
|
||||
"""Test that we can supply a 'batch code' when receiving items."""
|
||||
|
23
InvenTree/part/migrations/0086_auto_20220912_0007.py
Normal file
23
InvenTree/part/migrations/0086_auto_20220912_0007.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -43,7 +43,7 @@ from InvenTree import helpers, validators
|
||||
from InvenTree.fields import InvenTreeNotesField, InvenTreeURLField
|
||||
from InvenTree.helpers import decimal2money, decimal2string, normalize
|
||||
from InvenTree.models import (DataImportMixin, InvenTreeAttachment,
|
||||
InvenTreeTree)
|
||||
InvenTreeBarcodeMixin, InvenTreeTree)
|
||||
from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
|
||||
SalesOrderStatus)
|
||||
from order import models as OrderModels
|
||||
@ -300,7 +300,7 @@ class PartManager(TreeManager):
|
||||
|
||||
|
||||
@cleanup.ignore
|
||||
class Part(MetadataMixin, MPTTModel):
|
||||
class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
"""The Part object represents an abstract part, the 'concept' of an actual entity.
|
||||
|
||||
An actual physical instance of a Part is a StockItem which is treated separately.
|
||||
@ -941,18 +941,6 @@ class Part(MetadataMixin, MPTTModel):
|
||||
|
||||
responsible = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_('Responsible'), related_name='parts_responible')
|
||||
|
||||
def format_barcode(self, **kwargs):
|
||||
"""Return a JSON string for formatting a barcode for this Part object."""
|
||||
return helpers.MakeBarcode(
|
||||
"part",
|
||||
self.id,
|
||||
{
|
||||
"name": self.full_name,
|
||||
"url": reverse('api-part-detail', kwargs={'pk': self.id}),
|
||||
},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@property
|
||||
def category_path(self):
|
||||
"""Return the category path of this Part instance"""
|
||||
|
@ -45,6 +45,11 @@
|
||||
{% if barcodes %}
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% endif %}
|
||||
{% if part.barcode_hash %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unink Barcode" %}</a></li>
|
||||
{% else %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
|
||||
{% endif %}
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
@ -167,6 +172,7 @@
|
||||
<td>{% trans "Description" %}</td>
|
||||
<td>{{ part.description }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<!-- Part info messages -->
|
||||
@ -295,6 +301,13 @@
|
||||
<td>{{ part.keywords }}{% include "clip.html"%}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if part.barcode_hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-barcode'></span></td>
|
||||
<td>{% trans "Barcode Identifier" %}</td>
|
||||
<td {% if part.barcode_data %}title='{{ part.barcode_data }}'{% endif %}>{{ part.barcode_hash }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
<div class='col-sm-6'>
|
||||
@ -391,6 +404,7 @@
|
||||
}
|
||||
);
|
||||
|
||||
{% if barcodes %}
|
||||
$("#show-qr-code").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'part-qr' part.id %}",
|
||||
@ -400,6 +414,24 @@
|
||||
);
|
||||
});
|
||||
|
||||
$('#barcode-unlink').click(function() {
|
||||
unlinkBarcode({
|
||||
part: {{ part.pk }},
|
||||
});
|
||||
});
|
||||
|
||||
$('#barcode-link').click(function() {
|
||||
linkBarcodeDialog(
|
||||
{
|
||||
part: {{ part.pk }},
|
||||
},
|
||||
{
|
||||
title: '{% trans "Link Barcode to Part" %}',
|
||||
}
|
||||
);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if labels_enabled %}
|
||||
$('#print-label').click(function() {
|
||||
printPartLabels([{{ part.pk }}]);
|
||||
|
@ -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"""
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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={
|
||||
|
@ -9,8 +9,8 @@ references model objects actually exist in the database.
|
||||
|
||||
import json
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from company.models import SupplierPart
|
||||
from InvenTree.helpers import hash_barcode
|
||||
from part.models import Part
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import BarcodeMixin
|
||||
@ -18,121 +18,89 @@ from stock.models import StockItem, StockLocation
|
||||
|
||||
|
||||
class InvenTreeBarcodePlugin(BarcodeMixin, InvenTreePlugin):
|
||||
"""Generic base class for handling InvenTree barcodes"""
|
||||
|
||||
@staticmethod
|
||||
def get_supported_barcode_models():
|
||||
"""Returns a list of database models which support barcode functionality"""
|
||||
|
||||
return [
|
||||
Part,
|
||||
StockItem,
|
||||
StockLocation,
|
||||
SupplierPart,
|
||||
]
|
||||
|
||||
def format_matched_response(self, label, model, instance):
|
||||
"""Format a response for the scanned data"""
|
||||
|
||||
response = {
|
||||
'pk': instance.pk
|
||||
}
|
||||
|
||||
# Add in the API URL if available
|
||||
if hasattr(model, 'get_api_url'):
|
||||
response['api_url'] = f"{model.get_api_url()}{instance.pk}/"
|
||||
|
||||
# Add in the web URL if available
|
||||
if hasattr(instance, 'get_absolute_url'):
|
||||
response['web_url'] = instance.get_absolute_url()
|
||||
|
||||
return {label: response}
|
||||
|
||||
|
||||
class InvenTreeInternalBarcodePlugin(InvenTreeBarcodePlugin):
|
||||
"""Builtin BarcodePlugin for matching and generating internal barcodes."""
|
||||
|
||||
NAME = "InvenTreeBarcode"
|
||||
NAME = "InvenTreeInternalBarcode"
|
||||
|
||||
def validate(self):
|
||||
"""Validate a barcode.
|
||||
def scan(self, barcode_data):
|
||||
"""Scan a barcode against this plugin.
|
||||
|
||||
An "InvenTree" barcode must be a jsonnable-dict with the following tags:
|
||||
{
|
||||
'tool': 'InvenTree',
|
||||
'version': <anything>
|
||||
}
|
||||
Here we are looking for a dict object which contains a reference to a particular InvenTree database object
|
||||
"""
|
||||
# The data must either be dict or be able to dictified
|
||||
if type(self.data) is dict:
|
||||
|
||||
if type(barcode_data) is dict:
|
||||
pass
|
||||
elif type(self.data) is str:
|
||||
elif type(barcode_data) is str:
|
||||
try:
|
||||
self.data = json.loads(self.data)
|
||||
if type(self.data) is not dict:
|
||||
return False
|
||||
barcode_data = json.loads(barcode_data)
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
return None
|
||||
else:
|
||||
return False # pragma: no cover
|
||||
return None
|
||||
|
||||
# If any of the following keys are in the JSON data,
|
||||
# let's go ahead and assume that the code is a valid InvenTree one...
|
||||
if type(barcode_data) is not dict:
|
||||
return None
|
||||
|
||||
for key in ['tool', 'version', 'InvenTree', 'stockitem', 'stocklocation', 'part']:
|
||||
if key in self.data.keys():
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
def getStockItem(self):
|
||||
"""Lookup StockItem by 'stockitem' key in barcode data."""
|
||||
for k in self.data.keys():
|
||||
if k.lower() == 'stockitem':
|
||||
|
||||
data = self.data[k]
|
||||
|
||||
pk = None
|
||||
|
||||
# Initially try casting to an integer
|
||||
# Look for various matches. First good match will be returned
|
||||
for model in self.get_supported_barcode_models():
|
||||
label = model.barcode_model_type()
|
||||
if label in barcode_data:
|
||||
try:
|
||||
pk = int(data)
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
pk = None
|
||||
instance = model.objects.get(pk=barcode_data[label])
|
||||
return self.format_matched_response(label, model, instance)
|
||||
except (ValueError, model.DoesNotExist):
|
||||
pass
|
||||
|
||||
if pk is None: # pragma: no cover
|
||||
try:
|
||||
pk = self.data[k]['id']
|
||||
except (AttributeError, KeyError):
|
||||
raise ValidationError({k: "id parameter not supplied"})
|
||||
|
||||
try:
|
||||
item = StockItem.objects.get(pk=pk)
|
||||
return item
|
||||
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
|
||||
raise ValidationError({k: "Stock item does not exist"})
|
||||
class InvenTreeExternalBarcodePlugin(InvenTreeBarcodePlugin):
|
||||
"""Builtin BarcodePlugin for matching arbitrary external barcodes."""
|
||||
|
||||
return None
|
||||
NAME = "InvenTreeExternalBarcode"
|
||||
|
||||
def getStockLocation(self):
|
||||
"""Lookup StockLocation by 'stocklocation' key in barcode data."""
|
||||
for k in self.data.keys():
|
||||
if k.lower() == 'stocklocation':
|
||||
def scan(self, barcode_data):
|
||||
"""Scan a barcode against this plugin.
|
||||
|
||||
pk = None
|
||||
Here we are looking for a dict object which contains a reference to a particular InvenTree databse object
|
||||
"""
|
||||
|
||||
# First try simple integer lookup
|
||||
try:
|
||||
pk = int(self.data[k])
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
pk = None
|
||||
for model in self.get_supported_barcode_models():
|
||||
label = model.barcode_model_type()
|
||||
|
||||
if pk is None: # pragma: no cover
|
||||
# Lookup by 'id' field
|
||||
try:
|
||||
pk = self.data[k]['id']
|
||||
except (AttributeError, KeyError):
|
||||
raise ValidationError({k: "id parameter not supplied"})
|
||||
barcode_hash = hash_barcode(barcode_data)
|
||||
|
||||
try:
|
||||
loc = StockLocation.objects.get(pk=pk)
|
||||
return loc
|
||||
except (ValueError, StockLocation.DoesNotExist): # pragma: no cover
|
||||
raise ValidationError({k: "Stock location does not exist"})
|
||||
instance = model.lookup_barcode(barcode_hash)
|
||||
|
||||
return None
|
||||
|
||||
def getPart(self):
|
||||
"""Lookup Part by 'part' key in barcode data."""
|
||||
for k in self.data.keys():
|
||||
if k.lower() == 'part':
|
||||
|
||||
pk = None
|
||||
|
||||
# Try integer lookup first
|
||||
try:
|
||||
pk = int(self.data[k])
|
||||
except (TypeError, ValueError): # pragma: no cover
|
||||
pk = None
|
||||
|
||||
if pk is None: # pragma: no cover
|
||||
try:
|
||||
pk = self.data[k]['id']
|
||||
except (AttributeError, KeyError):
|
||||
raise ValidationError({k: 'id parameter not supplied'})
|
||||
|
||||
try:
|
||||
part = Part.objects.get(pk=pk)
|
||||
return part
|
||||
except (ValueError, Part.DoesNotExist): # pragma: no cover
|
||||
raise ValidationError({k: 'Part does not exist'})
|
||||
|
||||
return None
|
||||
if instance is not None:
|
||||
return self.format_matched_response(label, model, instance)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
23
InvenTree/stock/migrations/0084_auto_20220903_0154.py
Normal file
23
InvenTree/stock/migrations/0084_auto_20220903_0154.py
Normal 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'),
|
||||
),
|
||||
]
|
48
InvenTree/stock/migrations/0085_auto_20220903_0225.py
Normal file
48
InvenTree/stock/migrations/0085_auto_20220903_0225.py
Normal 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
|
||||
)
|
||||
]
|
17
InvenTree/stock/migrations/0086_remove_stockitem_uid.py
Normal file
17
InvenTree/stock/migrations/0086_remove_stockitem_uid.py
Normal 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',
|
||||
),
|
||||
]
|
23
InvenTree/stock/migrations/0087_auto_20220912_2341.py
Normal file
23
InvenTree/stock/migrations/0087_auto_20220912_2341.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -30,7 +30,8 @@ import report.models
|
||||
from company import models as CompanyModels
|
||||
from InvenTree.fields import (InvenTreeModelMoneyField, InvenTreeNotesField,
|
||||
InvenTreeURLField)
|
||||
from InvenTree.models import InvenTreeAttachment, InvenTreeTree, extract_int
|
||||
from InvenTree.models import (InvenTreeAttachment, InvenTreeBarcodeMixin,
|
||||
InvenTreeTree, extract_int)
|
||||
from InvenTree.status_codes import StockHistoryCode, StockStatus
|
||||
from part import models as PartModels
|
||||
from plugin.events import trigger_event
|
||||
@ -38,7 +39,7 @@ from plugin.models import MetadataMixin
|
||||
from users.models import Owner
|
||||
|
||||
|
||||
class StockLocation(MetadataMixin, InvenTreeTree):
|
||||
class StockLocation(InvenTreeBarcodeMixin, MetadataMixin, InvenTreeTree):
|
||||
"""Organization tree for StockItem objects.
|
||||
|
||||
A "StockLocation" can be considered a warehouse, or storage location
|
||||
@ -126,27 +127,6 @@ class StockLocation(MetadataMixin, InvenTreeTree):
|
||||
"""Return url for instance."""
|
||||
return reverse('stock-location-detail', kwargs={'pk': self.id})
|
||||
|
||||
def format_barcode(self, **kwargs):
|
||||
"""Return a JSON string for formatting a barcode for this StockLocation object."""
|
||||
return InvenTree.helpers.MakeBarcode(
|
||||
'stocklocation',
|
||||
self.pk,
|
||||
{
|
||||
"name": self.name,
|
||||
"url": reverse('api-location-detail', kwargs={'pk': self.id}),
|
||||
},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@property
|
||||
def barcode(self) -> str:
|
||||
"""Get Brief payload data (e.g. for labels).
|
||||
|
||||
Returns:
|
||||
str: Brief pyload data
|
||||
"""
|
||||
return self.format_barcode(brief=True)
|
||||
|
||||
def get_stock_items(self, cascade=True):
|
||||
"""Return a queryset for all stock items under this category.
|
||||
|
||||
@ -221,12 +201,11 @@ def generate_batch_code():
|
||||
return Template(batch_template).render(context)
|
||||
|
||||
|
||||
class StockItem(MetadataMixin, MPTTModel):
|
||||
class StockItem(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
|
||||
"""A StockItem object represents a quantity of physical instances of a part.
|
||||
|
||||
Attributes:
|
||||
parent: Link to another StockItem from which this StockItem was created
|
||||
uid: Field containing a unique-id which is mapped to a third-party identifier (e.g. a barcode)
|
||||
part: Link to the master abstract part that this StockItem is an instance of
|
||||
supplier_part: Link to a specific SupplierPart (optional)
|
||||
location: Where this StockItem is located
|
||||
@ -552,38 +531,6 @@ class StockItem(MetadataMixin, MPTTModel):
|
||||
"""Returns part name."""
|
||||
return self.part.full_name
|
||||
|
||||
def format_barcode(self, **kwargs):
|
||||
"""Return a JSON string for formatting a barcode for this StockItem.
|
||||
|
||||
Can be used to perform lookup of a stockitem using barcode.
|
||||
|
||||
Contains the following data:
|
||||
`{ type: 'StockItem', stock_id: <pk>, part_id: <part_pk> }`
|
||||
|
||||
Voltagile data (e.g. stock quantity) should be looked up using the InvenTree API (as it may change)
|
||||
"""
|
||||
return InvenTree.helpers.MakeBarcode(
|
||||
"stockitem",
|
||||
self.id,
|
||||
{
|
||||
"request": kwargs.get('request', None),
|
||||
"item_url": reverse('stock-item-detail', kwargs={'pk': self.id}),
|
||||
"url": reverse('api-stock-detail', kwargs={'pk': self.id}),
|
||||
},
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@property
|
||||
def barcode(self):
|
||||
"""Get Brief payload data (e.g. for labels).
|
||||
|
||||
Returns:
|
||||
str: Brief pyload data
|
||||
"""
|
||||
return self.format_barcode(brief=True)
|
||||
|
||||
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
|
||||
|
||||
# Note: When a StockItem is deleted, a pre_delete signal handles the parent/child relationship
|
||||
parent = TreeForeignKey(
|
||||
'self',
|
||||
|
@ -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',
|
||||
|
@ -44,7 +44,7 @@
|
||||
<ul class='dropdown-menu' role='menu'>
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if roles.stock.change %}
|
||||
{% if item.uid %}
|
||||
{% if item.barcode_hash %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
|
||||
{% else %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
|
||||
@ -155,11 +155,11 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{% if item.uid %}
|
||||
{% if item.barcode_hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-barcode'></span></td>
|
||||
<td>{% trans "Barcode Identifier" %}</td>
|
||||
<td>{{ item.uid }}</td>
|
||||
<td {% if item.barcode_data %}title='{{ item.barcode_data }}'{% endif %}>{{ item.barcode_hash }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if item.batch %}
|
||||
@ -529,12 +529,22 @@ $("#show-qr-code").click(function() {
|
||||
});
|
||||
});
|
||||
|
||||
{% if barcodes %}
|
||||
$("#barcode-link").click(function() {
|
||||
linkBarcodeDialog({{ item.id }});
|
||||
linkBarcodeDialog(
|
||||
{
|
||||
stockitem: {{ item.pk }},
|
||||
},
|
||||
{
|
||||
title: '{% trans "Link Barcode to Stock Item" %}',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#barcode-unlink").click(function() {
|
||||
unlinkBarcode({{ item.id }});
|
||||
unlinkBarcode({
|
||||
stockitem: {{ item.pk }},
|
||||
});
|
||||
});
|
||||
|
||||
$("#barcode-scan-into-location").click(function() {
|
||||
@ -545,6 +555,7 @@ $("#barcode-scan-into-location").click(function() {
|
||||
}
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if plugins_enabled %}
|
||||
$('#locate-item-button').click(function() {
|
||||
|
@ -48,6 +48,11 @@
|
||||
<button id='barcode-options' title='{% trans "Barcode actions" %}' class='btn btn-outline-secondary dropdown-toggle' type='button' data-bs-toggle='dropdown'><span class='fas fa-qrcode'></span> <span class='caret'></span></button>
|
||||
<ul class='dropdown-menu'>
|
||||
<li><a class='dropdown-item' href='#' id='show-qr-code'><span class='fas fa-qrcode'></span> {% trans "Show QR Code" %}</a></li>
|
||||
{% if location.barcode_hash %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-unlink'><span class='fas fa-unlink'></span> {% trans "Unlink Barcode" %}</a></li>
|
||||
{% else %}
|
||||
<li><a class='dropdown-item' href='#' id='barcode-link'><span class='fas fa-link'></span> {% trans "Link Barcode" %}</a></li>
|
||||
{% endif %}
|
||||
{% if labels_enabled %}
|
||||
<li><a class='dropdown-item' href='#' id='print-label'><span class='fas fa-tag'></span> {% trans "Print Label" %}</a></li>
|
||||
{% endif %}
|
||||
@ -135,6 +140,13 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if location and location.barcode_hash %}
|
||||
<tr>
|
||||
<td><span class='fas fa-barcode'></span></td>
|
||||
<td>{% trans "Barcode Identifier" %}</td>
|
||||
<td {% if location.barcode_data %}title='{{ location.barcode_data }}'{% endif %}>{{ location.barcode_hash }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endblock details_left %}
|
||||
|
||||
@ -335,6 +347,7 @@
|
||||
adjustLocationStock('move');
|
||||
});
|
||||
|
||||
{% if barcodes %}
|
||||
$('#show-qr-code').click(function() {
|
||||
launchModalForm("{% url 'stock-location-qr' location.id %}",
|
||||
{
|
||||
@ -342,6 +355,26 @@
|
||||
});
|
||||
});
|
||||
|
||||
$("#barcode-link").click(function() {
|
||||
linkBarcodeDialog(
|
||||
{
|
||||
stocklocation: {{ location.pk }},
|
||||
},
|
||||
{
|
||||
title: '{% trans "Link Barcode to Stock Location" %}',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
$("#barcode-unlink").click(function() {
|
||||
unlinkBarcode({
|
||||
stocklocation: {{ location.pk }},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
$('#item-create').click(function () {
|
||||
|
@ -14,8 +14,8 @@ from .models import (StockItem, StockItemTestResult, StockItemTracking,
|
||||
StockLocation)
|
||||
|
||||
|
||||
class StockTest(InvenTreeTestCase):
|
||||
"""Tests to ensure that the stock location tree functions correcly."""
|
||||
class StockTestBase(InvenTreeTestCase):
|
||||
"""Base class for running Stock tests"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
@ -44,6 +44,10 @@ class StockTest(InvenTreeTestCase):
|
||||
Part.objects.rebuild()
|
||||
StockItem.objects.rebuild()
|
||||
|
||||
|
||||
class StockTest(StockTestBase):
|
||||
"""Tests to ensure that the stock location tree functions correcly."""
|
||||
|
||||
def test_link(self):
|
||||
"""Test the link URL field validation"""
|
||||
|
||||
@ -151,12 +155,6 @@ class StockTest(InvenTreeTestCase):
|
||||
|
||||
self.assertEqual(self.home.get_absolute_url(), '/stock/location/1/')
|
||||
|
||||
def test_barcode(self):
|
||||
"""Test format_barcode."""
|
||||
barcode = self.office.format_barcode(brief=False)
|
||||
|
||||
self.assertIn('"name": "Office"', barcode)
|
||||
|
||||
def test_strings(self):
|
||||
"""Test str function."""
|
||||
it = StockItem.objects.get(pk=1)
|
||||
@ -724,7 +722,38 @@ class StockTest(InvenTreeTestCase):
|
||||
self.assertEqual(C22.get_ancestors().count(), 1)
|
||||
|
||||
|
||||
class VariantTest(StockTest):
|
||||
class StockBarcodeTest(StockTestBase):
|
||||
"""Run barcode tests for the stock app"""
|
||||
|
||||
def test_stock_item_barcode_basics(self):
|
||||
"""Simple tests for the StockItem barcode integration"""
|
||||
|
||||
item = StockItem.objects.get(pk=1)
|
||||
|
||||
self.assertEqual(StockItem.barcode_model_type(), 'stockitem')
|
||||
|
||||
# Call format_barcode method
|
||||
barcode = item.format_barcode(brief=False)
|
||||
|
||||
for key in ['tool', 'version', 'instance', 'stockitem']:
|
||||
self.assertIn(key, barcode)
|
||||
|
||||
# Render simple barcode data for the StockItem
|
||||
barcode = item.barcode
|
||||
self.assertEqual(barcode, '{"stockitem": 1}')
|
||||
|
||||
def test_location_barcode_basics(self):
|
||||
"""Simple tests for the StockLocation barcode integration"""
|
||||
|
||||
self.assertEqual(StockLocation.barcode_model_type(), 'stocklocation')
|
||||
|
||||
loc = StockLocation.objects.get(pk=1)
|
||||
|
||||
barcode = loc.format_barcode(brief=True)
|
||||
self.assertEqual('{"stocklocation": 1}', barcode)
|
||||
|
||||
|
||||
class VariantTest(StockTestBase):
|
||||
"""Tests for calculation stock counts against templates / variants."""
|
||||
|
||||
def test_variant_stock(self):
|
||||
@ -805,7 +834,7 @@ class VariantTest(StockTest):
|
||||
item.save()
|
||||
|
||||
|
||||
class TestResultTest(StockTest):
|
||||
class TestResultTest(StockTestBase):
|
||||
"""Tests for the StockItemTestResult model."""
|
||||
|
||||
def test_test_count(self):
|
||||
|
@ -352,19 +352,17 @@ function barcodeScanDialog() {
|
||||
|
||||
|
||||
/*
|
||||
* Dialog for linking a particular barcode to a stock item.
|
||||
* Dialog for linking a particular barcode to a database model instsance
|
||||
*/
|
||||
function linkBarcodeDialog(stockitem) {
|
||||
function linkBarcodeDialog(data, options={}) {
|
||||
|
||||
var modal = '#modal-form';
|
||||
|
||||
barcodeDialog(
|
||||
'{% trans "Link Barcode to Stock Item" %}',
|
||||
options.title,
|
||||
{
|
||||
url: '/api/barcode/link/',
|
||||
data: {
|
||||
stockitem: stockitem,
|
||||
},
|
||||
data: data,
|
||||
onScan: function() {
|
||||
|
||||
$(modal).modal('hide');
|
||||
@ -376,13 +374,13 @@ function linkBarcodeDialog(stockitem) {
|
||||
|
||||
|
||||
/*
|
||||
* Remove barcode association from a device.
|
||||
* Remove barcode association from a database model instance.
|
||||
*/
|
||||
function unlinkBarcode(stockitem) {
|
||||
function unlinkBarcode(data, options={}) {
|
||||
|
||||
var html = `<b>{% trans "Unlink Barcode" %}</b><br>`;
|
||||
|
||||
html += '{% trans "This will remove the association between this stock item and the barcode" %}';
|
||||
html += '{% trans "This will remove the link to the associated barcode" %}';
|
||||
|
||||
showQuestionDialog(
|
||||
'{% trans "Unlink Barcode" %}',
|
||||
@ -391,13 +389,10 @@ function unlinkBarcode(stockitem) {
|
||||
accept_text: '{% trans "Unlink" %}',
|
||||
accept: function() {
|
||||
inventreePut(
|
||||
`/api/stock/${stockitem}/`,
|
||||
'/api/barcode/unlink/',
|
||||
data,
|
||||
{
|
||||
// Clear the UID field
|
||||
uid: '',
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
method: 'POST',
|
||||
success: function() {
|
||||
location.reload();
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user