Merge pull request #722 from SchrodingersGat/barcode

Create simple endpoint for barcode decode
This commit is contained in:
Oliver 2020-04-16 21:55:24 +10:00 committed by GitHub
commit cf5af4dc77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 646 additions and 58 deletions

142
InvenTree/InvenTree/api.py Normal file
View File

@ -0,0 +1,142 @@
"""
Main JSON interface views
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.utils.translation import ugettext as _
from django.http import JsonResponse
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.views import APIView
from .views import AjaxView
from .version import inventreeVersion, inventreeInstanceName
from plugins import plugins as inventree_plugins
# Load barcode plugins
print("INFO: Loading plugins")
barcode_plugins = inventree_plugins.load_barcode_plugins()
action_plugins = inventree_plugins.load_action_plugins()
class InfoView(AjaxView):
""" Simple JSON endpoint for InvenTree information.
Use to confirm that the server is running, etc.
"""
def get(self, request, *args, **kwargs):
data = {
'server': 'InvenTree',
'version': inventreeVersion(),
'instance': inventreeInstanceName(),
}
return JsonResponse(data)
class ActionPluginView(APIView):
"""
Endpoint for running custom action plugins.
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
action = request.data.get('action', None)
data = request.data.get('data', None)
if action is None:
return Response({
'error': _("No action specified")
})
for plugin_class in action_plugins:
if plugin_class.action_name() == action:
plugin = plugin_class(request.user, data=data)
plugin.perform_action()
return Response(plugin.get_response())
# If we got to here, no matching action was found
return Response({
'error': _("No matching action found"),
"action": action,
})
class BarcodePluginView(APIView):
"""
Endpoint for handling barcode scan requests.
Barcode data are decoded by the client application,
and sent to this endpoint (as a JSON object) for validation.
A barcode could follow the internal InvenTree barcode format,
or it could match to a third-party barcode format (e.g. Digikey).
"""
permission_classes = [
permissions.IsAuthenticated,
]
def post(self, request, *args, **kwargs):
response = {}
barcode_data = request.data.get('barcode', None)
print("Barcode data:")
print(barcode_data)
if barcode_data is None:
response['error'] = _('No barcode data provided')
else:
# Look for a barcode plugin that knows how to handle the data
for plugin_class in barcode_plugins:
# Instantiate the plugin with the provided plugin data
plugin = plugin_class(barcode_data)
if plugin.validate():
# Plugin should return a dict response
response = plugin.decode()
if type(response) is dict:
if 'success' not in response.keys() and 'error' not in response.keys():
response['success'] = _('Barcode successfully decoded')
else:
response = {
'error': _('Barcode plugin returned incorrect response')
}
response['plugin'] = plugin.plugin_name()
response['hash'] = plugin.hash()
break
if 'error' not in response and 'success' not in response:
response = {
'error': _('Unknown barcode format'),
}
# Include the original barcode data
response['barcode_data'] = barcode_data
print("Response:")
print(response)
return Response(response)

View File

@ -172,7 +172,7 @@ def WrapWithQuotes(text, quote='"'):
return text
def MakeBarcode(object_type, object_id, object_url, data={}):
def MakeBarcode(object_name, object_data):
""" Generate a string for a barcode. Adds some global InvenTree parameters.
Args:
@ -185,13 +185,12 @@ def MakeBarcode(object_type, object_id, object_url, data={}):
json string of the supplied data plus some other data
"""
# Add in some generic InvenTree data
data['type'] = object_type
data['id'] = object_id
data['url'] = object_url
data['tool'] = 'InvenTree'
data['instance'] = inventreeInstanceName()
data['version'] = inventreeVersion()
data = {
'tool': 'InvenTree',
'version': inventreeVersion(),
'instance': inventreeInstanceName(),
object_name: object_data
}
return json.dumps(data, sort_keys=True)

View File

