mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 11:10:54 +00:00
Merge pull request #2957 from SchrodingersGat/locate-mixin
Adds plugin mixin to "locate" items
This commit is contained in:
@ -8,9 +8,7 @@ from __future__ import unicode_literals
|
||||
from django.conf import settings
|
||||
from django.urls import include, re_path
|
||||
|
||||
from rest_framework import generics
|
||||
from rest_framework import status
|
||||
from rest_framework import permissions
|
||||
from rest_framework import filters, generics, permissions, status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.response import Response
|
||||
|
||||
@ -19,6 +17,7 @@ from django_filters.rest_framework import DjangoFilterBackend
|
||||
from common.api import GlobalSettingsPermissions
|
||||
from plugin.base.barcodes.api import barcode_api_urls
|
||||
from plugin.base.action.api import ActionPluginView
|
||||
from plugin.base.locate.api import LocatePluginView
|
||||
from plugin.models import PluginConfig, PluginSetting
|
||||
import plugin.serializers as PluginSerializers
|
||||
from plugin.registry import registry
|
||||
@ -38,6 +37,35 @@ class PluginList(generics.ListAPIView):
|
||||
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||
queryset = PluginConfig.objects.all()
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter plugins which support a given mixin
|
||||
mixin = params.get('mixin', None)
|
||||
|
||||
if mixin:
|
||||
matches = []
|
||||
|
||||
for result in queryset:
|
||||
if mixin in result.mixins().keys():
|
||||
matches.append(result.pk)
|
||||
|
||||
queryset = queryset.filter(pk__in=matches)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = [
|
||||
DjangoFilterBackend,
|
||||
filters.SearchFilter,
|
||||
filters.OrderingFilter,
|
||||
]
|
||||
|
||||
filter_fields = [
|
||||
'active',
|
||||
]
|
||||
|
||||
ordering_fields = [
|
||||
'key',
|
||||
'name',
|
||||
@ -163,6 +191,7 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
|
||||
plugin_api_urls = [
|
||||
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
||||
re_path(r'^barcode/', include(barcode_api_urls)),
|
||||
re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'),
|
||||
]
|
||||
|
||||
general_plugin_api_urls = [
|
||||
|
82
InvenTree/plugin/base/locate/api.py
Normal file
82
InvenTree/plugin/base/locate/api.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""API for location plugins"""
|
||||
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import ParseError, NotFound
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
from plugin import registry
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
|
||||
class LocatePluginView(APIView):
|
||||
"""
|
||||
Endpoint for using a custom plugin to identify or 'locate' a stock item or location
|
||||
"""
|
||||
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
# Which plugin to we wish to use?
|
||||
plugin = request.data.get('plugin', None)
|
||||
|
||||
if not plugin:
|
||||
raise ParseError("'plugin' field must be supplied")
|
||||
|
||||
# Check that the plugin exists, and supports the 'locate' mixin
|
||||
plugins = registry.with_mixin('locate')
|
||||
|
||||
if plugin not in [p.slug for p in plugins]:
|
||||
raise ParseError(f"Plugin '{plugin}' is not installed, or does not support the location mixin")
|
||||
|
||||
# StockItem to identify
|
||||
item_pk = request.data.get('item', None)
|
||||
|
||||
# StockLocation to identify
|
||||
location_pk = request.data.get('location', None)
|
||||
|
||||
if not item_pk and not location_pk:
|
||||
raise ParseError("Must supply either 'item' or 'location' parameter")
|
||||
|
||||
data = {
|
||||
"success": "Identification plugin activated",
|
||||
"plugin": plugin,
|
||||
}
|
||||
|
||||
# StockItem takes priority
|
||||
if item_pk:
|
||||
try:
|
||||
StockItem.objects.get(pk=item_pk)
|
||||
|
||||
offload_task('plugin.registry.call_function', plugin, 'locate_stock_item', item_pk)
|
||||
|
||||
data['item'] = item_pk
|
||||
|
||||
return Response(data)
|
||||
|
||||
except StockItem.DoesNotExist:
|
||||
raise NotFound("StockItem matching PK '{item}' not found")
|
||||
|
||||
elif location_pk:
|
||||
try:
|
||||
StockLocation.objects.get(pk=location_pk)
|
||||
|
||||
offload_task('plugin.registry.call_function', plugin, 'locate_stock_location', location_pk)
|
||||
|
||||
data['location'] = location_pk
|
||||
|
||||
return Response(data)
|
||||
|
||||
except StockLocation.DoesNotExist:
|
||||
raise NotFound("StockLocation matching PK {'location'} not found")
|
||||
|
||||
else:
|
||||
raise NotFound()
|
74
InvenTree/plugin/base/locate/mixins.py
Normal file
74
InvenTree/plugin/base/locate/mixins.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Plugin mixin for locating stock items and locations"""
|
||||
|
||||
import logging
|
||||
|
||||
from plugin.helpers import MixinImplementationError
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class LocateMixin:
|
||||
"""
|
||||
Mixin class which provides support for 'locating' inventory items,
|
||||
for example identifying the location of a particular StockLocation.
|
||||
|
||||
Plugins could implement audible or visual cues to direct attention to the location,
|
||||
with (for e.g.) LED strips or buzzers, or some other method.
|
||||
|
||||
The plugins may also be used to *deliver* a particular stock item to the user.
|
||||
|
||||
A class which implements this mixin may implement the following methods:
|
||||
|
||||
- locate_stock_item : Used to locate / identify a particular stock item
|
||||
- locate_stock_location : Used to locate / identify a particular stock location
|
||||
|
||||
Refer to the default method implementations below for more information!
|
||||
|
||||
"""
|
||||
|
||||
class MixinMeta:
|
||||
MIXIN_NAME = "Locate"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.add_mixin('locate', True, __class__)
|
||||
|
||||
def locate_stock_item(self, item_pk):
|
||||
"""
|
||||
Attempt to locate a particular StockItem
|
||||
|
||||
Arguments:
|
||||
item_pk: The PK (primary key) of the StockItem to be located
|
||||
|
||||
The default implementation for locating a StockItem
|
||||
attempts to locate the StockLocation where the item is located.
|
||||
|
||||
An attempt is only made if the StockItem is *in stock*
|
||||
|
||||
Note: A custom implemenation could always change this behaviour
|
||||
"""
|
||||
|
||||
logger.info(f"LocateMixin: Attempting to locate StockItem pk={item_pk}")
|
||||
|
||||
from stock.models import StockItem
|
||||
|
||||
try:
|
||||
item = StockItem.objects.get(pk=item_pk)
|
||||
|
||||
if item.in_stock and item.location is not None:
|
||||
self.locate_stock_location(item.location.pk)
|
||||
|
||||
except StockItem.DoesNotExist:
|
||||
logger.warning("LocateMixin: StockItem pk={item_pk} not found")
|
||||
pass
|
||||
|
||||
def locate_stock_location(self, location_pk):
|
||||
"""
|
||||
Attempt to location a particular StockLocation
|
||||
|
||||
Arguments:
|
||||
location_pk: The PK (primary key) of the StockLocation to be located
|
||||
|
||||
Note: The default implementation here does nothing!
|
||||
"""
|
||||
raise MixinImplementationError
|
@ -10,6 +10,7 @@ from ..base.action.mixins import ActionMixin
|
||||
from ..base.barcodes.mixins import BarcodeMixin
|
||||
from ..base.event.mixins import EventMixin
|
||||
from ..base.label.mixins import LabelPrintingMixin
|
||||
from ..base.locate.mixins import LocateMixin
|
||||
|
||||
__all__ = [
|
||||
'APICallMixin',
|
||||
@ -23,6 +24,7 @@ __all__ = [
|
||||
'PanelMixin',
|
||||
'ActionMixin',
|
||||
'BarcodeMixin',
|
||||
'LocateMixin',
|
||||
'SingleNotificationMethod',
|
||||
'BulkNotificationMethod',
|
||||
]
|
||||
|
@ -16,6 +16,65 @@ import common.models
|
||||
from plugin import InvenTreePlugin, registry
|
||||
|
||||
|
||||
class MetadataMixin(models.Model):
|
||||
"""
|
||||
Model mixin class which adds a JSON metadata field to a model,
|
||||
for use by any (and all) plugins.
|
||||
|
||||
The intent of this mixin is to provide a metadata field on a model instance,
|
||||
for plugins to read / modify as required, to store any extra information.
|
||||
|
||||
The assumptions for models implementing this mixin are:
|
||||
|
||||
- The internal InvenTree business logic will make no use of this field
|
||||
- Multiple plugins may read / write to this metadata field, and not assume they have sole rights
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
metadata = models.JSONField(
|
||||
blank=True, null=True,
|
||||
verbose_name=_('Plugin Metadata'),
|
||||
help_text=_('JSON metadata field, for use by external plugins'),
|
||||
)
|
||||
|
||||
def get_metadata(self, key: str, backup_value=None):
|
||||
"""
|
||||
Finds metadata for this model instance, using the provided key for lookup
|
||||
|
||||
Args:
|
||||
key: String key for requesting metadata. e.g. if a plugin is accessing the metadata, the plugin slug should be used
|
||||
|
||||
Returns:
|
||||
Python dict object containing requested metadata. If no matching metadata is found, returns None
|
||||
"""
|
||||
|
||||
if self.metadata is None:
|
||||
return backup_value
|
||||
|
||||
return self.metadata.get(key, backup_value)
|
||||
|
||||
def set_metadata(self, key: str, data, commit=True):
|
||||
"""
|
||||
Save the provided metadata under the provided key.
|
||||
|
||||
Args:
|
||||
key: String key for saving metadata
|
||||
data: Data object to save - must be able to be rendered as a JSON string
|
||||
overwrite: If true, existing metadata with the provided key will be overwritten. If false, a merge will be attempted
|
||||
"""
|
||||
|
||||
if self.metadata is None:
|
||||
# Handle a null field value
|
||||
self.metadata = {}
|
||||
|
||||
self.metadata[key] = data
|
||||
|
||||
if commit:
|
||||
self.save()
|
||||
|
||||
|
||||
class PluginConfig(models.Model):
|
||||
"""
|
||||
A PluginConfig object holds settings for plugins.
|
||||
|
0
InvenTree/plugin/samples/locate/__init__.py
Normal file
0
InvenTree/plugin/samples/locate/__init__.py
Normal file
38
InvenTree/plugin/samples/locate/locate_sample.py
Normal file
38
InvenTree/plugin/samples/locate/locate_sample.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""
|
||||
Sample plugin for locating stock items / locations.
|
||||
|
||||
Note: This plugin does not *actually* locate anything!
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import LocateMixin
|
||||
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
|
||||
"""
|
||||
A very simple example of the 'locate' plugin.
|
||||
This plugin class simply prints location information to the logger.
|
||||
"""
|
||||
|
||||
NAME = "SampleLocatePlugin"
|
||||
SLUG = "samplelocate"
|
||||
TITLE = "Sample plugin for locating items"
|
||||
|
||||
VERSION = "0.1"
|
||||
|
||||
def locate_stock_location(self, location_pk):
|
||||
|
||||
from stock.models import StockLocation
|
||||
|
||||
logger.info(f"SampleLocatePlugin attempting to locate location ID {location_pk}")
|
||||
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=location_pk)
|
||||
logger.info(f"Location exists at '{location.pathstring}'")
|
||||
except StockLocation.DoesNotExist:
|
||||
logger.error(f"Location ID {location_pk} does not exist!")
|
@ -19,6 +19,34 @@ from plugin.models import PluginConfig, PluginSetting, NotificationUserSetting
|
||||
from common.serializers import GenericReferencedSettingSerializer
|
||||
|
||||
|
||||
class MetadataSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer class for model metadata API access.
|
||||
"""
|
||||
|
||||
metadata = serializers.JSONField(required=True)
|
||||
|
||||
def __init__(self, model_type, *args, **kwargs):
|
||||
|
||||
self.Meta.model = model_type
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'metadata',
|
||||
]
|
||||
|
||||
def update(self, instance, data):
|
||||
|
||||
if self.partial:
|
||||
# Default behaviour is to "merge" new data in
|
||||
metadata = instance.metadata.copy() if instance.metadata else {}
|
||||
metadata.update(data['metadata'])
|
||||
data['metadata'] = metadata
|
||||
|
||||
return super().update(instance, data)
|
||||
|
||||
|
||||
class PluginConfigSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for a PluginConfig:
|
||||
|
@ -45,6 +45,14 @@ def mixin_enabled(plugin, key, *args, **kwargs):
|
||||
return plugin.mixin_enabled(key)
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def mixin_available(mixin, *args, **kwargs):
|
||||
"""
|
||||
Returns True if there is at least one active plugin which supports the provided mixin
|
||||
"""
|
||||
return len(registry.with_mixin(mixin)) > 0
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def navigation_enabled(*args, **kwargs):
|
||||
"""
|
||||
|
Reference in New Issue
Block a user