mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2957 from SchrodingersGat/locate-mixin
Adds plugin mixin to "locate" items
This commit is contained in:
commit
042cb021de
@ -142,6 +142,18 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
|
||||
return response
|
||||
|
||||
def put(self, url, data, expected_code=None, format='json'):
|
||||
"""
|
||||
Issue a PUT request
|
||||
"""
|
||||
|
||||
response = self.client.put(url, data=data, format=format)
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
||||
def options(self, url, expected_code=None):
|
||||
"""
|
||||
Issue an OPTIONS request
|
||||
|
@ -4,11 +4,16 @@ InvenTree API version information
|
||||
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 48
|
||||
INVENTREE_API_VERSION = 49
|
||||
|
||||
"""
|
||||
Increment this API version number whenever there is a significant change to the API that any clients need to know about
|
||||
|
||||
v49 -> 2022-05-09 : https://github.com/inventree/InvenTree/pull/2957
|
||||
- Allows filtering of plugin list by 'active' status
|
||||
- Allows filtering of plugin list by 'mixin' support
|
||||
- Adds endpoint to "identify" or "locate" stock items and locations (using plugins)
|
||||
|
||||
v48 -> 2022-05-12 : https://github.com/inventree/InvenTree/pull/2977
|
||||
- Adds "export to file" functionality for PurchaseOrder API endpoint
|
||||
- Adds "export to file" functionality for SalesOrder API endpoint
|
||||
|
@ -27,6 +27,8 @@ import order.serializers as serializers
|
||||
from part.models import Part
|
||||
from users.models import Owner
|
||||
|
||||
from plugin.serializers import MetadataSerializer
|
||||
|
||||
|
||||
class GeneralExtraLineList:
|
||||
"""
|
||||
@ -347,6 +349,15 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||
serializer_class = serializers.PurchaseOrderIssueSerializer
|
||||
|
||||
|
||||
class PurchaseOrderMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for viewing / updating PurchaseOrder metadata"""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return MetadataSerializer(models.PurchaseOrder, *args, **kwargs)
|
||||
|
||||
queryset = models.PurchaseOrder.objects.all()
|
||||
|
||||
|
||||
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to receive stock items against a purchase order.
|
||||
@ -916,6 +927,15 @@ class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
|
||||
serializer_class = serializers.SalesOrderCompleteSerializer
|
||||
|
||||
|
||||
class SalesOrderMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for viewing / updating SalesOrder metadata"""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return MetadataSerializer(models.SalesOrder, *args, **kwargs)
|
||||
|
||||
queryset = models.SalesOrder.objects.all()
|
||||
|
||||
|
||||
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
|
||||
"""
|
||||
API endpoint to allocation stock items against a SalesOrder,
|
||||
@ -1138,10 +1158,13 @@ order_api_urls = [
|
||||
|
||||
# Individual purchase order detail URLs
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
|
||||
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
|
||||
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
|
||||
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
|
||||
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
|
||||
re_path(r'^metadata/', PurchaseOrderMetadata.as_view(), name='api-po-metadata'),
|
||||
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
|
||||
|
||||
# PurchaseOrder detail API endpoint
|
||||
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
|
||||
])),
|
||||
|
||||
@ -1178,10 +1201,13 @@ order_api_urls = [
|
||||
|
||||
# Sales order detail view
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
|
||||
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
|
||||
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
||||
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
|
||||
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
|
||||
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
|
||||
re_path(r'^metadata/', SalesOrderMetadata.as_view(), name='api-so-metadata'),
|
||||
|
||||
# SalesOrder detail endpoint
|
||||
re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'),
|
||||
])),
|
||||
|
||||
|
23
InvenTree/order/migrations/0067_auto_20220516_1120.py
Normal file
23
InvenTree/order/migrations/0067_auto_20220516_1120.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.13 on 2022-05-16 11:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('order', '0066_alter_purchaseorder_supplier'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='purchaseorder',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesorder',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
]
|
@ -30,7 +30,9 @@ from users import models as UserModels
|
||||
from part import models as PartModels
|
||||
from stock import models as stock_models
|
||||
from company.models import Company, SupplierPart
|
||||
|
||||
from plugin.events import trigger_event
|
||||
from plugin.models import MetadataMixin
|
||||
|
||||
import InvenTree.helpers
|
||||
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
||||
@ -97,7 +99,7 @@ def get_next_so_number():
|
||||
return reference
|
||||
|
||||
|
||||
class Order(ReferenceIndexingMixin):
|
||||
class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||
""" Abstract model for an order.
|
||||
|
||||
Instances of this class:
|
||||
|
@ -306,6 +306,22 @@ class PurchaseOrderTest(OrderTest):
|
||||
|
||||
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
|
||||
|
||||
def test_po_metadata(self):
|
||||
url = reverse('api-po-metadata', kwargs={'pk': 1})
|
||||
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'metadata': {
|
||||
'yam': 'yum',
|
||||
}
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
order = models.PurchaseOrder.objects.get(pk=1)
|
||||
self.assertEqual(order.get_metadata('yam'), 'yum')
|
||||
|
||||
|
||||
class PurchaseOrderReceiveTest(OrderTest):
|
||||
"""
|
||||
@ -875,6 +891,22 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
|
||||
|
||||
def test_so_metadata(self):
|
||||
url = reverse('api-so-metadata', kwargs={'pk': 1})
|
||||
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'metadata': {
|
||||
'xyz': 'abc',
|
||||
}
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
order = models.SalesOrder.objects.get(pk=1)
|
||||
self.assertEqual(order.get_metadata('xyz'), 'abc')
|
||||
|
||||
|
||||
class SalesOrderAllocateTest(OrderTest):
|
||||
"""
|
||||
|
@ -44,6 +44,7 @@ from stock.models import StockItem, StockLocation
|
||||
from common.models import InvenTreeSetting
|
||||
from build.models import Build, BuildItem
|
||||
import order.models
|
||||
from plugin.serializers import MetadataSerializer
|
||||
|
||||
from . import serializers as part_serializers
|
||||
|
||||
@ -203,6 +204,15 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
return response
|
||||
|
||||
|
||||
class CategoryMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for viewing / updating PartCategory metadata"""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return MetadataSerializer(PartCategory, *args, **kwargs)
|
||||
|
||||
queryset = PartCategory.objects.all()
|
||||
|
||||
|
||||
class CategoryParameterList(generics.ListAPIView):
|
||||
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
|
||||
|
||||
@ -587,6 +597,17 @@ class PartScheduling(generics.RetrieveAPIView):
|
||||
return Response(schedule)
|
||||
|
||||
|
||||
class PartMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""
|
||||
API endpoint for viewing / updating Part metadata
|
||||
"""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return MetadataSerializer(Part, *args, **kwargs)
|
||||
|
||||
queryset = Part.objects.all()
|
||||
|
||||
|
||||
class PartSerialNumberDetail(generics.RetrieveAPIView):
|
||||
"""
|
||||
API endpoint for returning extra serial number information about a particular part
|
||||
@ -1912,7 +1933,15 @@ part_api_urls = [
|
||||
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
|
||||
re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'),
|
||||
# Category detail endpoints
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
|
||||
re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
|
||||
|
||||
# PartCategory detail endpoint
|
||||
re_path(r'^.*$', CategoryDetail.as_view(), name='api-part-category-detail'),
|
||||
])),
|
||||
|
||||
path('', CategoryList.as_view(), name='api-part-category-list'),
|
||||
])),
|
||||
|
||||
@ -1973,6 +2002,9 @@ part_api_urls = [
|
||||
# Endpoint for validating a BOM for the specific Part
|
||||
re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
|
||||
|
||||
# Part metadata
|
||||
re_path(r'^metadata/', PartMetadata.as_view(), name='api-part-metadata'),
|
||||
|
||||
# Part detail endpoint
|
||||
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
|
||||
])),
|
||||
|
23
InvenTree/part/migrations/0076_auto_20220516_0819.py
Normal file
23
InvenTree/part/migrations/0076_auto_20220516_0819.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.13 on 2022-05-16 08:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0075_auto_20211128_0151'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='partcategory',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
]
|
@ -46,29 +46,29 @@ from common.models import InvenTreeSetting
|
||||
|
||||
from InvenTree import helpers
|
||||
from InvenTree import validators
|
||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.helpers import decimal2string, normalize, decimal2money
|
||||
|
||||
import InvenTree.ready
|
||||
import InvenTree.tasks
|
||||
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.helpers import decimal2string, normalize, decimal2money
|
||||
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
|
||||
|
||||
import common.models
|
||||
from build import models as BuildModels
|
||||
from order import models as OrderModels
|
||||
from company.models import SupplierPart
|
||||
import part.settings as part_settings
|
||||
from stock import models as StockModels
|
||||
|
||||
import common.models
|
||||
|
||||
import part.settings as part_settings
|
||||
from plugin.models import MetadataMixin
|
||||
|
||||
|
||||
logger = logging.getLogger("inventree")
|
||||
|
||||
|
||||
class PartCategory(InvenTreeTree):
|
||||
class PartCategory(MetadataMixin, InvenTreeTree):
|
||||
""" PartCategory provides hierarchical organization of Part objects.
|
||||
|
||||
Attributes:
|
||||
@ -327,7 +327,7 @@ class PartManager(TreeManager):
|
||||
|
||||
|
||||
@cleanup.ignore
|
||||
class Part(MPTTModel):
|
||||
class Part(MetadataMixin, MPTTModel):
|
||||
""" The Part object represents an abstract part, the 'concept' of an actual entity.
|
||||
|
||||
An actual physical instance of a Part is a StockItem which is treated separately.
|
||||
|
@ -21,6 +21,85 @@ import build.models
|
||||
import order.models
|
||||
|
||||
|
||||
class PartCategoryAPITest(InvenTreeAPITestCase):
|
||||
"""Unit tests for the PartCategory API"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'company',
|
||||
'test_templates',
|
||||
'manufacturer_part',
|
||||
'supplier_part',
|
||||
'order',
|
||||
'stock',
|
||||
]
|
||||
|
||||
roles = [
|
||||
'part.change',
|
||||
'part.add',
|
||||
'part.delete',
|
||||
'part_category.change',
|
||||
'part_category.add',
|
||||
]
|
||||
|
||||
def test_category_list(self):
|
||||
|
||||
# List all part categories
|
||||
url = reverse('api-part-category-list')
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
# Filter by parent, depth=1
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'parent': 1,
|
||||
'cascade': False,
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
|
||||
# Filter by parent, cascading
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'parent': 1,
|
||||
'cascade': True,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
def test_category_metadata(self):
|
||||
"""Test metadata endpoint for the PartCategory"""
|
||||
|
||||
cat = PartCategory.objects.get(pk=1)
|
||||
|
||||
cat.metadata = {
|
||||
'foo': 'bar',
|
||||
'water': 'melon',
|
||||
'abc': 'xyz',
|
||||
}
|
||||
|
||||
cat.set_metadata('abc', 'ABC')
|
||||
|
||||
response = self.get(reverse('api-part-category-metadata', kwargs={'pk': 1}), expected_code=200)
|
||||
|
||||
metadata = response.data['metadata']
|
||||
|
||||
self.assertEqual(metadata['foo'], 'bar')
|
||||
self.assertEqual(metadata['water'], 'melon')
|
||||
self.assertEqual(metadata['abc'], 'ABC')
|
||||
|
||||
|
||||
class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
Tests for the various OPTIONS endpoints in the /part/ API
|
||||
@ -1021,6 +1100,59 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
self.assertEqual(data['in_stock'], 9000)
|
||||
self.assertEqual(data['unallocated_stock'], 9000)
|
||||
|
||||
def test_part_metadata(self):
|
||||
"""
|
||||
Tests for the part metadata endpoint
|
||||
"""
|
||||
|
||||
url = reverse('api-part-metadata', kwargs={'pk': 1})
|
||||
|
||||
part = Part.objects.get(pk=1)
|
||||
|
||||
# Metadata is initially null
|
||||
self.assertIsNone(part.metadata)
|
||||
|
||||
part.metadata = {'foo': 'bar'}
|
||||
part.save()
|
||||
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['metadata']['foo'], 'bar')
|
||||
|
||||
# Add more data via the API
|
||||
# Using the 'patch' method causes the new data to be merged in
|
||||
self.patch(
|
||||
url,
|
||||
{
|
||||
'metadata': {
|
||||
'hello': 'world',
|
||||
}
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
part.refresh_from_db()
|
||||
|
||||
self.assertEqual(part.metadata['foo'], 'bar')
|
||||
self.assertEqual(part.metadata['hello'], 'world')
|
||||
|
||||
# Now, issue a PUT request (existing data will be replacted)
|
||||
self.put(
|
||||
url,
|
||||
{
|
||||
'metadata': {
|
||||
'x': 'y'
|
||||
},
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
part.refresh_from_db()
|
||||
|
||||
self.assertFalse('foo' in part.metadata)
|
||||
self.assertFalse('hello' in part.metadata)
|
||||
self.assertEqual(part.metadata['x'], 'y')
|
||||
|
||||
|
||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
"""
|
||||
|
@ -199,7 +199,7 @@ class PartTest(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
part_2.validate_unique()
|
||||
|
||||
def test_metadata(self):
|
||||
def test_attributes(self):
|
||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||
|
||||
@ -245,6 +245,24 @@ class PartTest(TestCase):
|
||||
self.assertEqual(float(self.r1.get_internal_price(1)), 0.08)
|
||||
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
|
||||
|
||||
def test_metadata(self):
|
||||
"""Unit tests for the Part metadata field"""
|
||||
|
||||
p = Part.objects.get(pk=1)
|
||||
self.assertIsNone(p.metadata)
|
||||
|
||||
self.assertIsNone(p.get_metadata('test'))
|
||||
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
|
||||
|
||||
# Test update via the set_metadata() method
|
||||
p.set_metadata('test', 3)
|
||||
self.assertEqual(p.get_metadata('test'), 3)
|
||||
|
||||
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
|
||||
p.set_metadata(k, k)
|
||||
|
||||
self.assertEqual(len(p.metadata.keys()), 4)
|
||||
|
||||
|
||||
class TestTemplateTest(TestCase):
|
||||
|
||||
|
@ -8,9 +8,7 @@ from __future__ import unicode_literals
|
||||
from django.conf import settings
|
||||
from django.urls import include, re_path
|
||||
|
||||
from rest_framework import generics
|
||||
from rest_framework import status
|
||||
from rest_framework import permissions
|
||||
from rest_framework import filters, generics, permissions, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.response import Response
|
||||
|
||||
@ -19,6 +17,7 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from common.api import GlobalSettingsPermissions
|
||||
from plugin.base.barcodes.api import barcode_api_urls
|
||||
from plugin.base.action.api import ActionPluginView
|
||||
from plugin.base.locate.api import LocatePluginView
|
||||
from plugin.models import PluginConfig, PluginSetting
|
||||
import plugin.serializers as PluginSerializers
|
||||
from plugin.registry import registry
|
||||
@ -38,6 +37,35 @@ class PluginList(generics.ListAPIView):
|
||||
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||
queryset = PluginConfig.objects.all()
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter plugins which support a given mixin
|
||||
mixin = params.get('mixin', None)
|
||||
|
||||
if mixin:
|
||||
matches = []
|
||||
|
||||
for result in queryset:
|
||||
if mixin in result.mixins().keys():
|
||||
matches.append(result.pk)
|
||||
|
||||
queryset = queryset.filter(pk__in=matches)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'active',
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'key',
|
||||
'name',
|
||||
@ -163,6 +191,7 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
|
||||
plugin_api_urls = [
|
||||
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
||||
re_path(r'^barcode/', include(barcode_api_urls)),
|
||||
re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'),
|
||||
]
|
||||
|
||||
general_plugin_api_urls = [
|
||||
|
82
InvenTree/plugin/base/locate/api.py
Normal file
82
InvenTree/plugin/base/locate/api.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""API for location plugins"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import ParseError, NotFound
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
from plugin import registry
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
|
||||
class LocatePluginView(APIView):
|
||||
"""
|
||||
Endpoint for using a custom plugin to identify or 'locate' a stock item or location
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
# Which plugin to we wish to use?
|
||||
plugin = request.data.get('plugin', None)
|
||||
|
||||
if not plugin:
|
||||
raise ParseError("'plugin' field must be supplied")
|
||||
|
||||
# Check that the plugin exists, and supports the 'locate' mixin
|
||||
plugins = registry.with_mixin('locate')
|
||||
|
||||
if plugin not in [p.slug for p in plugins]:
|
||||
raise ParseError(f"Plugin '{plugin}' is not installed, or does not support the location mixin")
|
||||
|
||||
# StockItem to identify
|
||||
item_pk = request.data.get('item', None)
|
||||
|
||||
# StockLocation to identify
|
||||
location_pk = request.data.get('location', None)
|
||||
|
||||
if not item_pk and not location_pk:
|
||||
raise ParseError("Must supply either 'item' or 'location' parameter")
|
||||
|
||||
data = {
|
||||
"success": "Identification plugin activated",
|
||||
"plugin": plugin,
|
||||
}
|
||||
|
||||
# StockItem takes priority
|
||||
if item_pk:
|
||||
try:
|
||||
StockItem.objects.get(pk=item_pk)
|
||||
|
||||
offload_task('plugin.registry.call_function', plugin, 'locate_stock_item', item_pk)
|
||||
|
||||
data['item'] = item_pk
|
||||
|
||||
return Response(data)
|
||||
|
||||
except StockItem.DoesNotExist:
|
||||
raise NotFound("StockItem matching PK '{item}' not found")
|
||||
|
||||
elif location_pk:
|
||||
try:
|
||||
StockLocation.objects.get(pk=location_pk)
|
||||
|
||||
offload_task('plugin.registry.call_function', plugin, 'locate_stock_location', location_pk)
|
||||
|
||||
data['location'] = location_pk
|
||||
|
||||
return Response(data)
|
||||
|
||||
except StockLocation.DoesNotExist:
|
||||
raise NotFound("StockLocation matching PK {'location'} not found")
|
||||
|
||||
else:
|
||||
raise NotFound()
|
74
InvenTree/plugin/base/locate/mixins.py
Normal file
74
InvenTree/plugin/base/locate/mixins.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Plugin mixin for locating stock items and locations"""
|
||||
|
||||
import logging
|
||||
|
||||
from plugin.helpers import MixinImplementationError
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class LocateMixin:
|
||||
"""
|
||||
Mixin class which provides support for 'locating' inventory items,
|
||||
for example identifying the location of a particular StockLocation.
|
||||
|
||||
Plugins could implement audible or visual cues to direct attention to the location,
|
||||
with (for e.g.) LED strips or buzzers, or some other method.
|
||||
|
||||
The plugins may also be used to *deliver* a particular stock item to the user.
|
||||
|
||||
A class which implements this mixin may implement the following methods:
|
||||
|
||||
- locate_stock_item : Used to locate / identify a particular stock item
|
||||
- locate_stock_location : Used to locate / identify a particular stock location
|
||||
|
||||
Refer to the default method implementations below for more information!
|
||||
|
||||
"""
|
||||
|
||||
class MixinMeta:
|
||||
MIXIN_NAME = "Locate"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_mixin('locate', True, __class__)
|
||||
|
||||
def locate_stock_item(self, item_pk):
|
||||
"""
|
||||
Attempt to locate a particular StockItem
|
||||
|
||||
Arguments:
|
||||
item_pk: The PK (primary key) of the StockItem to be located
|
||||
|
||||
The default implementation for locating a StockItem
|
||||
attempts to locate the StockLocation where the item is located.
|
||||
|
||||
An attempt is only made if the StockItem is *in stock*
|
||||
|
||||
Note: A custom implemenation could always change this behaviour
|
||||
"""
|
||||
|
||||
logger.info(f"LocateMixin: Attempting to locate StockItem pk={item_pk}")
|
||||
|
||||
from stock.models import StockItem
|
||||
|
||||
try:
|
||||
item = StockItem.objects.get(pk=item_pk)
|
||||
|
||||
if item.in_stock and item.location is not None:
|
||||
self.locate_stock_location(item.location.pk)
|
||||
|
||||
except StockItem.DoesNotExist:
|
||||
logger.warning("LocateMixin: StockItem pk={item_pk} not found")
|
||||
pass
|
||||
|
||||
def locate_stock_location(self, location_pk):
|
||||
"""
|
||||
Attempt to location a particular StockLocation
|
||||
|
||||
Arguments:
|
||||
location_pk: The PK (primary key) of the StockLocation to be located
|
||||
|
||||
Note: The default implementation here does nothing!
|
||||
"""
|
||||
raise MixinImplementationError
|
@ -10,6 +10,7 @@ from ..base.action.mixins import ActionMixin
|
||||
from ..base.barcodes.mixins import BarcodeMixin
|
||||
from ..base.event.mixins import EventMixin
|
||||
from ..base.label.mixins import LabelPrintingMixin
|
||||
from ..base.locate.mixins import LocateMixin
|
||||
|
||||
__all__ = [
|
||||
'APICallMixin',
|
||||
@ -23,6 +24,7 @@ __all__ = [
|
||||
'PanelMixin',
|
||||
'ActionMixin',
|
||||
'BarcodeMixin',
|
||||
'LocateMixin',
|
||||
'SingleNotificationMethod',
|
||||
'BulkNotificationMethod',
|
||||
]
|
||||
|
@ -16,6 +16,65 @@ import common.models
|
||||
from plugin import InvenTreePlugin, registry
|
||||
|
||||
|
||||
class MetadataMixin(models.Model):
|
||||
"""
|
||||
Model mixin class which adds a JSON metadata field to a model,
|
||||
for use by any (and all) plugins.
|
||||
|
||||
The intent of this mixin is to provide a metadata field on a model instance,
|
||||
for plugins to read / modify as required, to store any extra information.
|
||||
|
||||
The assumptions for models implementing this mixin are:
|
||||
|
||||
- The internal InvenTree business logic will make no use of this field
|
||||
- Multiple plugins may read / write to this metadata field, and not assume they have sole rights
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
metadata = models.JSONField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Plugin Metadata'),
|
||||
help_text=_('JSON metadata field, for use by external plugins'),
|
||||
)
|
||||
|
||||
def get_metadata(self, key: str, backup_value=None):
|
||||
"""
|
||||
Finds metadata for this model instance, using the provided key for lookup
|
||||
|
||||
Args:
|
||||
key: String key for requesting metadata. e.g. if a plugin is accessing the metadata, the plugin slug should be used
|
||||
|
||||
Returns:
|
||||
Python dict object containing requested metadata. If no matching metadata is found, returns None
|
||||
"""
|
||||
|
||||
if self.metadata is None:
|
||||
return backup_value
|
||||
|
||||
return self.metadata.get(key, backup_value)
|
||||
|
||||
def set_metadata(self, key: str, data, commit=True):
|
||||
"""
|
||||
Save the provided metadata under the provided key.
|
||||
|
||||
Args:
|
||||
key: String key for saving metadata
|
||||
data: Data object to save - must be able to be rendered as a JSON string
|
||||
overwrite: If true, existing metadata with the provided key will be overwritten. If false, a merge will be attempted
|
||||
"""
|
||||
|
||||
if self.metadata is None:
|
||||
# Handle a null field value
|
||||
self.metadata = {}
|
||||
|
||||
self.metadata[key] = data
|
||||
|
||||
if commit:
|
||||
self.save()
|
||||
|
||||
|
||||
class PluginConfig(models.Model):
|
||||
"""
|
||||
A PluginConfig object holds settings for plugins.
|
||||
|
0
InvenTree/plugin/samples/locate/__init__.py
Normal file
0
InvenTree/plugin/samples/locate/__init__.py
Normal file
38
InvenTree/plugin/samples/locate/locate_sample.py
Normal file
38
InvenTree/plugin/samples/locate/locate_sample.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""
|
||||
Sample plugin for locating stock items / locations.
|
||||
|
||||
Note: This plugin does not *actually* locate anything!
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import LocateMixin
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
|
||||
"""
|
||||
A very simple example of the 'locate' plugin.
|
||||
This plugin class simply prints location information to the logger.
|
||||
"""
|
||||
|
||||
NAME = "SampleLocatePlugin"
|
||||
SLUG = "samplelocate"
|
||||
TITLE = "Sample plugin for locating items"
|
||||
|
||||
VERSION = "0.1"
|
||||
|
||||
def locate_stock_location(self, location_pk):
|
||||
|
||||
from stock.models import StockLocation
|
||||
|
||||
logger.info(f"SampleLocatePlugin attempting to locate location ID {location_pk}")
|
||||
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=location_pk)
|
||||
logger.info(f"Location exists at '{location.pathstring}'")
|
||||
except StockLocation.DoesNotExist:
|
||||
logger.error(f"Location ID {location_pk} does not exist!")
|
@ -19,6 +19,34 @@ from plugin.models import PluginConfig, PluginSetting, NotificationUserSetting
|
||||
from common.serializers import GenericReferencedSettingSerializer
|
||||
|
||||
|
||||
class MetadataSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer class for model metadata API access.
|
||||
"""
|
||||
|
||||
metadata = serializers.JSONField(required=True)
|
||||
|
||||
def __init__(self, model_type, *args, **kwargs):
|
||||
|
||||
self.Meta.model = model_type
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'metadata',
|
||||
]
|
||||
|
||||
def update(self, instance, data):
|
||||
|
||||
if self.partial:
|
||||
# Default behaviour is to "merge" new data in
|
||||
metadata = instance.metadata.copy() if instance.metadata else {}
|
||||
metadata.update(data['metadata'])
|
||||
data['metadata'] = metadata
|
||||
|
||||
return super().update(instance, data)
|
||||
|
||||
|
||||
class PluginConfigSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for a PluginConfig:
|
||||
|
@ -45,6 +45,14 @@ def mixin_enabled(plugin, key, *args, **kwargs):
|
||||
return plugin.mixin_enabled(key)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def mixin_available(mixin, *args, **kwargs):
|
||||
"""
|
||||
Returns True if there is at least one active plugin which supports the provided mixin
|
||||
"""
|
||||
return len(registry.with_mixin(mixin)) > 0
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def navigation_enabled(*args, **kwargs):
|
||||
"""
|
||||
|
@ -43,6 +43,8 @@ from order.serializers import PurchaseOrderSerializer
|
||||
from part.models import BomItem, Part, PartCategory
|
||||
from part.serializers import PartBriefSerializer
|
||||
|
||||
from plugin.serializers import MetadataSerializer
|
||||
|
||||
from stock.admin import StockItemResource
|
||||
from stock.models import StockLocation, StockItem
|
||||
from stock.models import StockItemTracking
|
||||
@ -92,6 +94,15 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
|
||||
class StockMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for viewing / updating StockItem metadata"""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return MetadataSerializer(StockItem, *args, **kwargs)
|
||||
|
||||
queryset = StockItem.objects.all()
|
||||
|
||||
|
||||
class StockItemContextMixin:
|
||||
""" Mixin class for adding StockItem object to serializer context """
|
||||
|
||||
@ -1368,6 +1379,15 @@ class StockTrackingList(generics.ListAPIView):
|
||||
]
|
||||
|
||||
|
||||
class LocationMetadata(generics.RetrieveUpdateAPIView):
|
||||
"""API endpoint for viewing / updating StockLocation metadata"""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
return MetadataSerializer(StockLocation, *args, **kwargs)
|
||||
|
||||
queryset = StockLocation.objects.all()
|
||||
|
||||
|
||||
class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of StockLocation object
|
||||
|
||||
@ -1385,7 +1405,14 @@ stock_api_urls = [
|
||||
|
||||
re_path(r'^tree/', StockLocationTree.as_view(), name='api-location-tree'),
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/', LocationDetail.as_view(), name='api-location-detail'),
|
||||
# Stock location detail endpoints
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
|
||||
re_path(r'^metadata/', LocationMetadata.as_view(), name='api-location-metadata'),
|
||||
|
||||
re_path(r'^.*$', LocationDetail.as_view(), name='api-location-detail'),
|
||||
])),
|
||||
|
||||
re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
|
||||
])),
|
||||
|
||||
@ -1417,8 +1444,9 @@ stock_api_urls = [
|
||||
|
||||
# Detail views for a single stock item
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
|
||||
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
|
||||
re_path(r'^metadata/', StockMetadata.as_view(), name='api-stock-item-metadata'),
|
||||
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
|
||||
re_path(r'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'),
|
||||
re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
|
||||
])),
|
||||
|
27
InvenTree/stock/migrations/0075_auto_20220515_1440.py
Normal file
27
InvenTree/stock/migrations/0075_auto_20220515_1440.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 3.2.13 on 2022-05-15 14:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0074_alter_stockitem_batch'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='stockitem',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='stocklocation',
|
||||
name='metadata',
|
||||
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='stocklocation',
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
@ -38,6 +38,7 @@ import common.models
|
||||
import report.models
|
||||
import label.models
|
||||
|
||||
from plugin.models import MetadataMixin
|
||||
from plugin.events import trigger_event
|
||||
|
||||
from InvenTree.status_codes import StockStatus, StockHistoryCode
|
||||
@ -51,7 +52,7 @@ from company import models as CompanyModels
|
||||
from part import models as PartModels
|
||||
|
||||
|
||||
class StockLocation(InvenTreeTree):
|
||||
class StockLocation(MetadataMixin, InvenTreeTree):
|
||||
""" Organization tree for StockItem objects
|
||||
A "StockLocation" can be considered a warehouse, or storage location
|
||||
Stock locations can be heirarchical as required
|
||||
@ -242,7 +243,7 @@ def generate_batch_code():
|
||||
return Template(batch_template).render(context)
|
||||
|
||||
|
||||
class StockItem(MPTTModel):
|
||||
class StockItem(MetadataMixin, MPTTModel):
|
||||
"""
|
||||
A StockItem object represents a quantity of physical instances of a part.
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
{% extends "page_base.html" %}
|
||||
{% load static %}
|
||||
{% load plugin_extras %}
|
||||
{% load inventree_extras %}
|
||||
{% load status_codes %}
|
||||
{% load i18n %}
|
||||
@ -18,7 +19,6 @@
|
||||
<div id="breadcrumb-tree"></div>
|
||||
{% endblock breadcrumb_tree %}
|
||||
|
||||
|
||||
{% block heading %}
|
||||
{% trans "Stock Item" %}: {{ item.part.full_name}}
|
||||
{% endblock heading %}
|
||||
@ -29,6 +29,12 @@
|
||||
{% url 'admin:stock_stockitem_change' item.pk as url %}
|
||||
{% include "admin_button.html" with url=url %}
|
||||
{% endif %}
|
||||
{% mixin_available "locate" as locate_available %}
|
||||
{% if plugins_enabled and locate_available %}
|
||||
<button id='locate-item-button' title='{% trans "Locate stock item" %}' class='btn btn-outline-secondary' typy='button'>
|
||||
<span class='fas fa-search-location'></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if barcodes %}
|
||||
<!-- Barcode actions menu -->
|
||||
<div class='btn-group' role='group'>
|
||||
@ -514,6 +520,14 @@ $("#barcode-scan-into-location").click(function() {
|
||||
});
|
||||
});
|
||||
|
||||
{% if plugins_enabled %}
|
||||
$('#locate-item-button').click(function() {
|
||||
locateItemOrLocation({
|
||||
item: {{ item.pk }},
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
function itemAdjust(action) {
|
||||
|
||||
inventreeGet(
|
||||
|
@ -1,6 +1,7 @@
|
||||
{% extends "stock/stock_app_base.html" %}
|
||||
{% load static %}
|
||||
{% load inventree_extras %}
|
||||
{% load plugin_extras %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block sidebar %}
|
||||
@ -27,6 +28,14 @@
|
||||
{% include "admin_button.html" with url=url %}
|
||||
{% endif %}
|
||||
|
||||
{% mixin_available "locate" as locate_available %}
|
||||
{% if location and plugins_enabled and locate_available %}
|
||||
<button id='locate-location-button' title='{% trans "Locate stock location" %}' class='btn btn-outline-secondary' typy='button'>
|
||||
<span class='fas fa-search-location'></span>
|
||||
</button>
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% if barcodes %}
|
||||
<!-- Barcode actions menu -->
|
||||
{% if location %}
|
||||
@ -206,6 +215,14 @@
|
||||
{% block js_ready %}
|
||||
{{ block.super }}
|
||||
|
||||
{% if plugins_enabled and location %}
|
||||
$('#locate-location-button').click(function() {
|
||||
locateItemOrLocation({
|
||||
location: {{ location.pk }},
|
||||
});
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
onPanelLoad('sublocations', function() {
|
||||
loadStockLocationTable($('#sublocation-table'), {
|
||||
params: {
|
||||
|
@ -2,6 +2,7 @@
|
||||
{% load i18n %}
|
||||
{% load inventree_extras %}
|
||||
|
||||
{% plugins_enabled as plugins_enabled %}
|
||||
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
||||
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
|
||||
{% settings_value "REPORT_ENABLE" as report_enabled %}
|
||||
|
@ -236,17 +236,13 @@ function selectLabel(labels, items, options={}) {
|
||||
if (plugins_enabled) {
|
||||
inventreeGet(
|
||||
`/api/plugin/`,
|
||||
{},
|
||||
{
|
||||
mixin: 'labels',
|
||||
},
|
||||
{
|
||||
async: false,
|
||||
success: function(response) {
|
||||
response.forEach(function(plugin) {
|
||||
// Look for active plugins which implement the 'labels' mixin class
|
||||
if (plugin.active && plugin.mixins && plugin.mixins.labels) {
|
||||
// This plugin supports label printing
|
||||
plugins.push(plugin);
|
||||
}
|
||||
});
|
||||
plugins = response;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -7,6 +7,7 @@
|
||||
|
||||
/* exported
|
||||
installPlugin,
|
||||
locateItemOrLocation
|
||||
*/
|
||||
|
||||
function installPlugin() {
|
||||
@ -24,3 +25,50 @@ function installPlugin() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function locateItemOrLocation(options={}) {
|
||||
|
||||
if (!options.item && !options.location) {
|
||||
console.error(`locateItemOrLocation: Either 'item' or 'location' must be provided!`);
|
||||
return;
|
||||
}
|
||||
|
||||
function performLocate(plugin) {
|
||||
inventreePut(
|
||||
'{% url "api-locate-plugin" %}',
|
||||
{
|
||||
plugin: plugin,
|
||||
item: options.item,
|
||||
location: options.location,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Request the list of available 'locate' plugins
|
||||
inventreeGet(
|
||||
`/api/plugin/`,
|
||||
{
|
||||
mixin: 'locate',
|
||||
},
|
||||
{
|
||||
success: function(plugins) {
|
||||
// No 'locate' plugins are available!
|
||||
if (plugins.length == 0) {
|
||||
console.warn(`No 'locate' plugins are available`);
|
||||
} else if (plugins.length == 1) {
|
||||
// Only a single locate plugin is available
|
||||
performLocate(plugins[0].key);
|
||||
} else {
|
||||
// More than 1 location plugin available
|
||||
// Select from a list
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user