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:
Oliver Walters 2022-05-16 20:20:32 +10:00
parent cd68d5a80e
commit 37a74dbfef
7 changed files with 212 additions and 3 deletions

View File

@ -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

View File

@ -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'),
])),

View File

@ -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):
"""

View File

@ -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):

View File

@ -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):
"""

View File

@ -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:

View File

@ -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'),
])),