2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 04:26: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:
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 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

View File

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

View File

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

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

View File

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

View File

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

View File

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