diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 6fecce5ace..5043944e06 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -2,11 +2,15 @@ # InvenTree API version -INVENTREE_API_VERSION = 118 +INVENTREE_API_VERSION = 119 """ Increment this API version number whenever there is a significant change to the API that any clients need to know about + +v119 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4898 + - Add Metadata to: Part test templates, Part parameters, Part category parameter templates, BOM item substitute, Part relateds, Stock item test result + v118 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4935 - Adds extra fields for the PartParameterTemplate model diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index aee0d59ae8..8137f7a6a8 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -561,7 +561,10 @@ company_api_urls = [ ])), re_path(r'^contact/', include([ - path('/', ContactDetail.as_view(), name='api-contact-detail'), + re_path(r'^(?P\d+)/?', include([ + re_path('^metadata/', MetadataView.as_view(), {'model': Contact}, name='api-contact-metadata'), + re_path('^.*$', ContactDetail.as_view(), name='api-contact-detail'), + ])), re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'), ])), diff --git a/InvenTree/company/fixtures/contact.yaml b/InvenTree/company/fixtures/contact.yaml new file mode 100644 index 0000000000..2de910ff72 --- /dev/null +++ b/InvenTree/company/fixtures/contact.yaml @@ -0,0 +1,9 @@ +# Sample contact data +- model: company.contact + pk: 1 + fields: + name: Johnny Rotten + company: 1 + phone: 001234567 + email: none@example.com + role: Rockstar diff --git a/InvenTree/company/migrations/0062_contact_metadata.py b/InvenTree/company/migrations/0062_contact_metadata.py new file mode 100644 index 0000000000..eb3f638076 --- /dev/null +++ b/InvenTree/company/migrations/0062_contact_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2023-05-25 16:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('company', '0061_remove_supplierpart_pack_size'), + ] + + operations = [ + migrations.AddField( + model_name='contact', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 3cd94c855d..cf6407c27a 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -225,7 +225,7 @@ class CompanyAttachment(InvenTreeAttachment): ) -class Contact(models.Model): +class Contact(MetadataMixin, models.Model): """A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects. Attributes: diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index e354b2f0d9..4f98686111 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -6,7 +6,7 @@ from rest_framework import status from InvenTree.unit_test import InvenTreeAPITestCase -from .models import Company, Contact, SupplierPart +from .models import Company, Contact, ManufacturerPart, SupplierPart class CompanyTest(InvenTreeAPITestCase): @@ -233,7 +233,10 @@ class ContactTest(InvenTreeAPITestCase): def test_edit(self): """Test that we can edit a Contact via the API""" - url = reverse('api-contact-detail', kwargs={'pk': 1}) + # Get the first contact + contact = Contact.objects.first() + # Use this contact in the tests + url = reverse('api-contact-detail', kwargs={'pk': contact.pk}) # Retrieve detail view data = self.get(url, expected_code=200).data @@ -259,13 +262,16 @@ class ContactTest(InvenTreeAPITestCase): expected_code=200 ) - contact = Contact.objects.get(pk=1) + # Get the contact again + contact = Contact.objects.first() self.assertEqual(contact.role, 'x') def test_delete(self): """Tests that we can delete a Contact via the API""" - url = reverse('api-contact-detail', kwargs={'pk': 6}) + # Get the last contact + contact = Contact.objects.first() + url = reverse('api-contact-detail', kwargs={'pk': contact.pk}) # Delete (without required permissions) self.delete(url, expected_code=403) @@ -490,3 +496,63 @@ class SupplierPartTest(InvenTreeAPITestCase): sp = SupplierPart.objects.get(pk=response.data['pk']) self.assertEqual(sp.available, 999) self.assertIsNotNone(sp.availability_updated) + + +class CompanyMetadataAPITest(InvenTreeAPITestCase): + """Unit tests for the various metadata endpoints of API.""" + + fixtures = [ + 'category', + 'part', + 'location', + 'company', + 'contact', + 'manufacturer_part', + 'supplier_part', + ] + + roles = [ + 'company.change', + 'purchase_order.change', + 'part.change', + ] + + def metatester(self, apikey, model): + """Generic tester""" + + modeldata = model.objects.first() + + # Useless test unless a model object is found + self.assertIsNotNone(modeldata) + + url = reverse(apikey, kwargs={'pk': modeldata.pk}) + + # Metadata is initially null + self.assertIsNone(modeldata.metadata) + + numstr = f'12{len(apikey)}' + + self.patch( + url, + { + 'metadata': { + f'abc-{numstr}': f'xyz-{apikey}-{numstr}', + } + }, + expected_code=200 + ) + + # Refresh + modeldata.refresh_from_db() + self.assertEqual(modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}') + + def test_metadata(self): + """Test all endpoints""" + + for apikey, model in { + 'api-manufacturer-part-metadata': ManufacturerPart, + 'api-supplier-part-metadata': SupplierPart, + 'api-company-metadata': Company, + 'api-contact-metadata': Contact, + }.items(): + self.metatester(apikey, model) diff --git a/InvenTree/order/fixtures/return_order.yaml b/InvenTree/order/fixtures/return_order.yaml index 1f96ea1e2c..db2e1d13b8 100644 --- a/InvenTree/order/fixtures/return_order.yaml +++ b/InvenTree/order/fixtures/return_order.yaml @@ -51,3 +51,19 @@ description: 'RMA from a customer' customer: 5 status: 10 # Pending + +# 1 x R_4K7_0603 +- model: order.returnorderlineitem + pk: 1 + fields: + order: 6 + item: 1008 + quantity: 1 + +# An extra line item +- model: order.returnorderextraline + pk: 1 + fields: + order: 6 + reference: 'Freight cost' + quantity: 1 diff --git a/InvenTree/order/fixtures/sales_order.yaml b/InvenTree/order/fixtures/sales_order.yaml index e80119fa3e..4b81c516e6 100644 --- a/InvenTree/order/fixtures/sales_order.yaml +++ b/InvenTree/order/fixtures/sales_order.yaml @@ -37,3 +37,26 @@ description: "One sales order, please" customer: 5 status: 60 # Returned + +# 1 x R_4K7_0603 +- model: order.salesorderlineitem + pk: 1 + fields: + order: 5 + part: 5 + quantity: 1 + +# An extra line item +- model: order.salesorderextraline + pk: 1 + fields: + order: 5 + reference: 'Freight cost' + quantity: 1 + +# Shipment +- model: order.salesordershipment + pk: 1 + fields: + order: 1 + reference: "Test Shipment, must be present for metadata test" diff --git a/InvenTree/order/test_api.py b/InvenTree/order/test_api.py index 5908283d4b..6bd6023c77 100644 --- a/InvenTree/order/test_api.py +++ b/InvenTree/order/test_api.py @@ -509,23 +509,6 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(po.status, PurchaseOrderStatus.PLACED) - def test_po_metadata(self): - """Test the 'metadata' endpoint for the PurchaseOrder model""" - url = reverse('api-po-metadata', kwargs={'pk': 1}) - - self.patch( - url, - { - 'metadata': { - 'yam': 'yum', - } - }, - expected_code=200 - ) - - order = models.PurchaseOrder.objects.get(pk=1) - self.assertEqual(order.get_metadata('yam'), 'yum') - def test_po_calendar(self): """Test the calendar export endpoint""" @@ -1374,23 +1357,6 @@ class SalesOrderTest(OrderTest): self.assertEqual(so.status, SalesOrderStatus.CANCELLED) - def test_so_metadata(self): - """Test the 'metadata' API endpoint for the SalesOrder model""" - url = reverse('api-so-metadata', kwargs={'pk': 1}) - - self.patch( - url, - { - 'metadata': { - 'xyz': 'abc', - } - }, - expected_code=200 - ) - - order = models.SalesOrder.objects.get(pk=1) - self.assertEqual(order.get_metadata('xyz'), 'abc') - def test_so_calendar(self): """Test the calendar export endpoint""" @@ -1887,6 +1853,9 @@ class SalesOrderAllocateTest(OrderTest): """Test the SalesOrderShipment list API endpoint""" url = reverse('api-so-shipment-list') + # Count before creation + countbefore = models.SalesOrderShipment.objects.count() + # Create some new shipments via the API for order in models.SalesOrder.objects.all(): @@ -1916,7 +1885,7 @@ class SalesOrderAllocateTest(OrderTest): # List *all* shipments response = self.get(url, expected_code=200) - self.assertEqual(len(response.data), 1 + 3 * models.SalesOrder.objects.count()) + self.assertEqual(len(response.data), countbefore + 3 * models.SalesOrder.objects.count()) class ReturnOrderTests(InvenTreeAPITestCase): @@ -2212,3 +2181,71 @@ class ReturnOrderTests(InvenTreeAPITestCase): response = self.get(url, expected_code=200, format=None) calendar = Calendar.from_ical(response.content) self.assertIsInstance(calendar, Calendar) + + +class OrderMetadataAPITest(InvenTreeAPITestCase): + """Unit tests for the various metadata endpoints of API.""" + + fixtures = [ + 'category', + 'part', + 'company', + 'location', + 'supplier_part', + 'stock', + 'order', + 'sales_order', + 'return_order', + ] + + roles = [ + 'purchase_order.change', + 'sales_order.change', + 'return_order.change', + ] + + def metatester(self, apikey, model): + """Generic tester""" + + modeldata = model.objects.first() + + # Useless test unless a model object is found + self.assertIsNotNone(modeldata) + + url = reverse(apikey, kwargs={'pk': modeldata.pk}) + + # Metadata is initially null + self.assertIsNone(modeldata.metadata) + + numstr = f'12{len(apikey)}' + + self.patch( + url, + { + 'metadata': { + f'abc-{numstr}': f'xyz-{apikey}-{numstr}', + } + }, + expected_code=200 + ) + + # Refresh + modeldata.refresh_from_db() + self.assertEqual(modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}') + + def test_metadata(self): + """Test all endpoints""" + + for apikey, model in { + 'api-po-metadata': models.PurchaseOrder, + 'api-po-line-metadata': models.PurchaseOrderLineItem, + 'api-po-extra-line-metadata': models.PurchaseOrderExtraLine, + 'api-so-shipment-metadata': models.SalesOrderShipment, + 'api-so-metadata': models.SalesOrder, + 'api-so-line-metadata': models.SalesOrderLineItem, + 'api-so-extra-line-metadata': models.SalesOrderExtraLine, + 'api-return-order-metadata': models.ReturnOrder, + 'api-return-order-line-metadata': models.ReturnOrderLineItem, + 'api-return-order-extra-line-metadata': models.ReturnOrderExtraLine, + }.items(): + self.metatester(apikey, model) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 07663f834f..8df2acd940 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -1892,7 +1892,10 @@ part_api_urls = [ re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'), re_path(r'^parameters/', include([ - re_path(r'^(?P\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'), + re_path(r'^(?P\d+)/', include([ + re_path(r'^metadata/', MetadataView.as_view(), {'model': PartCategoryParameterTemplate}, name='api-part-category-parameter-metadata'), + re_path(r'^.*$', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'), + ])), re_path(r'^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'), ])), @@ -1910,7 +1913,10 @@ part_api_urls = [ # Base URL for PartTestTemplate API endpoints re_path(r'^test-template/', include([ - path(r'/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'), + path(r'/', include([ + re_path(r'^metadata/', MetadataView.as_view(), {'model': PartTestTemplate}, name='api-part-test-template-metadata'), + re_path(r'^.*$', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'), + ])), path('', PartTestTemplateList.as_view(), name='api-part-test-template-list'), ])), @@ -1934,7 +1940,10 @@ part_api_urls = [ # Base URL for PartRelated API endpoints re_path(r'^related/', include([ - path(r'/', PartRelatedDetail.as_view(), name='api-part-related-detail'), + path(r'/', include([ + re_path(r'^metadata/', MetadataView.as_view(), {'model': PartRelated}, name='api-part-related-metadata'), + re_path(r'^.*$', PartRelatedDetail.as_view(), name='api-part-related-detail'), + ])), re_path(r'^.*$', PartRelatedList.as_view(), name='api-part-related-list'), ])), @@ -1948,7 +1957,10 @@ part_api_urls = [ re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'), ])), - path(r'/', PartParameterDetail.as_view(), name='api-part-parameter-detail'), + path(r'/', include([ + re_path(r'^metadata/?', MetadataView.as_view(), {'model': PartParameter}, name='api-part-parameter-metadata'), + re_path(r'^.*$', PartParameterDetail.as_view(), name='api-part-parameter-detail'), + ])), re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'), ])), @@ -2012,7 +2024,10 @@ bom_api_urls = [ re_path(r'^substitute/', include([ # Detail view - path(r'/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'), + path(r'/', include([ + re_path(r'^metadata/?', MetadataView.as_view(), {'model': BomItemSubstitute}, name='api-bom-substitute-metadata'), + re_path(r'^.*$', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'), + ])), # Catch all re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'), diff --git a/InvenTree/part/fixtures/bom.yaml b/InvenTree/part/fixtures/bom.yaml index f09d1b8cf4..e3763eb41f 100644 --- a/InvenTree/part/fixtures/bom.yaml +++ b/InvenTree/part/fixtures/bom.yaml @@ -50,3 +50,9 @@ part: 101 sub_part: 100 quantity: 10 + +- model: part.bomitemsubstitute + pk: 1 + fields: + part: 5 + bom_item: 6 diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 8d461402d1..39cbf74ccb 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -188,3 +188,9 @@ level: 0 lft: 0 rght: 0 + +- model: part.partrelated + pk: 1 + fields: + part_1: 10003 + part_2: 10004 diff --git a/InvenTree/part/migrations/0112_auto_20230525_1606.py b/InvenTree/part/migrations/0112_auto_20230525_1606.py new file mode 100644 index 0000000000..a1cb586db8 --- /dev/null +++ b/InvenTree/part/migrations/0112_auto_20230525_1606.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.19 on 2023-05-25 16:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0111_auto_20230521_1350'), + ] + + operations = [ + migrations.AddField( + model_name='bomitemsubstitute', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + migrations.AddField( + model_name='partcategoryparametertemplate', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + migrations.AddField( + model_name='partparameter', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + migrations.AddField( + model_name='partrelated', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + migrations.AddField( + model_name='parttesttemplate', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + ] diff --git a/InvenTree/part/migrations/0112_auto_20230531_1205.py b/InvenTree/part/migrations/0113_auto_20230531_1205.py similarity index 93% rename from InvenTree/part/migrations/0112_auto_20230531_1205.py rename to InvenTree/part/migrations/0113_auto_20230531_1205.py index 612761a358..ddba7719ba 100644 --- a/InvenTree/part/migrations/0112_auto_20230531_1205.py +++ b/InvenTree/part/migrations/0113_auto_20230531_1205.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('part', '0111_auto_20230521_1350'), + ('part', '0112_auto_20230525_1606'), ] operations = [ diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 8e925166bd..71bb224587 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -3194,7 +3194,7 @@ class PartCategoryStar(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories') -class PartTestTemplate(models.Model): +class PartTestTemplate(MetadataMixin, models.Model): """A PartTestTemplate defines a 'template' for a test which is required to be run against a StockItem (an instance of the Part). The test template applies "recursively" to part variants, allowing tests to be @@ -3443,7 +3443,7 @@ def post_save_part_parameter_template(sender, instance, created, **kwargs): ) -class PartParameter(models.Model): +class PartParameter(MetadataMixin, models.Model): """A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter pair to a part. Attributes: @@ -3559,7 +3559,7 @@ class PartParameter(models.Model): return part_parameter -class PartCategoryParameterTemplate(models.Model): +class PartCategoryParameterTemplate(MetadataMixin, models.Model): """A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a PartParameterTemplate. Multiple PartParameterTemplate instances can be associated to a PartCategory to drive a default list of parameter templates attached to a Part instance upon creation. @@ -3999,7 +3999,7 @@ def update_pricing_after_delete(sender, instance, **kwargs): instance.part.schedule_pricing_update(create=False) -class BomItemSubstitute(models.Model): +class BomItemSubstitute(MetadataMixin, models.Model): """A BomItemSubstitute provides a specification for alternative parts, which can be used in a bill of materials. Attributes: @@ -4058,7 +4058,7 @@ class BomItemSubstitute(models.Model): ) -class PartRelated(models.Model): +class PartRelated(MetadataMixin, models.Model): """Store and handle related parts (eg. mating connector, crimps, etc.).""" class Meta: diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 3ee62f88ff..8d56b5cbcc 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -22,8 +22,9 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus, StockStatus) from InvenTree.unit_test import InvenTreeAPITestCase from part.models import (BomItem, BomItemSubstitute, Part, PartCategory, - PartCategoryParameterTemplate, PartParameterTemplate, - PartRelated, PartStocktake) + PartCategoryParameterTemplate, PartParameter, + PartParameterTemplate, PartRelated, PartStocktake, + PartTestTemplate) from stock.models import StockItem, StockLocation @@ -159,26 +160,6 @@ class PartCategoryAPITest(InvenTreeAPITestCase): # Annotation should include parts from all sub-categories self.assertEqual(response.data['part_count'], 100) - def test_category_metadata(self): - """Test metadata endpoint for the PartCategory.""" - cat = PartCategory.objects.get(pk=1) - - cat.metadata = { - 'foo': 'bar', - 'water': 'melon', - 'abc': 'xyz', - } - - cat.set_metadata('abc', 'ABC') - - response = self.get(reverse('api-part-category-metadata', kwargs={'pk': 1}), expected_code=200) - - metadata = response.data['metadata'] - - self.assertEqual(metadata['foo'], 'bar') - self.assertEqual(metadata['water'], 'melon') - self.assertEqual(metadata['abc'], 'ABC') - def test_category_parameters(self): """Test that the PartCategoryParameterTemplate API function work""" @@ -1665,56 +1646,6 @@ class PartDetailTests(PartAPITestBase): 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 PartListTests(PartAPITestBase): """Unit tests for the Part List API endpoint""" @@ -2536,9 +2467,13 @@ class BomItemTest(InvenTreeAPITestCase): url = reverse('api-bom-substitute-list') stock_url = reverse('api-stock-list') - # Initially we have no substitute parts + # Initially we may have substitute parts + # Count first, operate directly on Model + countbefore = BomItemSubstitute.objects.count() + + # Now, make sure API returns the same count response = self.get(url, expected_code=200) - self.assertEqual(len(response.data), 0) + self.assertEqual(len(response.data), countbefore) # BOM item we are interested in bom_item = BomItem.objects.get(pk=1) @@ -2594,9 +2529,9 @@ class BomItemTest(InvenTreeAPITestCase): self.assertEqual(len(response.data), n_items + ii + 1) - # There should now be 5 substitute parts available in the database + # There should now be 5 more substitute parts available in the database response = self.get(url, expected_code=200) - self.assertEqual(len(response.data), 5) + self.assertEqual(len(response.data), countbefore + 5) # The BomItem detail endpoint should now also reflect the substitute data data = self.get( @@ -3050,3 +2985,71 @@ class PartStocktakeTest(InvenTreeAPITestCase): InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', True, None) response = self.post(url, data={}, expected_code=400) self.assertIn('Background worker check failed', str(response.data)) + + +class PartMetadataAPITest(InvenTreeAPITestCase): + """Unit tests for the various metadata endpoints of API.""" + + fixtures = [ + 'category', + 'part', + 'params', + 'location', + 'bom', + 'company', + 'test_templates', + 'manufacturer_part', + 'supplier_part', + 'order', + 'stock', + ] + + roles = [ + 'part.change', + 'part_category.change', + ] + + def metatester(self, apikey, model): + """Generic tester""" + + modeldata = model.objects.first() + + # Useless test unless a model object is found + self.assertIsNotNone(modeldata) + + url = reverse(apikey, kwargs={'pk': modeldata.pk}) + + # Metadata is initially null + self.assertIsNone(modeldata.metadata) + + numstr = randint(100, 900) + + self.patch( + url, + { + 'metadata': { + f'abc-{numstr}': f'xyz-{apikey}-{numstr}', + } + }, + expected_code=200 + ) + + # Refresh + modeldata.refresh_from_db() + self.assertEqual(modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}') + + def test_metadata(self): + """Test all endpoints""" + + for apikey, model in { + 'api-part-category-parameter-metadata': PartCategoryParameterTemplate, + 'api-part-category-metadata': PartCategory, + 'api-part-test-template-metadata': PartTestTemplate, + 'api-part-related-metadata': PartRelated, + 'api-part-parameter-template-metadata': PartParameterTemplate, + 'api-part-parameter-metadata': PartParameter, + 'api-part-metadata': Part, + 'api-bom-substitute-metadata': BomItemSubstitute, + 'api-bom-item-metadata': BomItem, + }.items(): + self.metatester(apikey, model) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 9718d606a9..ee539bad8e 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -294,8 +294,10 @@ class PartTest(TestCase): """Unit tests for the PartRelated model""" # Create a part relationship + # Count before creation + countbefore = PartRelated.objects.count() PartRelated.objects.create(part_1=self.r1, part_2=self.r2) - self.assertEqual(PartRelated.objects.count(), 1) + self.assertEqual(PartRelated.objects.count(), countbefore + 1) # Creating a duplicate part relationship should fail with self.assertRaises(ValidationError): @@ -321,7 +323,7 @@ class PartTest(TestCase): # Delete a part, ensure the relationship also gets deleted self.r1.delete() - self.assertEqual(PartRelated.objects.count(), 0) + self.assertEqual(PartRelated.objects.count(), countbefore) self.assertEqual(len(self.r2.get_related_parts()), 0) # Add multiple part relationships to self.r2 @@ -330,12 +332,12 @@ class PartTest(TestCase): n = Part.objects.count() - 1 - self.assertEqual(PartRelated.objects.count(), n) + self.assertEqual(PartRelated.objects.count(), n + countbefore) self.assertEqual(len(self.r2.get_related_parts()), n) - # Deleting r2 should remove *all* relationships + # Deleting r2 should remove *all* newly created relationships self.r2.delete() - self.assertEqual(PartRelated.objects.count(), 0) + self.assertEqual(PartRelated.objects.count(), countbefore) def test_stocktake(self): """Test for adding stocktake data""" diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 54690223d6..81ea2d4343 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -1430,7 +1430,10 @@ stock_api_urls = [ # StockItemTestResult API endpoints re_path(r'^test/', include([ - path(r'/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'), + path(r'/', include([ + re_path(r'^metadata/', MetadataView.as_view(), {'model': StockItemTestResult}, name='api-stock-test-result-metadata'), + re_path(r'^.*$', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'), + ])), re_path(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'), ])), diff --git a/InvenTree/stock/migrations/0101_stockitemtestresult_metadata.py b/InvenTree/stock/migrations/0101_stockitemtestresult_metadata.py new file mode 100644 index 0000000000..95a6583f3f --- /dev/null +++ b/InvenTree/stock/migrations/0101_stockitemtestresult_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2023-05-25 16:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0100_stockitem_consumed_by'), + ] + + operations = [ + migrations.AddField( + model_name='stockitemtestresult', + name='metadata', + field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 5055ccd6ec..ae7f6d2594 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -2170,7 +2170,7 @@ def rename_stock_item_test_result_attachment(instance, filename): return os.path.join('stock_files', str(instance.stock_item.pk), os.path.basename(filename)) -class StockItemTestResult(models.Model): +class StockItemTestResult(MetadataMixin, models.Model): """A StockItemTestResult records results of custom tests against individual StockItem objects. This is useful for tracking unit acceptance tests, and particularly useful when integrated diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py index 886678d5be..42d0a976c6 100644 --- a/InvenTree/stock/test_api.py +++ b/InvenTree/stock/test_api.py @@ -1693,3 +1693,62 @@ class StockMergeTest(StockAPITestCase): # Total number of stock items has been reduced! self.assertEqual(StockItem.objects.filter(part=self.part).count(), n - 2) + + +class StockMetadataAPITest(InvenTreeAPITestCase): + """Unit tests for the various metadata endpoints of API.""" + + fixtures = [ + 'category', + 'part', + 'bom', + 'company', + 'location', + 'supplier_part', + 'stock', + 'stock_tests', + ] + + roles = [ + 'stock.change', + 'stock_location.change', + ] + + def metatester(self, apikey, model): + """Generic tester""" + + modeldata = model.objects.first() + + # Useless test unless a model object is found + self.assertIsNotNone(modeldata) + + url = reverse(apikey, kwargs={'pk': modeldata.pk}) + + # Metadata is initially null + self.assertIsNone(modeldata.metadata) + + numstr = f'12{len(apikey)}' + + self.patch( + url, + { + 'metadata': { + f'abc-{numstr}': f'xyz-{apikey}-{numstr}', + } + }, + expected_code=200 + ) + + # Refresh + modeldata.refresh_from_db() + self.assertEqual(modeldata.get_metadata(f'abc-{numstr}'), f'xyz-{apikey}-{numstr}') + + def test_metadata(self): + """Test all endpoints""" + + for apikey, model in { + 'api-location-metadata': StockLocation, + 'api-stock-test-result-metadata': StockItemTestResult, + 'api-stock-item-metadata': StockItem, + }.items(): + self.metatester(apikey, model)