@ -27,6 +27,22 @@ class APITests(APITestCase):
User = get_user_model()
User.objects.create_user(self.username, 'user@email.com', self.password)
def test_info_view(self):
"""
Test that we can read the 'info-view' endpoint.
"""
url = reverse('api-inventree-info')
response = self.client.get(url, format='json')
data = response.json()
self.assertIn('server', data)
self.assertIn('version', data)
self.assertIn('instance', data)
self.assertEquals('InvenTree', data['server'])
def test_get_token_fail(self):
""" Ensure that an invalid user cannot get a token """
@ -65,3 +81,7 @@ class APITests(APITestCase):
response = self.client.get(part_url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_barcode(self):
# TODO - Complete this
pass

View File

@ -113,15 +113,15 @@ class TestMakeBarcode(TestCase):
def test_barcode(self):
data = {
'animal': 'cat',
'legs': 3,
'noise': 'purr'
bc = helpers.MakeBarcode(
"part",
{
"id": 3,
"url": "www.google.com",
}
)
bc = helpers.MakeBarcode("part", 3, "www.google.com", data)
self.assertIn('animal', bc)
self.assertIn('part', bc)
self.assertIn('tool', bc)
self.assertIn('"tool": "InvenTree"', bc)

View File

@ -35,7 +35,8 @@ from rest_framework.documentation import include_docs_urls
from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView
from .views import InfoView
from .api import InfoView, BarcodePluginView, ActionPluginView
from users.urls import user_urls
@ -53,8 +54,12 @@ apipatterns = [
# User URLs
url(r'^user/', include(user_urls)),
# Plugin endpoints
url(r'^barcode/', BarcodePluginView.as_view(), name='api-barcode-plugin'),
url(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
# InvenTree information endpoint
url(r'^$', InfoView.as_view(), name='inventree-info'),
url(r'^$', InfoView.as_view(), name='api-inventree-info'),
]
settings_urls = [

View File

@ -22,7 +22,6 @@ from common.models import InvenTreeSetting
from .forms import DeleteForm, EditUserForm, SetPasswordForm
from .helpers import str2bool
from .version import inventreeVersion, inventreeInstanceName
from rest_framework import views
@ -416,22 +415,6 @@ class AjaxDeleteView(AjaxMixin, UpdateView):
return self.renderJsonResponse(request, form, data=data, context=context)
class InfoView(AjaxView):
""" Simple JSON endpoint for InvenTree information.
Use to confirm that the server is running, etc.
"""
def get(self, request, *args, **kwargs):
data = {
'server': 'InvenTree',
'version': inventreeVersion(),
'instance': inventreeInstanceName(),
}
return JsonResponse(data)
class EditUserView(AjaxUpdateView):
""" View for editing user information """

View File

@ -61,7 +61,7 @@ InvenTree | {% trans "Supplier Part" %}
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail-parts' part.supplier.id %}">{{ part.supplier.name }}</a></td></tr>
<tr>
<td></td>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "SKU" %}</td>
<td>{{ part.SKU }}</tr>
</tr>
@ -71,14 +71,14 @@ InvenTree | {% trans "Supplier Part" %}
<td>{% trans "Manufacturer" %}</td>
<td><a href="{% url 'company-detail-parts' part.manufacturer.id %}">{{ part.manufacturer.name }}</a></td></tr>
<tr>
<td></td>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "MPN" %}</td>
<td>{{ part.MPN }}</td>
</tr>
{% endif %}
{% if part.note %}
<tr>
<td></td>
<td><span class='fas fa-sticky-note'></span></td>
<td>{% trans "Note" %}</td>
<td>{{ part.note }}</td>
</tr>

View File

@ -74,6 +74,7 @@ class POList(generics.ListCreateAPIView):
data = queryset.values(
'pk',
'supplier',
'supplier_reference',
'supplier__name',
'supplier__image',
'reference',

View File

@ -69,6 +69,7 @@ class EditPurchaseOrderForm(HelperForm):
fields = [
'reference',
'supplier',
'supplier_reference',
'description',
'link',
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-04-15 03:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0018_auto_20200406_0151'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='supplier_reference',
field=models.CharField(blank=True, help_text='Supplier order reference', max_length=64),
),
]

View File

@ -131,6 +131,8 @@ class PurchaseOrder(Order):
help_text=_('Company')
)
supplier_reference = models.CharField(max_length=64, blank=True, help_text=_("Supplier order reference"))
received_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,

View File

@ -19,6 +19,7 @@ class POSerializer(InvenTreeModelSerializer):
fields = [
'pk',
'supplier',
'supplier_reference',
'reference',
'description',
'link',

View File

@ -63,15 +63,27 @@ InvenTree | {{ order }}
<table class='table'>
<col width='25'>
<tr>
<td><span class='fas fa-industry'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier }}</a></td>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>{% trans "Order Status" %}</td>
<td>{% order_status order.status %}</td>
</tr>
<tr>
<td><span class='fas fa-building'></span></td>
<td>{% trans "Supplier" %}</td>
<td><a href="{% url 'company-detail' order.supplier.id %}">{{ order.supplier.name }}</a></td>
</tr>
{% if order.supplier_reference %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Supplier Reference" %}</td>
<td>{{ order.supplier_reference }}</td>
</tr>
{% endif %}
{% if order.link %}
<tr>
<td><span class='fas fa-link'></span></td>

View File

@ -478,11 +478,11 @@ class Part(models.Model):
""" Return a JSON string for formatting a barcode for this Part object """
return helpers.MakeBarcode(
"Part",
self.id,
reverse('api-part-detail', kwargs={'pk': self.id}),
"part",
{
'name': self.name,
"id": self.id,
"name": self.full_name,
"url": reverse('api-part-detail', kwargs={'pk': self.id}),
}
)

View File

@ -96,6 +96,8 @@ class PartSerializer(InvenTreeModelSerializer):
queryset = queryset.prefetch_related('builds')
return queryset
# TODO - Include a 'category_detail' field which serializers the category object
class Meta:
model = Part
partial = True

View File

View File

View File

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
import plugins.plugin as plugin
class ActionPlugin(plugin.InvenTreePlugin):
"""
The ActionPlugin class is used to perform custom actions
"""
ACTION_NAME = ""
@classmethod
def action_name(cls):
"""
Return the action name for this plugin.
If the ACTION_NAME parameter is empty,
look at the PLUGIN_NAME instead.
"""
action = cls.ACTION_NAME
if not action:
action = cls.PLUGIN_NAME
return action
def __init__(self, user, data=None):
"""
An action plugin takes a user reference, and an optional dataset (dict)
"""
plugin.InvenTreePlugin.__init__(self)
self.user = user
self.data = data
def perform_action(self):
"""
Override this method to perform the action!
"""
pass
def get_result(self):
"""
Result of the action?
"""
# Re-implement this for cutsom actions
return False
def get_info(self):
"""
Extra info? Can be a string / dict / etc
"""
return None
def get_response(self):
"""
Return a response. Default implementation is a simple response
which can be overridden.
"""
return {
"action": self.action_name(),
"result": self.get_result(),
"info": self.get_info(),
}
class SimpleActionPlugin(ActionPlugin):
"""
An EXTREMELY simple action plugin which demonstrates
the capability of the ActionPlugin class
"""
PLUGIN_NAME = "SimpleActionPlugin"
ACTION_NAME = "simple"
def perform_action(self):
print("Action plugin in action!")
def get_info(self):
return {
"user": self.user.username,
"hello": "world",
}
def get_result(self):
return True

View File

View File

@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
import hashlib
from rest_framework.renderers import JSONRenderer
from stock.serializers import StockItemSerializer, LocationSerializer
from part.serializers import PartSerializer
import plugins.plugin as plugin
class BarcodePlugin(plugin.InvenTreePlugin):
"""
The BarcodePlugin class is the base class for any barcode plugin.
"""
def __init__(self, barcode_data):
plugin.InvenTreePlugin.__init__(self)
self.data = barcode_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.
"""
hash = hashlib.md5(str(self.data).encode())
return str(hash.hexdigest())
def validate(self):
"""
Default implementation returns False
"""
return False
def decode(self):
"""
Decode the barcode, and craft a response
"""
return None
def render_part(self, part):
"""
Render a Part object to JSON
Use the existing serializer to do this.
"""
serializer = PartSerializer(part)
return serializer.data
def render_stock_location(self, loc):
"""
Render a StockLocation object to JSON
Use the existing serializer to do this.
"""
serializer = LocationSerializer(loc)
return serializer.data
def render_stock_item(self, item):
"""
Render a StockItem object to JSON.
Use the existing serializer to do this
"""
serializer = StockItemSerializer(item, part_detail=True, location_detail=True, supplier_detail=True)
return serializer.data

View File

@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from . import barcode
class DigikeyBarcodePlugin(barcode.BarcodePlugin):
PLUGIN_NAME = "DigikeyBarcodePlugin"

View File

@ -0,0 +1,94 @@
"""
The InvenTreeBarcodePlugin validates barcodes generated by InvenTree itself.
It can be used as a template for developing third-party barcode plugins.
The data format is very simple, and maps directly to database objects,
via the "id" parameter.
Parsing an InvenTree barcode simply involves validating that the
references model objects actually exist in the database.
"""
# -*- coding: utf-8 -*-
import json
from . import barcode
from stock.models import StockItem, StockLocation
from part.models import Part
from django.utils.translation import ugettext as _
class InvenTreeBarcodePlugin(barcode.BarcodePlugin):
PLUGIN_NAME = "InvenTreeBarcodePlugin"
def validate(self):
"""
An "InvenTree" barcode must include the following tags:
{
'tool': 'InvenTree',
'version': <anything>
}
"""
# The data must either be dict or be able to dictified
if type(self.data) is dict:
pass
elif type(self.data) is str:
try:
self.data = json.loads(self.data)
except json.JSONDecodeError:
return False
else:
return False
for key in ['tool', 'version']:
if key not in self.data.keys():
return False
if not self.data['tool'] == 'InvenTree':
return False
return True
def decode(self):
response = {}
if 'part' in self.data.keys():
id = self.data['part'].get('id', None)
try:
part = Part.objects.get(id=id)
response['part'] = self.render_part(part)
except (ValueError, Part.DoesNotExist):
response['error'] = _('Part does not exist')
elif 'stocklocation' in self.data.keys():
id = self.data['stocklocation'].get('id', None)
try:
loc = StockLocation.objects.get(id=id)
response['stocklocation'] = self.render_stock_location(loc)
except (ValueError, StockLocation.DoesNotExist):
response['error'] = _('StockLocation does not exist')
elif 'stockitem' in self.data.keys():
id = self.data['stockitem'].get('id', None)
try:
item = StockItem.objects.get(id=id)
response['stockitem'] = self.render_stock_item(item)
except (ValueError, StockItem.DoesNotExist):
response['error'] = _('StockItem does not exist')
else:
response['error'] = _('No matching data')
return response

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
class InvenTreePlugin():
"""
Base class for a Barcode plugin
"""
# Override the plugin name for each concrete plugin instance
PLUGIN_NAME = ''
def plugin_name(self):
return self.PLUGIN_NAME
def __init__(self):
pass

View File

@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
import inspect
import importlib
import pkgutil
# Barcode plugins
import plugins.barcode as barcode
from plugins.barcode.barcode import BarcodePlugin
# Action plugins
import plugins.action as action
from plugins.action.action import ActionPlugin
def iter_namespace(pkg):
return pkgutil.iter_modules(pkg.__path__, pkg.__name__ + ".")
def get_modules(pkg):
# Return all modules in a given package
return [importlib.import_module(name) for finder, name, ispkg in iter_namespace(pkg)]
def get_classes(module):
# Return all classes in a given module
return inspect.getmembers(module, inspect.isclass)
def get_plugins(pkg, baseclass):
"""
Return a list of all modules under a given package.
- Modules must be a subclass of the provided 'baseclass'
- Modules must have a non-empty PLUGIN_NAME parameter
"""
plugins = []
modules = get_modules(pkg)
# Iterate through each module in the package
for mod in modules:
# Iterate through each class in the module
for item in get_classes(mod):
plugin = item[1]
if issubclass(plugin, baseclass) and plugin.PLUGIN_NAME:
plugins.append(plugin)
return plugins
def load_barcode_plugins():
"""
Return a list of all registered barcode plugins
"""
print("Loading barcode plugins")
plugins = get_plugins(barcode, BarcodePlugin)
if len(plugins) > 0:
print("Discovered {n} barcode plugins:".format(n=len(plugins)))
for bp in plugins:
print(" - {bp}".format(bp=bp.PLUGIN_NAME))
return plugins
def load_action_plugins():
"""
Return a list of all registered action plugins
"""
print("Loading action plugins")
plugins = get_plugins(action, ActionPlugin)
if len(plugins) > 0:
print("Discovered {n} action plugins:".format(n=len(plugins)))
for ap in plugins:
print(" - {ap}".format(ap=ap.PLUGIN_NAME))
return plugins

View File

@ -344,6 +344,7 @@ class StockList(generics.ListCreateAPIView):
data = queryset.values(
'pk',
'uid',
'parent',
'quantity',
'serial',
@ -540,7 +541,7 @@ class StockList(generics.ListCreateAPIView):
'supplier_part',
'customer',
'belongs_to',
'build'
'build',
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.10 on 2020-04-14 12:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0025_auto_20200405_2243'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='uid',
field=models.CharField(blank=True, help_text='Unique identifier field', max_length=128),
),
]

View File

@ -44,11 +44,11 @@ class StockLocation(InvenTreeTree):
""" Return a JSON string for formatting a barcode for this StockLocation object """
return helpers.MakeBarcode(
'StockLocation',
self.id,
reverse('api-location-detail', kwargs={'pk': self.id}),
'stocklocation',
{
'name': self.name,
"id": self.id,
"name": self.name,
"url": reverse('api-location-detail', kwargs={'pk': self.id}),
}
)
@ -108,6 +108,7 @@ class StockItem(MPTTModel):
Attributes:
parent: Link to another StockItem from which this StockItem was created
uid: Field containing a unique-id which is mapped to a third-party identifier (e.g. a barcode)
part: Link to the master abstract part that this StockItem is an instance of
supplier_part: Link to a specific SupplierPart (optional)
location: Where this StockItem is located
@ -288,15 +289,15 @@ class StockItem(MPTTModel):
"""
return helpers.MakeBarcode(
'StockItem',
self.id,
reverse('api-stock-detail', kwargs={'pk': self.id}),
"stockitem",
{
'part_id': self.part.id,
'part_name': self.part.full_name
"id": self.id,
"url": reverse('api-stock-detail', kwargs={'pk': self.id}),
}
)
uid = models.CharField(blank=True, max_length=128, help_text=("Unique identifier field"))
parent = TreeForeignKey('self',
on_delete=models.DO_NOTHING,
blank=True, null=True,

View File

@ -39,6 +39,7 @@ class StockItemSerializerBrief(InvenTreeModelSerializer):
model = StockItem
fields = [
'pk',
'uid',
'part',
'part_name',
'supplier_part',
@ -106,6 +107,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'status',
'status_text',
'tracking_items',
'uid',
'url',
]

View File

@ -85,7 +85,7 @@
</tr>
{% if item.belongs_to %}
<tr>
<td></td>
<td><span class='fas fa-box'></span></td>
<td>{% trans "Belongs To" %}</td>
<td><a href="{% url 'stock-item-detail' item.belongs_to.id %}">{{ item.belongs_to }}</a></td>
</tr>
@ -96,6 +96,13 @@
<td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td>
</tr>
{% endif %}
{% if item.uid %}
<tr>
<td><span class='fas fa-barcode'></span></td>
<td>{% trans "Unique Identifier" %}</td>
<td>{{ item.uid }}</td>
</tr>
{% endif %}
{% if item.serialized %}
<tr>
<td></td>