mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #2957 from SchrodingersGat/locate-mixin
Adds plugin mixin to "locate" items
This commit is contained in:
commit
042cb021de
@ -142,6 +142,18 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
|
|
||||||
return response
|
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):
|
def options(self, url, expected_code=None):
|
||||||
"""
|
"""
|
||||||
Issue an OPTIONS request
|
Issue an OPTIONS request
|
||||||
|
@ -4,11 +4,16 @@ InvenTree API version information
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# 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
|
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
|
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 PurchaseOrder API endpoint
|
||||||
- Adds "export to file" functionality for SalesOrder API endpoint
|
- Adds "export to file" functionality for SalesOrder API endpoint
|
||||||
|
@ -27,6 +27,8 @@ import order.serializers as serializers
|
|||||||
from part.models import Part
|
from part.models import Part
|
||||||
from users.models import Owner
|
from users.models import Owner
|
||||||
|
|
||||||
|
from plugin.serializers import MetadataSerializer
|
||||||
|
|
||||||
|
|
||||||
class GeneralExtraLineList:
|
class GeneralExtraLineList:
|
||||||
"""
|
"""
|
||||||
@ -347,6 +349,15 @@ class PurchaseOrderIssue(PurchaseOrderContextMixin, generics.CreateAPIView):
|
|||||||
serializer_class = serializers.PurchaseOrderIssueSerializer
|
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):
|
class PurchaseOrderReceive(PurchaseOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint to receive stock items against a purchase order.
|
API endpoint to receive stock items against a purchase order.
|
||||||
@ -916,6 +927,15 @@ class SalesOrderComplete(SalesOrderContextMixin, generics.CreateAPIView):
|
|||||||
serializer_class = serializers.SalesOrderCompleteSerializer
|
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):
|
class SalesOrderAllocateSerials(SalesOrderContextMixin, generics.CreateAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint to allocation stock items against a SalesOrder,
|
API endpoint to allocation stock items against a SalesOrder,
|
||||||
@ -1138,10 +1158,13 @@ order_api_urls = [
|
|||||||
|
|
||||||
# Individual purchase order detail URLs
|
# Individual purchase order detail URLs
|
||||||
re_path(r'^(?P<pk>\d+)/', include([
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
re_path(r'^issue/', PurchaseOrderIssue.as_view(), name='api-po-issue'),
|
|
||||||
re_path(r'^receive/', PurchaseOrderReceive.as_view(), name='api-po-receive'),
|
|
||||||
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
|
re_path(r'^cancel/', PurchaseOrderCancel.as_view(), name='api-po-cancel'),
|
||||||
re_path(r'^complete/', PurchaseOrderComplete.as_view(), name='api-po-complete'),
|
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'),
|
re_path(r'.*$', PurchaseOrderDetail.as_view(), name='api-po-detail'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
@ -1178,10 +1201,13 @@ order_api_urls = [
|
|||||||
|
|
||||||
# Sales order detail view
|
# Sales order detail view
|
||||||
re_path(r'^(?P<pk>\d+)/', include([
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
re_path(r'^cancel/', SalesOrderCancel.as_view(), name='api-so-cancel'),
|
|
||||||
re_path(r'^complete/', SalesOrderComplete.as_view(), name='api-so-complete'),
|
|
||||||
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
re_path(r'^allocate/', SalesOrderAllocate.as_view(), name='api-so-allocate'),
|
||||||
re_path(r'^allocate-serials/', SalesOrderAllocateSerials.as_view(), name='api-so-allocate-serials'),
|
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'),
|
re_path(r'^.*$', SalesOrderDetail.as_view(), name='api-so-detail'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
23
InvenTree/order/migrations/0067_auto_20220516_1120.py
Normal file
23
InvenTree/order/migrations/0067_auto_20220516_1120.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-05-16 11:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('order', '0066_alter_purchaseorder_supplier'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='purchaseorder',
|
||||||
|
name='metadata',
|
||||||
|
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='salesorder',
|
||||||
|
name='metadata',
|
||||||
|
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||||
|
),
|
||||||
|
]
|
@ -30,7 +30,9 @@ from users import models as UserModels
|
|||||||
from part import models as PartModels
|
from part import models as PartModels
|
||||||
from stock import models as stock_models
|
from stock import models as stock_models
|
||||||
from company.models import Company, SupplierPart
|
from company.models import Company, SupplierPart
|
||||||
|
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
|
from plugin.models import MetadataMixin
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
from InvenTree.fields import InvenTreeModelMoneyField, RoundingDecimalField
|
||||||
@ -97,7 +99,7 @@ def get_next_so_number():
|
|||||||
return reference
|
return reference
|
||||||
|
|
||||||
|
|
||||||
class Order(ReferenceIndexingMixin):
|
class Order(MetadataMixin, ReferenceIndexingMixin):
|
||||||
""" Abstract model for an order.
|
""" Abstract model for an order.
|
||||||
|
|
||||||
Instances of this class:
|
Instances of this class:
|
||||||
|
@ -306,6 +306,22 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(po.status, PurchaseOrderStatus.PLACED)
|
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):
|
class PurchaseOrderReceiveTest(OrderTest):
|
||||||
"""
|
"""
|
||||||
@ -875,6 +891,22 @@ class SalesOrderTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(so.status, SalesOrderStatus.CANCELLED)
|
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):
|
class SalesOrderAllocateTest(OrderTest):
|
||||||
"""
|
"""
|
||||||
|
@ -44,6 +44,7 @@ from stock.models import StockItem, StockLocation
|
|||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from build.models import Build, BuildItem
|
from build.models import Build, BuildItem
|
||||||
import order.models
|
import order.models
|
||||||
|
from plugin.serializers import MetadataSerializer
|
||||||
|
|
||||||
from . import serializers as part_serializers
|
from . import serializers as part_serializers
|
||||||
|
|
||||||
@ -203,6 +204,15 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return response
|
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):
|
class CategoryParameterList(generics.ListAPIView):
|
||||||
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
|
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
|
||||||
|
|
||||||
@ -587,6 +597,17 @@ class PartScheduling(generics.RetrieveAPIView):
|
|||||||
return Response(schedule)
|
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):
|
class PartSerialNumberDetail(generics.RetrieveAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for returning extra serial number information about a particular part
|
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'^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'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
|
||||||
|
|
||||||
re_path(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'),
|
# Category detail endpoints
|
||||||
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
|
|
||||||
|
re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
|
||||||
|
|
||||||
|
# PartCategory detail endpoint
|
||||||
|
re_path(r'^.*$', CategoryDetail.as_view(), name='api-part-category-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
path('', CategoryList.as_view(), name='api-part-category-list'),
|
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
|
# Endpoint for validating a BOM for the specific Part
|
||||||
re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
|
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
|
# Part detail endpoint
|
||||||
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
|
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
|
||||||
])),
|
])),
|
||||||
|
23
InvenTree/part/migrations/0076_auto_20220516_0819.py
Normal file
23
InvenTree/part/migrations/0076_auto_20220516_0819.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-05-16 08:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0075_auto_20211128_0151'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='part',
|
||||||
|
name='metadata',
|
||||||
|
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='partcategory',
|
||||||
|
name='metadata',
|
||||||
|
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||||
|
),
|
||||||
|
]
|
@ -46,29 +46,29 @@ from common.models import InvenTreeSetting
|
|||||||
|
|
||||||
from InvenTree import helpers
|
from InvenTree import helpers
|
||||||
from InvenTree import validators
|
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.ready
|
||||||
import InvenTree.tasks
|
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
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
|
||||||
|
|
||||||
|
import common.models
|
||||||
from build import models as BuildModels
|
from build import models as BuildModels
|
||||||
from order import models as OrderModels
|
from order import models as OrderModels
|
||||||
from company.models import SupplierPart
|
from company.models import SupplierPart
|
||||||
|
import part.settings as part_settings
|
||||||
from stock import models as StockModels
|
from stock import models as StockModels
|
||||||
|
|
||||||
import common.models
|
from plugin.models import MetadataMixin
|
||||||
|
|
||||||
import part.settings as part_settings
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger("inventree")
|
logger = logging.getLogger("inventree")
|
||||||
|
|
||||||
|
|
||||||
class PartCategory(InvenTreeTree):
|
class PartCategory(MetadataMixin, InvenTreeTree):
|
||||||
""" PartCategory provides hierarchical organization of Part objects.
|
""" PartCategory provides hierarchical organization of Part objects.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
@ -327,7 +327,7 @@ class PartManager(TreeManager):
|
|||||||
|
|
||||||
|
|
||||||
@cleanup.ignore
|
@cleanup.ignore
|
||||||
class Part(MPTTModel):
|
class Part(MetadataMixin, MPTTModel):
|
||||||
""" The Part object represents an abstract part, the 'concept' of an actual entity.
|
""" 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.
|
An actual physical instance of a Part is a StockItem which is treated separately.
|
||||||
|
@ -21,6 +21,85 @@ import build.models
|
|||||||
import order.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):
|
class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
Tests for the various OPTIONS endpoints in the /part/ API
|
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['in_stock'], 9000)
|
||||||
self.assertEqual(data['unallocated_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):
|
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
|
@ -199,7 +199,7 @@ class PartTest(TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
part_2.validate_unique()
|
part_2.validate_unique()
|
||||||
|
|
||||||
def test_metadata(self):
|
def test_attributes(self):
|
||||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
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(1)), 0.08)
|
||||||
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
|
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):
|
class TestTemplateTest(TestCase):
|
||||||
|
|
||||||
|
@ -8,9 +8,7 @@ from __future__ import unicode_literals
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import include, re_path
|
from django.urls import include, re_path
|
||||||
|
|
||||||
from rest_framework import generics
|
from rest_framework import filters, generics, permissions, status
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework import permissions
|
|
||||||
from rest_framework.exceptions import NotFound
|
from rest_framework.exceptions import NotFound
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
@ -19,6 +17,7 @@ from django_filters.rest_framework import DjangoFilterBackend
|
|||||||
from common.api import GlobalSettingsPermissions
|
from common.api import GlobalSettingsPermissions
|
||||||
from plugin.base.barcodes.api import barcode_api_urls
|
from plugin.base.barcodes.api import barcode_api_urls
|
||||||
from plugin.base.action.api import ActionPluginView
|
from plugin.base.action.api import ActionPluginView
|
||||||
|
from plugin.base.locate.api import LocatePluginView
|
||||||
from plugin.models import PluginConfig, PluginSetting
|
from plugin.models import PluginConfig, PluginSetting
|
||||||
import plugin.serializers as PluginSerializers
|
import plugin.serializers as PluginSerializers
|
||||||
from plugin.registry import registry
|
from plugin.registry import registry
|
||||||
@ -38,6 +37,35 @@ class PluginList(generics.ListAPIView):
|
|||||||
serializer_class = PluginSerializers.PluginConfigSerializer
|
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||||
queryset = PluginConfig.objects.all()
|
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 = [
|
ordering_fields = [
|
||||||
'key',
|
'key',
|
||||||
'name',
|
'name',
|
||||||
@ -163,6 +191,7 @@ class PluginSettingDetail(generics.RetrieveUpdateAPIView):
|
|||||||
plugin_api_urls = [
|
plugin_api_urls = [
|
||||||
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
re_path(r'^action/', ActionPluginView.as_view(), name='api-action-plugin'),
|
||||||
re_path(r'^barcode/', include(barcode_api_urls)),
|
re_path(r'^barcode/', include(barcode_api_urls)),
|
||||||
|
re_path(r'^locate/', LocatePluginView.as_view(), name='api-locate-plugin'),
|
||||||
]
|
]
|
||||||
|
|
||||||
general_plugin_api_urls = [
|
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.barcodes.mixins import BarcodeMixin
|
||||||
from ..base.event.mixins import EventMixin
|
from ..base.event.mixins import EventMixin
|
||||||
from ..base.label.mixins import LabelPrintingMixin
|
from ..base.label.mixins import LabelPrintingMixin
|
||||||
|
from ..base.locate.mixins import LocateMixin
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'APICallMixin',
|
'APICallMixin',
|
||||||
@ -23,6 +24,7 @@ __all__ = [
|
|||||||
'PanelMixin',
|
'PanelMixin',
|
||||||
'ActionMixin',
|
'ActionMixin',
|
||||||
'BarcodeMixin',
|
'BarcodeMixin',
|
||||||
|
'LocateMixin',
|
||||||
'SingleNotificationMethod',
|
'SingleNotificationMethod',
|
||||||
'BulkNotificationMethod',
|
'BulkNotificationMethod',
|
||||||
]
|
]
|
||||||
|
@ -16,6 +16,65 @@ import common.models
|
|||||||
from plugin import InvenTreePlugin, registry
|
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):
|
class PluginConfig(models.Model):
|
||||||
"""
|
"""
|
||||||
A PluginConfig object holds settings for plugins.
|
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
|
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):
|
class PluginConfigSerializer(serializers.ModelSerializer):
|
||||||
"""
|
"""
|
||||||
Serializer for a PluginConfig:
|
Serializer for a PluginConfig:
|
||||||
|
@ -45,6 +45,14 @@ def mixin_enabled(plugin, key, *args, **kwargs):
|
|||||||
return plugin.mixin_enabled(key)
|
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()
|
@register.simple_tag()
|
||||||
def navigation_enabled(*args, **kwargs):
|
def navigation_enabled(*args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -43,6 +43,8 @@ from order.serializers import PurchaseOrderSerializer
|
|||||||
from part.models import BomItem, Part, PartCategory
|
from part.models import BomItem, Part, PartCategory
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer
|
||||||
|
|
||||||
|
from plugin.serializers import MetadataSerializer
|
||||||
|
|
||||||
from stock.admin import StockItemResource
|
from stock.admin import StockItemResource
|
||||||
from stock.models import StockLocation, StockItem
|
from stock.models import StockLocation, StockItem
|
||||||
from stock.models import StockItemTracking
|
from stock.models import StockItemTracking
|
||||||
@ -92,6 +94,15 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
return self.serializer_class(*args, **kwargs)
|
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:
|
class StockItemContextMixin:
|
||||||
""" Mixin class for adding StockItem object to serializer context """
|
""" 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):
|
class LocationDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of StockLocation object
|
""" 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'^tree/', StockLocationTree.as_view(), name='api-location-tree'),
|
||||||
|
|
||||||
re_path(r'^(?P<pk>\d+)/', LocationDetail.as_view(), name='api-location-detail'),
|
# Stock location detail endpoints
|
||||||
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
|
|
||||||
|
re_path(r'^metadata/', LocationMetadata.as_view(), name='api-location-metadata'),
|
||||||
|
|
||||||
|
re_path(r'^.*$', LocationDetail.as_view(), name='api-location-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
|
re_path(r'^.*$', StockLocationList.as_view(), name='api-location-list'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
@ -1417,8 +1444,9 @@ stock_api_urls = [
|
|||||||
|
|
||||||
# Detail views for a single stock item
|
# Detail views for a single stock item
|
||||||
re_path(r'^(?P<pk>\d+)/', include([
|
re_path(r'^(?P<pk>\d+)/', include([
|
||||||
re_path(r'^serialize/', StockItemSerialize.as_view(), name='api-stock-item-serialize'),
|
|
||||||
re_path(r'^install/', StockItemInstall.as_view(), name='api-stock-item-install'),
|
re_path(r'^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'^uninstall/', StockItemUninstall.as_view(), name='api-stock-item-uninstall'),
|
||||||
re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
|
re_path(r'^.*$', StockDetail.as_view(), name='api-stock-detail'),
|
||||||
])),
|
])),
|
||||||
|
27
InvenTree/stock/migrations/0075_auto_20220515_1440.py
Normal file
27
InvenTree/stock/migrations/0075_auto_20220515_1440.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 3.2.13 on 2022-05-15 14:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('stock', '0074_alter_stockitem_batch'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='stockitem',
|
||||||
|
name='metadata',
|
||||||
|
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='stocklocation',
|
||||||
|
name='metadata',
|
||||||
|
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='stocklocation',
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
]
|
@ -38,6 +38,7 @@ import common.models
|
|||||||
import report.models
|
import report.models
|
||||||
import label.models
|
import label.models
|
||||||
|
|
||||||
|
from plugin.models import MetadataMixin
|
||||||
from plugin.events import trigger_event
|
from plugin.events import trigger_event
|
||||||
|
|
||||||
from InvenTree.status_codes import StockStatus, StockHistoryCode
|
from InvenTree.status_codes import StockStatus, StockHistoryCode
|
||||||
@ -51,7 +52,7 @@ from company import models as CompanyModels
|
|||||||
from part import models as PartModels
|
from part import models as PartModels
|
||||||
|
|
||||||
|
|
||||||
class StockLocation(InvenTreeTree):
|
class StockLocation(MetadataMixin, InvenTreeTree):
|
||||||
""" Organization tree for StockItem objects
|
""" Organization tree for StockItem objects
|
||||||
A "StockLocation" can be considered a warehouse, or storage location
|
A "StockLocation" can be considered a warehouse, or storage location
|
||||||
Stock locations can be heirarchical as required
|
Stock locations can be heirarchical as required
|
||||||
@ -242,7 +243,7 @@ def generate_batch_code():
|
|||||||
return Template(batch_template).render(context)
|
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.
|
A StockItem object represents a quantity of physical instances of a part.
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{% extends "page_base.html" %}
|
{% extends "page_base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load plugin_extras %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
{% load status_codes %}
|
{% load status_codes %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
@ -18,7 +19,6 @@
|
|||||||
<div id="breadcrumb-tree"></div>
|
<div id="breadcrumb-tree"></div>
|
||||||
{% endblock breadcrumb_tree %}
|
{% endblock breadcrumb_tree %}
|
||||||
|
|
||||||
|
|
||||||
{% block heading %}
|
{% block heading %}
|
||||||
{% trans "Stock Item" %}: {{ item.part.full_name}}
|
{% trans "Stock Item" %}: {{ item.part.full_name}}
|
||||||
{% endblock heading %}
|
{% endblock heading %}
|
||||||
@ -29,6 +29,12 @@
|
|||||||
{% url 'admin:stock_stockitem_change' item.pk as url %}
|
{% url 'admin:stock_stockitem_change' item.pk as url %}
|
||||||
{% include "admin_button.html" with url=url %}
|
{% include "admin_button.html" with url=url %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% mixin_available "locate" as locate_available %}
|
||||||
|
{% if plugins_enabled and locate_available %}
|
||||||
|
<button id='locate-item-button' title='{% trans "Locate stock item" %}' class='btn btn-outline-secondary' typy='button'>
|
||||||
|
<span class='fas fa-search-location'></span>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% if barcodes %}
|
{% if barcodes %}
|
||||||
<!-- Barcode actions menu -->
|
<!-- Barcode actions menu -->
|
||||||
<div class='btn-group' role='group'>
|
<div class='btn-group' role='group'>
|
||||||
@ -514,6 +520,14 @@ $("#barcode-scan-into-location").click(function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
{% if plugins_enabled %}
|
||||||
|
$('#locate-item-button').click(function() {
|
||||||
|
locateItemOrLocation({
|
||||||
|
item: {{ item.pk }},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
function itemAdjust(action) {
|
function itemAdjust(action) {
|
||||||
|
|
||||||
inventreeGet(
|
inventreeGet(
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{% extends "stock/stock_app_base.html" %}
|
{% extends "stock/stock_app_base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
{% load plugin_extras %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block sidebar %}
|
{% block sidebar %}
|
||||||
@ -27,6 +28,14 @@
|
|||||||
{% include "admin_button.html" with url=url %}
|
{% include "admin_button.html" with url=url %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% mixin_available "locate" as locate_available %}
|
||||||
|
{% if location and plugins_enabled and locate_available %}
|
||||||
|
<button id='locate-location-button' title='{% trans "Locate stock location" %}' class='btn btn-outline-secondary' typy='button'>
|
||||||
|
<span class='fas fa-search-location'></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if barcodes %}
|
{% if barcodes %}
|
||||||
<!-- Barcode actions menu -->
|
<!-- Barcode actions menu -->
|
||||||
{% if location %}
|
{% if location %}
|
||||||
@ -206,6 +215,14 @@
|
|||||||
{% block js_ready %}
|
{% block js_ready %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
|
||||||
|
{% if plugins_enabled and location %}
|
||||||
|
$('#locate-location-button').click(function() {
|
||||||
|
locateItemOrLocation({
|
||||||
|
location: {{ location.pk }},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
onPanelLoad('sublocations', function() {
|
onPanelLoad('sublocations', function() {
|
||||||
loadStockLocationTable($('#sublocation-table'), {
|
loadStockLocationTable($('#sublocation-table'), {
|
||||||
params: {
|
params: {
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
|
|
||||||
|
{% plugins_enabled as plugins_enabled %}
|
||||||
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
{% settings_value 'BARCODE_ENABLE' as barcodes %}
|
||||||
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
|
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
|
||||||
{% settings_value "REPORT_ENABLE" as report_enabled %}
|
{% settings_value "REPORT_ENABLE" as report_enabled %}
|
||||||
|
@ -236,17 +236,13 @@ function selectLabel(labels, items, options={}) {
|
|||||||
if (plugins_enabled) {
|
if (plugins_enabled) {
|
||||||
inventreeGet(
|
inventreeGet(
|
||||||
`/api/plugin/`,
|
`/api/plugin/`,
|
||||||
{},
|
{
|
||||||
|
mixin: 'labels',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
async: false,
|
async: false,
|
||||||
success: function(response) {
|
success: function(response) {
|
||||||
response.forEach(function(plugin) {
|
plugins = response;
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
/* exported
|
/* exported
|
||||||
installPlugin,
|
installPlugin,
|
||||||
|
locateItemOrLocation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function installPlugin() {
|
function installPlugin() {
|
||||||
@ -24,3 +25,50 @@ function installPlugin() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function locateItemOrLocation(options={}) {
|
||||||
|
|
||||||
|
if (!options.item && !options.location) {
|
||||||
|
console.error(`locateItemOrLocation: Either 'item' or 'location' must be provided!`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function performLocate(plugin) {
|
||||||
|
inventreePut(
|
||||||
|
'{% url "api-locate-plugin" %}',
|
||||||
|
{
|
||||||
|
plugin: plugin,
|
||||||
|
item: options.item,
|
||||||
|
location: options.location,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request the list of available 'locate' plugins
|
||||||
|
inventreeGet(
|
||||||
|
`/api/plugin/`,
|
||||||
|
{
|
||||||
|
mixin: 'locate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: function(plugins) {
|
||||||
|
// No 'locate' plugins are available!
|
||||||
|
if (plugins.length == 0) {
|
||||||
|
console.warn(`No 'locate' plugins are available`);
|
||||||
|
} else if (plugins.length == 1) {
|
||||||
|
// Only a single locate plugin is available
|
||||||
|
performLocate(plugins[0].key);
|
||||||
|
} else {
|
||||||
|
// More than 1 location plugin available
|
||||||
|
// Select from a list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user