mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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
|
||||
|
||||
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
|
||||
|
@ -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<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'),
|
||||
])),
|
||||
|
||||
@ -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'),
|
||||
])),
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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):
|
||||
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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:
|
||||
|
@ -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<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'),
|
||||
])),
|
||||
|
||||
@ -1417,8 +1445,9 @@ stock_api_urls = [
|
||||
|
||||
# Detail views for a single stock item
|
||||
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'^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'),
|
||||
])),
|
||||
|
Loading…
Reference in New Issue
Block a user