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
|
||||||
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
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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)
|
||||||
|
@ -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'.
|
||||||
|
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
|
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:
|
||||||
|
@ -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");
|
||||||
}
|
}
|
||||||
|
@ -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'),
|
||||||
|
]))
|
||||||
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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(),
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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."""
|
||||||
|
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.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"""
|
||||||
|
@ -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 }}]);
|
||||||
|
@ -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"""
|
||||||
|
@ -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,85 +50,40 @@ 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)
|
|
||||||
|
|
||||||
|
|
||||||
class BarcodeAssign(APIView):
|
class BarcodeAssign(APIView):
|
||||||
@ -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']
|
||||||
|
|
||||||
try:
|
# Here we only check against 'InvenTree' plugins
|
||||||
item = StockItem.objects.get(pk=data['stockitem'])
|
plugins = [
|
||||||
except (ValueError, StockItem.DoesNotExist):
|
InvenTreeInternalBarcodePlugin(),
|
||||||
raise ValidationError({'stockitem': _('No matching stock item found')})
|
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:
|
raise ValidationError(result)
|
||||||
current_plugin.init(barcode_data)
|
|
||||||
|
|
||||||
if current_plugin.validate():
|
barcode_hash = hash_barcode(barcode_data)
|
||||||
plugin = current_plugin
|
|
||||||
break
|
|
||||||
|
|
||||||
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
|
instance.assign_barcode(
|
||||||
if plugin is not None:
|
barcode_data=barcode_data,
|
||||||
|
barcode_hash=barcode_hash,
|
||||||
|
)
|
||||||
|
|
||||||
result_hash = plugin.hash()
|
return Response({
|
||||||
response['hash'] = result_hash
|
'success': f"Assigned barcode to {label} instance",
|
||||||
response['plugin'] = plugin.name
|
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:
|
# If we got here, it means that no valid model types were provided
|
||||||
match_found = True
|
raise ValidationError({
|
||||||
response['error'] = _('Barcode already matches Stock Item')
|
'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:
|
class BarcodeUnassign(APIView):
|
||||||
match_found = True
|
"""Endpoint for unlinking / unassigning a custom barcode from a database object"""
|
||||||
response['error'] = _('Barcode already matches Part')
|
|
||||||
|
|
||||||
if not match_found:
|
permission_classes = [
|
||||||
item = plugin.getStockItemByHash()
|
permissions.IsAuthenticated,
|
||||||
|
]
|
||||||
|
|
||||||
if item is not None:
|
def post(self, request, *args, **kwargs):
|
||||||
response['error'] = _('Barcode hash already matches Stock Item')
|
"""Respond to a barcode unassign POST request"""
|
||||||
match_found = True
|
|
||||||
|
|
||||||
else:
|
# The following database models support assignment of third-party barcodes
|
||||||
result_hash = hash_barcode(barcode_data)
|
supported_models = InvenTreeExternalBarcodePlugin.get_supported_barcode_models()
|
||||||
|
|
||||||
response['hash'] = result_hash
|
supported_labels = [model.barcode_model_type() for model in supported_models]
|
||||||
response['plugin'] = None
|
model_names = ', '.join(supported_labels)
|
||||||
|
|
||||||
# Lookup stock item by hash
|
data = request.data
|
||||||
try:
|
|
||||||
item = StockItem.objects.get(uid=result_hash)
|
|
||||||
response['error'] = _('Barcode hash already matches Stock Item')
|
|
||||||
match_found = True
|
|
||||||
except StockItem.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not match_found:
|
matched_labels = []
|
||||||
response['success'] = _('Barcode associated with Stock Item')
|
|
||||||
|
|
||||||
# Save the barcode hash
|
for label in supported_labels:
|
||||||
item.uid = response['hash']
|
if label in data:
|
||||||
item.save()
|
matched_labels.append(label)
|
||||||
|
|
||||||
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_part_detail=True)
|
if len(matched_labels) == 0:
|
||||||
response['stockitem'] = serializer.data
|
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 = [
|
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'),
|
||||||
]
|
]
|
||||||
|
@ -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):
|
return None
|
||||||
"""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
|
|
||||||
|
@ -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={
|
||||||
|
@ -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
|
return None
|
||||||
|
|
||||||
# If any of the following keys are in the JSON data,
|
if type(barcode_data) is not dict:
|
||||||
# let's go ahead and assume that the code is a valid InvenTree one...
|
return None
|
||||||
|
|
||||||
for key in ['tool', 'version', 'InvenTree', 'stockitem', 'stocklocation', 'part']:
|
# Look for various matches. First good match will be returned
|
||||||
if key in self.data.keys():
|
for model in self.get_supported_barcode_models():
|
||||||
return True
|
label = model.barcode_model_type()
|
||||||
|
if label in barcode_data:
|
||||||
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:
|
try:
|
||||||
pk = int(data)
|
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):
|
||||||
item = StockItem.objects.get(pk=pk)
|
"""Builtin BarcodePlugin for matching arbitrary external barcodes."""
|
||||||
return item
|
|
||||||
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
|
|
||||||
raise ValidationError({k: "Stock item does not exist"})
|
|
||||||
|
|
||||||
return None
|
NAME = "InvenTreeExternalBarcode"
|
||||||
|
|
||||||
def getStockLocation(self):
|
def scan(self, barcode_data):
|
||||||
"""Lookup StockLocation by 'stocklocation' key in barcode data."""
|
"""Scan a barcode against this plugin.
|
||||||
for k in self.data.keys():
|
|
||||||
if k.lower() == 'stocklocation':
|
|
||||||
|
|
||||||
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
|
for model in self.get_supported_barcode_models():
|
||||||
try:
|
label = model.barcode_model_type()
|
||||||
pk = int(self.data[k])
|
|
||||||
except (TypeError, ValueError): # pragma: no cover
|
|
||||||
pk = None
|
|
||||||
|
|
||||||
if pk is None: # pragma: no cover
|
barcode_hash = hash_barcode(barcode_data)
|
||||||
# Lookup by 'id' field
|
|
||||||
try:
|
|
||||||
pk = self.data[k]['id']
|
|
||||||
except (AttributeError, KeyError):
|
|
||||||
raise ValidationError({k: "id parameter not supplied"})
|
|
||||||
|
|
||||||
try:
|
instance = model.lookup_barcode(barcode_hash)
|
||||||
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
|
if instance is not None:
|
||||||
|
return self.format_matched_response(label, model, instance)
|
||||||
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
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
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 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',
|
||||||
|
@ -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',
|
||||||
|
@ -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() {
|
||||||
|
@ -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 () {
|
||||||
|
@ -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):
|
||||||
|
@ -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();
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user