From 59c747841c9accc67026177fb776f7ab98236559 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 22:01:27 +1000 Subject: [PATCH 01/27] Adds plugin mixin to "locate" items --- .../plugin/builtin/integration/mixins.py | 64 +++++++++++++++++++ InvenTree/plugin/mixins/__init__.py | 24 +++++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index b22efc9415..7a7c9c4404 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -432,6 +432,70 @@ class LabelPrintingMixin: ... # pragma: no cover +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('localte', 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 + """ + + 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: + 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! + """ + ... + + class APICallMixin: """ Mixin that enables easier API calls for a plugin diff --git a/InvenTree/plugin/mixins/__init__.py b/InvenTree/plugin/mixins/__init__.py index fdbe863e19..bf44e29897 100644 --- a/InvenTree/plugin/mixins/__init__.py +++ b/InvenTree/plugin/mixins/__init__.py @@ -2,7 +2,18 @@ Utility class to enable simpler imports """ -from ..builtin.integration.mixins import APICallMixin, AppMixin, LabelPrintingMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin, PanelMixin +from ..builtin.integration.mixins import ( + APICallMixin, + AppMixin, + EventMixin, + LabelPrintingMixin, + LocateMixin, + NavigationMixin, + PanelMixin, + ScheduleMixin, + SettingsMixin, + UrlsMixin, +) from common.notifications import SingleNotificationMethod, BulkNotificationMethod @@ -10,17 +21,18 @@ from ..builtin.action.mixins import ActionMixin from ..builtin.barcode.mixins import BarcodeMixin __all__ = [ + 'ActionMixin', 'APICallMixin', 'AppMixin', + 'BarcodeMixin', + 'BulkNotificationMethod', 'EventMixin', 'LabelPrintingMixin', + 'LocateMixin', + 'PanelMixin', 'NavigationMixin', 'ScheduleMixin', 'SettingsMixin', - 'UrlsMixin', - 'PanelMixin', - 'ActionMixin', - 'BarcodeMixin', 'SingleNotificationMethod', - 'BulkNotificationMethod', + 'UrlsMixin', ] From 172705cc3bb55ee31cb3be509cf70f20ad245240 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 22:13:07 +1000 Subject: [PATCH 02/27] Adds sample plugin for locating items --- .../plugin/samples/integration/locate.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 InvenTree/plugin/samples/integration/locate.py diff --git a/InvenTree/plugin/samples/integration/locate.py b/InvenTree/plugin/samples/integration/locate.py new file mode 100644 index 0000000000..37645da771 --- /dev/null +++ b/InvenTree/plugin/samples/integration/locate.py @@ -0,0 +1,34 @@ +""" +Sample plugin for locating stock items / locations. + +Note: This plugin does not *actually* locate anything! +""" + +import logging + +from plugin import IntegrationPluginBase +from plugin.mixins import LocateMixin + + +logger = logging.getLogger('inventree') + + +class SampleLocatePlugin(LocateMixin, IntegrationPluginBase): + + PLUGIN_NAME = "SampleLocatePlugin" + PLUGIN_SLUG = "samplelocate", + PLUGIN_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!") From 0a0a6a799ada9bb1db5bb0dca550fb3c8f3ff539 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 22:20:00 +1000 Subject: [PATCH 03/27] Fix for sample plugin --- .../plugin/samples/integration/{locate.py => locate_sample.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename InvenTree/plugin/samples/integration/{locate.py => locate_sample.py} (96%) diff --git a/InvenTree/plugin/samples/integration/locate.py b/InvenTree/plugin/samples/integration/locate_sample.py similarity index 96% rename from InvenTree/plugin/samples/integration/locate.py rename to InvenTree/plugin/samples/integration/locate_sample.py index 37645da771..af1df17707 100644 --- a/InvenTree/plugin/samples/integration/locate.py +++ b/InvenTree/plugin/samples/integration/locate_sample.py @@ -16,7 +16,7 @@ logger = logging.getLogger('inventree') class SampleLocatePlugin(LocateMixin, IntegrationPluginBase): PLUGIN_NAME = "SampleLocatePlugin" - PLUGIN_SLUG = "samplelocate", + PLUGIN_SLUG = "samplelocate" PLUGIN_TITLE = "Sample plugin for locating items" VERSION = "0.1" From 6aeb7d723d595c291891f217489dc9adcf5fa23c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 22:25:09 +1000 Subject: [PATCH 04/27] Fix typo in mixin name --- InvenTree/plugin/builtin/integration/mixins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 7a7c9c4404..84ed88c388 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -456,7 +456,7 @@ class LocateMixin: def __init__(self): super().__init__() - self.add_mixin('localte', True, __class__) + self.add_mixin('locate', True, __class__) def locate_stock_item(self, item_pk): """ From 26f32a0ce87b0ceb77064d0ab8957a2cb5a6d591 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 22:33:33 +1000 Subject: [PATCH 05/27] Plugin list API filters - Filter by "active" status - Filter by "mixin" support --- InvenTree/plugin/api.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index b9fd6e643d..29e8e4c603 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -7,9 +7,7 @@ from __future__ import unicode_literals 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 @@ -35,6 +33,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', From 256af802e27eaaafe2e7b6cdb54eb9ee4c9b284e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 22:35:46 +1000 Subject: [PATCH 06/27] Use the new 'mixin' filter to simplify request of label printer plugins --- InvenTree/templates/js/translated/label.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) 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; } } ); From 4704db5183f08d4c85c7aa961ebd554e5d4f5bd2 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 22:48:26 +1000 Subject: [PATCH 07/27] Adds 'locate item' and 'locate location' buttons --- InvenTree/plugin/templatetags/plugin_extras.py | 6 ++++++ InvenTree/stock/templates/stock/item_base.html | 8 +++++++- InvenTree/stock/templates/stock/location.html | 9 +++++++++ InvenTree/templates/base.html | 1 + 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py index a30f7ec2e4..fbd6c483ae 100644 --- a/InvenTree/plugin/templatetags/plugin_extras.py +++ b/InvenTree/plugin/templatetags/plugin_extras.py @@ -44,6 +44,12 @@ 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/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 944e432026..2d7877acf4 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 %}
diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 61320a2676..1e4f144107 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 plugins_enabled and locate_available %} + + +{% endif %} + {% if barcodes %} {% if location %} 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 %} From 9b7c26ec9cd314744ec7da8ca02d4aa316d51844 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 22:54:48 +1000 Subject: [PATCH 08/27] Bump API version --- InvenTree/InvenTree/api_version.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 86f41816b2..4285f61efb 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 = 46 +INVENTREE_API_VERSION = 47 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about +v47 -> 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) + v46 -> 2022-05-09 - Fixes read permissions on settings API - Allows non-staff users to read global settings via the API From 57f3efe7588ea4c76054198ed440bf2dfdfb443a Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 23:42:28 +1000 Subject: [PATCH 09/27] Adds an endpoint for calling the plugin code to "locate" something --- InvenTree/InvenTree/api.py | 75 ++++++++++++++++++- InvenTree/InvenTree/urls.py | 3 +- .../plugin/builtin/integration/mixins.py | 3 + .../stock/templates/stock/item_base.html | 8 ++ InvenTree/templates/js/translated/plugin.js | 48 ++++++++++++ 5 files changed, 135 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index 171fe414d2..368f414da6 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -13,13 +13,18 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters 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 .views import AjaxView from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName from .status import is_worker_running +from stock.models import StockItem, StockLocation + from plugin import registry @@ -114,7 +119,75 @@ class ActionPluginView(APIView): return Response(plugin.get_response()) # If we got to here, no matching action was found - return Response({ + raise NotFound({ 'error': _("No matching action found"), "action": action, }) + + +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: + item = 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: + location = StockItem.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/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 2b31d7c3b5..62524be89e 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -45,7 +45,7 @@ from .views import DynamicJsView from .views import NotificationsView from .api import InfoView, NotFoundView -from .api import ActionPluginView +from .api import ActionPluginView, LocatePluginView from users.api import user_urls @@ -75,6 +75,7 @@ apipatterns += [ # Plugin endpoints re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'), + re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'), # Webhook enpoint path('', include(common_api_urls)), diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index 84ed88c388..fc8bcce296 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -473,6 +473,8 @@ class LocateMixin: 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: @@ -482,6 +484,7 @@ class LocateMixin: 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): diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 2d7877acf4..cd0f8f00de 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -520,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/templates/js/translated/plugin.js b/InvenTree/templates/js/translated/plugin.js index c612dd1e8c..cac44222be 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( + '{% url "api-plugin-list" %}', + { + 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 + } + } + }, + ); +} + + From 224092e192c5b45fe22ea88428bb81719ac00d71 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 23:44:58 +1000 Subject: [PATCH 10/27] Fix typo --- InvenTree/InvenTree/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index 368f414da6..d5c9e6b38e 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -178,7 +178,7 @@ class LocatePluginView(APIView): elif location_pk: try: - location = StockItem.objects.get(pk=location_pk) + location = StockLocation.objects.get(pk=location_pk) offload_task('plugin.registry.call_function', plugin, 'locate_stock_location', location_pk) From 35fbb910e851e09a1d3b886e9823edb25043ac47 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 23:45:29 +1000 Subject: [PATCH 11/27] Enable button for locating a StockLocation --- InvenTree/stock/templates/stock/location.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 1e4f144107..ee27c6fe23 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -215,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: { From 6c0661a6f2c0aca0ac7f31c27180562a2d31486c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 9 May 2022 23:52:19 +1000 Subject: [PATCH 12/27] PEP style fixes --- InvenTree/InvenTree/api.py | 12 ++++++------ InvenTree/plugin/api.py | 2 +- InvenTree/plugin/builtin/integration/mixins.py | 8 ++++---- InvenTree/plugin/templatetags/plugin_extras.py | 2 ++ 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index d5c9e6b38e..bcec1f4a5d 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -138,7 +138,7 @@ class LocatePluginView(APIView): # Which plugin to we wish to use? plugin = request.data.get('plugin', None) - + if not plugin: raise ParseError("'plugin' field must be supplied") @@ -146,10 +146,10 @@ class LocatePluginView(APIView): 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") + 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) + item_pk = request.data.get('item', None) # StockLocation to identify location_pk = request.data.get('location', None) @@ -165,7 +165,7 @@ class LocatePluginView(APIView): # StockItem takes priority if item_pk: try: - item = StockItem.objects.get(pk=item_pk) + StockItem.objects.get(pk=item_pk) offload_task('plugin.registry.call_function', plugin, 'locate_stock_item', item_pk) @@ -175,10 +175,10 @@ class LocatePluginView(APIView): except StockItem.DoesNotExist: raise NotFound("StockItem matching PK '{item}' not found") - + elif location_pk: try: - location = StockLocation.objects.get(pk=location_pk) + StockLocation.objects.get(pk=location_pk) offload_task('plugin.registry.call_function', plugin, 'locate_stock_location', location_pk) diff --git a/InvenTree/plugin/api.py b/InvenTree/plugin/api.py index 29e8e4c603..22df8f2ac3 100644 --- a/InvenTree/plugin/api.py +++ b/InvenTree/plugin/api.py @@ -47,7 +47,7 @@ class PluginList(generics.ListAPIView): for result in queryset: if mixin in result.mixins().keys(): matches.append(result.pk) - + queryset = queryset.filter(pk__in=matches) return queryset diff --git a/InvenTree/plugin/builtin/integration/mixins.py b/InvenTree/plugin/builtin/integration/mixins.py index fc8bcce296..3bde6f217d 100644 --- a/InvenTree/plugin/builtin/integration/mixins.py +++ b/InvenTree/plugin/builtin/integration/mixins.py @@ -453,11 +453,11 @@ class LocateMixin: 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 @@ -490,10 +490,10 @@ class LocateMixin: 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! """ ... diff --git a/InvenTree/plugin/templatetags/plugin_extras.py b/InvenTree/plugin/templatetags/plugin_extras.py index fbd6c483ae..d183ae5483 100644 --- a/InvenTree/plugin/templatetags/plugin_extras.py +++ b/InvenTree/plugin/templatetags/plugin_extras.py @@ -44,6 +44,7 @@ def mixin_enabled(plugin, key, *args, **kwargs): """ return plugin.mixin_enabled(key) + @register.simple_tag() def mixin_available(mixin, *args, **kwargs): """ @@ -51,6 +52,7 @@ def mixin_available(mixin, *args, **kwargs): """ return len(registry.with_mixin(mixin)) > 0 + @register.simple_tag() def navigation_enabled(*args, **kwargs): """ From 5ea115ae83ac2839a735cf34dff89c70e189d406 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 10 May 2022 01:18:36 +1000 Subject: [PATCH 13/27] hard code url --- InvenTree/templates/js/translated/plugin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/templates/js/translated/plugin.js b/InvenTree/templates/js/translated/plugin.js index cac44222be..87415b74dc 100644 --- a/InvenTree/templates/js/translated/plugin.js +++ b/InvenTree/templates/js/translated/plugin.js @@ -50,7 +50,7 @@ function locateItemOrLocation(options={}) { // Request the list of available 'locate' plugins inventreeGet( - '{% url "api-plugin-list" %}', + `/api/plugin/`, { mixin: 'locate', }, From 26499816c1a5b1bc3d28b193e65d48b7a56cae29 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 10 May 2022 08:12:04 +1000 Subject: [PATCH 14/27] javascript lint --- InvenTree/templates/js/translated/plugin.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/templates/js/translated/plugin.js b/InvenTree/templates/js/translated/plugin.js index 87415b74dc..62555c8ff4 100644 --- a/InvenTree/templates/js/translated/plugin.js +++ b/InvenTree/templates/js/translated/plugin.js @@ -30,7 +30,7 @@ function installPlugin() { function locateItemOrLocation(options={}) { if (!options.item && !options.location) { - console.error("locateItemOrLocation: Either 'item' or 'location' must be provided!"); + console.error(`locateItemOrLocation: Either 'item' or 'location' must be provided!`); return; } @@ -58,7 +58,7 @@ function locateItemOrLocation(options={}) { success: function(plugins) { // No 'locate' plugins are available! if (plugins.length == 0) { - console.warn("No 'locate' plugins are available"); + console.warn(`No 'locate' plugins are available`); } else if (plugins.length == 1) { // Only a single locate plugin is available performLocate(plugins[0].key); From a17ea3ac102ad2e8fef31a9788444331508a30d8 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Tue, 10 May 2022 08:31:40 +1000 Subject: [PATCH 15/27] Change "identify" to "locate" for consistency with the app --- InvenTree/stock/templates/stock/item_base.html | 2 +- InvenTree/stock/templates/stock/location.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index cd0f8f00de..da4b832266 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -31,7 +31,7 @@ {% endif %} {% mixin_available "locate" as locate_available %} {% if plugins_enabled and locate_available %} - {% endif %} diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index ee27c6fe23..14d5fbfa4d 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -30,7 +30,7 @@ {% mixin_available "locate" as locate_available %} {% if plugins_enabled and locate_available %} - From 40513c556daca23a502f83bfb4bed688f75647e6 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 00:10:50 +1000 Subject: [PATCH 16/27] Remove locate button from top-level stock location --- InvenTree/stock/templates/stock/location.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html index 14d5fbfa4d..1066adf6ea 100644 --- a/InvenTree/stock/templates/stock/location.html +++ b/InvenTree/stock/templates/stock/location.html @@ -29,7 +29,7 @@ {% endif %} {% mixin_available "locate" as locate_available %} -{% if plugins_enabled and locate_available %} +{% if location and plugins_enabled and locate_available %} From eebdad154ac720b43027c5305632a1e37c7bd274 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 00:12:33 +1000 Subject: [PATCH 17/27] PEP fixes --- InvenTree/InvenTree/api.py | 2 -- InvenTree/plugin/base/locate/mixins.py | 1 - 2 files changed, 3 deletions(-) diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index caaccdc384..9d901516d5 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -14,8 +14,6 @@ from rest_framework import filters from rest_framework import permissions -from InvenTree.tasks import offload_task - from .views import AjaxView from .version import inventreeVersion, inventreeApiVersion, inventreeInstanceName from .status import is_worker_running diff --git a/InvenTree/plugin/base/locate/mixins.py b/InvenTree/plugin/base/locate/mixins.py index dbacda12a7..3f91b998c5 100644 --- a/InvenTree/plugin/base/locate/mixins.py +++ b/InvenTree/plugin/base/locate/mixins.py @@ -7,7 +7,6 @@ from plugin.helpers import MixinImplementationError logger = logging.getLogger('inventree') - class LocateMixin: """ Mixin class which provides support for 'locating' inventory items, From 818be32e319a8253f4833b387d724bf8cac75d97 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 00:35:34 +1000 Subject: [PATCH 18/27] Move location plugin sample into its own directory --- InvenTree/plugin/samples/locate/__init__.py | 0 InvenTree/plugin/samples/{integration => locate}/locate_sample.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 InvenTree/plugin/samples/locate/__init__.py rename InvenTree/plugin/samples/{integration => locate}/locate_sample.py (100%) 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/integration/locate_sample.py b/InvenTree/plugin/samples/locate/locate_sample.py similarity index 100% rename from InvenTree/plugin/samples/integration/locate_sample.py rename to InvenTree/plugin/samples/locate/locate_sample.py From 097afed78e8c25e103c78c37603706f3874c1fde Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 00:45:16 +1000 Subject: [PATCH 19/27] Adds metadata mixin class for adding JSON field to internal tables - Add metadata field to stocklocation - Add metadata field to stockitem --- InvenTree/plugin/models.py | 24 +++++++++++++++++ .../migrations/0075_auto_20220515_1440.py | 27 +++++++++++++++++++ InvenTree/stock/models.py | 5 ++-- 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 InvenTree/stock/migrations/0075_auto_20220515_1440.py diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 3d2d143eea..d84136c2bf 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -16,6 +16,30 @@ 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'), + ) + + class PluginConfig(models.Model): """ A PluginConfig object holds settings for plugins. 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 a46d43b007..acd021caa6 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. From 28b811d961f448deab0451ec6f49b3d2f9507300 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 00:47:00 +1000 Subject: [PATCH 20/27] PEP style fixes --- InvenTree/plugin/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index d84136c2bf..130db8d12e 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -23,11 +23,11 @@ class MetadataMixin(models.Model): 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 + - Multiple plugins may read / write to this metadata field, and not assume they have sole rights """ class Meta: From cd68d5a80ef377bd7ef3340996addf1829a9c21e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 19:14:46 +1000 Subject: [PATCH 21/27] Add metadata mixin to Part and PartCategory models --- .../migrations/0076_auto_20220516_0819.py | 23 +++++++++++++++++++ InvenTree/part/models.py | 16 ++++++------- 2 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 InvenTree/part/migrations/0076_auto_20220516_0819.py 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 96ffa581f4..09128cd6bc 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. From 37a74dbfefee6978804f8281573b5bed53c0396b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 20:20:32 +1000 Subject: [PATCH 22/27] Adds a metadata serializer class for accessing instance metadata via the API - Adds endpoint for Part - Adds endpoint for PartCategory - Adds endpoint for StockItem - Adds endpoint for StockLocation --- InvenTree/InvenTree/api_tester.py | 12 +++++++ InvenTree/part/api.py | 34 +++++++++++++++++++- InvenTree/part/test_api.py | 53 +++++++++++++++++++++++++++++++ InvenTree/part/test_part.py | 18 +++++++++++ InvenTree/plugin/models.py | 35 ++++++++++++++++++++ InvenTree/plugin/serializers.py | 30 +++++++++++++++++ InvenTree/stock/api.py | 33 +++++++++++++++++-- 7 files changed, 212 insertions(+), 3 deletions(-) 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/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/test_api.py b/InvenTree/part/test_api.py index f0770eb1f5..e138aee2fd 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1021,6 +1021,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..e36d929cfe 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -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/models.py b/InvenTree/plugin/models.py index 130db8d12e..d2d263d14c 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -39,6 +39,41 @@ class MetadataMixin(models.Model): 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): """ diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index b3c0471635..e4ca703d25 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -15,10 +15,40 @@ from django.utils import timezone from rest_framework import serializers +from InvenTree.helpers import str2bool + 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/stock/api.py b/InvenTree/stock/api.py index 96a893e914..b917c6d8ac 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,15 @@ 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 +1445,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'), ])), From a52ba05eb7e78d5b09e7a510a3f619502b16e9f3 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 21:19:01 +1000 Subject: [PATCH 23/27] Add unit test for PartCategory API endpoints --- InvenTree/part/test_api.py | 79 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index e138aee2fd..2adffa6368 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-detail', 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 From 21ebf562fdbc88137e16545422a956f581053a36 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 21:21:32 +1000 Subject: [PATCH 24/27] Adds metadata fields to PurchaseOrder and SalesOrder models --- .../migrations/0067_auto_20220516_1120.py | 23 +++++++++++++++++++ InvenTree/order/models.py | 4 +++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 InvenTree/order/migrations/0067_auto_20220516_1120.py 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 6ca5b7a293..d0e4f8d93b 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: From 5582c8ba4388a7e50715bda301249cdd439d8096 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 21:29:49 +1000 Subject: [PATCH 25/27] Add metadata endpoints for SalesOrder and PurchaseOrder models --- InvenTree/order/api.py | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 7c8a93125f..2f307fd1a1 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'), ])), From bd17458f37ea9f746b7a73bec6b370c92997b351 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 21:40:10 +1000 Subject: [PATCH 26/27] Add metadata endpoints for SalesOrder and PurchaseOrder models --- InvenTree/order/api.py | 4 ++-- InvenTree/order/test_api.py | 32 ++++++++++++++++++++++++++++++++ InvenTree/part/test_part.py | 2 +- InvenTree/plugin/models.py | 10 +++++----- InvenTree/plugin/serializers.py | 4 +--- InvenTree/stock/api.py | 5 ++--- 6 files changed, 43 insertions(+), 14 deletions(-) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index 2f307fd1a1..966a01eb93 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -354,7 +354,7 @@ class PurchaseOrderMetadata(generics.RetrieveUpdateAPIView): def get_serializer(self, *args, **kwargs): return MetadataSerializer(models.PurchaseOrder, *args, **kwargs) - + queryset = models.PurchaseOrder.objects.all() @@ -932,7 +932,7 @@ class SalesOrderMetadata(generics.RetrieveUpdateAPIView): def get_serializer(self, *args, **kwargs): return MetadataSerializer(models.SalesOrder, *args, **kwargs) - + queryset = models.SalesOrder.objects.all() 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/test_part.py b/InvenTree/part/test_part.py index e36d929cfe..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/') diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index d2d263d14c..4db66a30fd 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -45,16 +45,16 @@ class MetadataMixin(models.Model): 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. @@ -68,9 +68,9 @@ class MetadataMixin(models.Model): if self.metadata is None: # Handle a null field value self.metadata = {} - + self.metadata[key] = data - + if commit: self.save() diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index e4ca703d25..d03b892eb3 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -15,8 +15,6 @@ from django.utils import timezone from rest_framework import serializers -from InvenTree.helpers import str2bool - from plugin.models import PluginConfig, PluginSetting, NotificationUserSetting from common.serializers import GenericReferencedSettingSerializer @@ -37,7 +35,7 @@ class MetadataSerializer(serializers.ModelSerializer): fields = [ 'metadata', ] - + def update(self, instance, data): if self.partial: diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index b917c6d8ac..9f6c3b9191 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -1384,7 +1384,7 @@ class LocationMetadata(generics.RetrieveUpdateAPIView): def get_serializer(self, *args, **kwargs): return MetadataSerializer(StockLocation, *args, **kwargs) - + queryset = StockLocation.objects.all() @@ -1412,8 +1412,7 @@ stock_api_urls = [ re_path(r'^.*$', LocationDetail.as_view(), name='api-location-detail'), ])), - - + re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'), ])), From 7fa83d70ad77005d17f3dd4e13dc04839fa898ad Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 22:14:06 +1000 Subject: [PATCH 27/27] Fix for broken unit test --- InvenTree/part/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 2adffa6368..dc79a3a123 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -91,7 +91,7 @@ class PartCategoryAPITest(InvenTreeAPITestCase): cat.set_metadata('abc', 'ABC') - response = self.get(reverse('api-part-category-detail', kwargs={'pk': 1}), expected_code=200) + response = self.get(reverse('api-part-category-metadata', kwargs={'pk': 1}), expected_code=200) metadata = response.data['metadata']