mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-29 20:16:44 +00:00
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
This commit is contained in:
parent
cd68d5a80e
commit
37a74dbfef
@ -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
|
||||||
|
@ -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'),
|
||||||
])),
|
])),
|
||||||
|
@ -1021,6 +1021,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):
|
||||||
"""
|
"""
|
||||||
|
@ -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):
|
||||||
|
|
||||||
|
@ -39,6 +39,41 @@ class MetadataMixin(models.Model):
|
|||||||
help_text=_('JSON metadata field, for use by external plugins'),
|
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):
|
||||||
"""
|
"""
|
||||||
|
@ -15,10 +15,40 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from InvenTree.helpers import str2bool
|
||||||
|
|
||||||
from plugin.models import PluginConfig, PluginSetting, NotificationUserSetting
|
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:
|
||||||
|
@ -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,15 @@ 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 +1445,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'),
|
||||||
])),
|
])),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user