diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index fe2057b453..c55c3d3ba3 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -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 diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index d5699f8de8..d2eab15468 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 7c8a93125f..966a01eb93 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -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\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\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'), ])), diff --git a/InvenTree/order/migrations/0067_auto_20220516_1120.py b/InvenTree/order/migrations/0067_auto_20220516_1120.py new file mode 100644 index 0000000000..0c5409cf35 --- /dev/null +++ b/InvenTree/order/migrations/0067_auto_20220516_1120.py @@ -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'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 1b9aae116c..e918f0a30c 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -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: diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 2ac7689434..76aa8670a4 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -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): """ diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 622ca38669..7213f8af4d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'), + # Category detail endpoints + re_path(r'^(?P\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'), ])), diff --git a/InvenTree/part/migrations/0076_auto_20220516_0819.py b/InvenTree/part/migrations/0076_auto_20220516_0819.py new file mode 100644 index 0000000000..5b02860aca --- /dev/null +++ b/InvenTree/part/migrations/0076_auto_20220516_0819.py @@ -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'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 6f288c5be5..64593e9756 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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. diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index f0770eb1f5..dc79a3a123 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -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): """ diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 5932c36757..f1bfcab40a 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -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): diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index f3710e4835..50e2b28795 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -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 = [ diff --git a/InvenTree/plugin/base/locate/api.py b/InvenTree/plugin/base/locate/api.py new file mode 100644 index 0000000000..30c6d749a9 --- /dev/null +++ b/InvenTree/plugin/base/locate/api.py @@ -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() diff --git a/InvenTree/plugin/base/locate/mixins.py b/InvenTree/plugin/base/locate/mixins.py new file mode 100644 index 0000000000..3f91b998c5 --- /dev/null +++ b/InvenTree/plugin/base/locate/mixins.py @@ -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 diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index de8ad4bc03..5a2bf95be6 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -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', ] diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 3d2d143eea..4db66a30fd 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -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. diff --git a/InvenTree/plugin/samples/locate/__init__.py b/InvenTree/plugin/samples/locate/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/InvenTree/plugin/samples/locate/locate_sample.py b/InvenTree/plugin/samples/locate/locate_sample.py new file mode 100644 index 0000000000..458b84cfa5 --- /dev/null +++ b/InvenTree/plugin/samples/locate/locate_sample.py @@ -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!") diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index b3c0471635..d03b892eb3 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -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: diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py index 3516aab0e3..5eca037104 100644 --- a/InvenTree/plugin/templatetags/plugin_extras.py +++ b/InvenTree/plugin/templatetags/plugin_extras.py @@ -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): """ diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 96a893e914..9f6c3b9191 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -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\d+)/', LocationDetail.as_view(), name='api-location-detail'), + # Stock location detail endpoints + re_path(r'^(?P\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\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'), ])), diff --git a/InvenTree/stock/migrations/0075_auto_20220515_1440.py b/InvenTree/stock/migrations/0075_auto_20220515_1440.py new file mode 100644 index 0000000000..814a97edb3 --- /dev/null +++ b/InvenTree/stock/migrations/0075_auto_20220515_1440.py @@ -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(), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 2d9a2c7f4a..f63f4c9241 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -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. diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 944e432026..da4b832266 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -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 @@ {% 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 %} + +{% endif %} {% if barcodes %}
@@ -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( diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 61320a2676..1066adf6ea 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -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 %} + + +{% endif %} + {% if barcodes %} {% 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: { diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html index 0d8272892a..795a5679aa 100644 --- a/InvenTree/templates/base.html +++ b/InvenTree/templates/base.html @@ -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 %} diff --git a/InvenTree/templates/js/translated/label.js b/InvenTree/templates/js/translated/label.js index d19c403861..388509c8bf 100644 --- a/InvenTree/templates/js/translated/label.js +++ b/InvenTree/templates/js/translated/label.js @@ -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; } } ); diff --git a/InvenTree/templates/js/translated/plugin.js b/InvenTree/templates/js/translated/plugin.js index c612dd1e8c..62555c8ff4 100644 --- a/InvenTree/templates/js/translated/plugin.js +++ b/InvenTree/templates/js/translated/plugin.js @@ -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 + } + } + }, + ); +} + +