From 37a74dbfefee6978804f8281573b5bed53c0396b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Mon, 16 May 2022 20:20:32 +1000 Subject: [PATCH] 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 --- InvenTree/InvenTree/api_tester.py | 12 +++++++ InvenTree/part/api.py | 34 +++++++++++++++++++- InvenTree/part/test_api.py | 53 +++++++++++++++++++++++++++++++ InvenTree/part/test_part.py | 18 +++++++++++ InvenTree/plugin/models.py | 35 ++++++++++++++++++++ InvenTree/plugin/serializers.py | 30 +++++++++++++++++ InvenTree/stock/api.py | 33 +++++++++++++++++-- 7 files changed, 212 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index fe2057b453..c55c3d3ba3 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -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 diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 622ca38669..7213f8af4d 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'), + # Category detail endpoints + re_path(r'^(?P\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'), ])), diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index f0770eb1f5..e138aee2fd 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -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): """ diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 5932c36757..e36d929cfe 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -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): diff --git a/InvenTree/plugin/models.py b/InvenTree/plugin/models.py index 130db8d12e..d2d263d14c 100644 --- a/InvenTree/plugin/models.py +++ b/InvenTree/plugin/models.py @@ -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): """ diff --git a/InvenTree/plugin/serializers.py b/InvenTree/plugin/serializers.py index b3c0471635..e4ca703d25 100644 --- a/InvenTree/plugin/serializers.py +++ b/InvenTree/plugin/serializers.py @@ -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: diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 96a893e914..b917c6d8ac 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -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\d+)/', LocationDetail.as_view(), name='api-location-detail'), + # Stock location detail endpoints + re_path(r'^(?P\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\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'), ])),