2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 19:46:46 +00:00

Add Metadata to more models (#4898)

* Update models: add MetadataMixin

* Fix name of model in Metadata API definition

* Add API endpoints

* Update API version

* Fix syntax

* Add API endpoint for RO, RO line, RO line extra item

* Add Metadata to Contacts

* Fix link in API version

* Fix name of model

* Fix error?

* Fix error?

* Fix all errors, hopefully..

* Add tests for order, line, extraline metadata

Extend for PO, SO

* Add tests for metadata for Company-related models

* Fix spelling

* Consolidate metadata test for all part models into one test

* Add test for all Stock metadata

* Update stock test_api

* Add all metadata tests for orders

* Fix various errors in tests

* Fix model name

* Add migration files

* Update tests for metadata

* Resolve conflict around API version number

* Rename migration file

* Rename migration file

* Will Contact edit endpoint work better?

* Revert changes in URL definitions

* Remove test, duplicate

* Fix tests with fixed PK, not from fixtures, to use a dynamic PK

* Fix migration overlap
This commit is contained in:
miggland 2023-06-02 11:26:20 +02:00 committed by GitHub
parent c0dafe155f
commit 1d85b70313
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 462 additions and 136 deletions

View File

@ -2,11 +2,15 @@
# InvenTree API version # 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 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 v118 -> 2023-06-01 : https://github.com/inventree/InvenTree/pull/4935
- Adds extra fields for the PartParameterTemplate model - Adds extra fields for the PartParameterTemplate model

View File

@ -561,7 +561,10 @@ company_api_urls = [
])), ])),
re_path(r'^contact/', include([ re_path(r'^contact/', include([
path('<int:pk>/', ContactDetail.as_view(), name='api-contact-detail'), re_path(r'^(?P<pk>\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'), re_path(r'^.*$', ContactList.as_view(), name='api-contact-list'),
])), ])),

View File

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

View File

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

View File

@ -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. """A Contact represents a person who works at a particular company. A Company may have zero or more associated Contact objects.
Attributes: Attributes:

View File

@ -6,7 +6,7 @@ from rest_framework import status
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from .models import Company, Contact, SupplierPart from .models import Company, Contact, ManufacturerPart, SupplierPart
class CompanyTest(InvenTreeAPITestCase): class CompanyTest(InvenTreeAPITestCase):
@ -233,7 +233,10 @@ class ContactTest(InvenTreeAPITestCase):
def test_edit(self): def test_edit(self):
"""Test that we can edit a Contact via the API""" """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 # Retrieve detail view
data = self.get(url, expected_code=200).data data = self.get(url, expected_code=200).data
@ -259,13 +262,16 @@ class ContactTest(InvenTreeAPITestCase):
expected_code=200 expected_code=200
) )
contact = Contact.objects.get(pk=1) # Get the contact again
contact = Contact.objects.first()
self.assertEqual(contact.role, 'x') self.assertEqual(contact.role, 'x')
def test_delete(self): def test_delete(self):
"""Tests that we can delete a Contact via the API""" """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) # Delete (without required permissions)
self.delete(url, expected_code=403) self.delete(url, expected_code=403)
@ -490,3 +496,63 @@ class SupplierPartTest(InvenTreeAPITestCase):
sp = SupplierPart.objects.get(pk=response.data['pk']) sp = SupplierPart.objects.get(pk=response.data['pk'])
self.assertEqual(sp.available, 999) self.assertEqual(sp.available, 999)
self.assertIsNotNone(sp.availability_updated) 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)

View File

@ -51,3 +51,19 @@
description: 'RMA from a customer' description: 'RMA from a customer'
customer: 5 customer: 5
status: 10 # Pending 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

View File

@ -37,3 +37,26 @@
description: "One sales order, please" description: "One sales order, please"
customer: 5 customer: 5
status: 60 # Returned 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"

View File

@ -509,23 +509,6 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(po.status, PurchaseOrderStatus.PLACED) 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): def test_po_calendar(self):
"""Test the calendar export endpoint""" """Test the calendar export endpoint"""
@ -1374,23 +1357,6 @@ class SalesOrderTest(OrderTest):
self.assertEqual(so.status, SalesOrderStatus.CANCELLED) 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): def test_so_calendar(self):
"""Test the calendar export endpoint""" """Test the calendar export endpoint"""
@ -1887,6 +1853,9 @@ class SalesOrderAllocateTest(OrderTest):
"""Test the SalesOrderShipment list API endpoint""" """Test the SalesOrderShipment list API endpoint"""
url = reverse('api-so-shipment-list') url = reverse('api-so-shipment-list')
# Count before creation
countbefore = models.SalesOrderShipment.objects.count()
# Create some new shipments via the API # Create some new shipments via the API
for order in models.SalesOrder.objects.all(): for order in models.SalesOrder.objects.all():
@ -1916,7 +1885,7 @@ class SalesOrderAllocateTest(OrderTest):
# List *all* shipments # List *all* shipments
response = self.get(url, expected_code=200) 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): class ReturnOrderTests(InvenTreeAPITestCase):
@ -2212,3 +2181,71 @@ class ReturnOrderTests(InvenTreeAPITestCase):
response = self.get(url, expected_code=200, format=None) response = self.get(url, expected_code=200, format=None)
calendar = Calendar.from_ical(response.content) calendar = Calendar.from_ical(response.content)
self.assertIsInstance(calendar, Calendar) 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)

View File

@ -1892,7 +1892,10 @@ 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/', include([ re_path(r'^parameters/', include([
re_path(r'^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'), re_path(r'^(?P<pk>\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'), 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 # Base URL for PartTestTemplate API endpoints
re_path(r'^test-template/', include([ re_path(r'^test-template/', include([
path(r'<int:pk>/', PartTestTemplateDetail.as_view(), name='api-part-test-template-detail'), path(r'<int:pk>/', 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'), path('', PartTestTemplateList.as_view(), name='api-part-test-template-list'),
])), ])),
@ -1934,7 +1940,10 @@ part_api_urls = [
# Base URL for PartRelated API endpoints # Base URL for PartRelated API endpoints
re_path(r'^related/', include([ re_path(r'^related/', include([
path(r'<int:pk>/', PartRelatedDetail.as_view(), name='api-part-related-detail'), path(r'<int:pk>/', 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'), 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'), re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
])), ])),
path(r'<int:pk>/', PartParameterDetail.as_view(), name='api-part-parameter-detail'), path(r'<int:pk>/', 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'), re_path(r'^.*$', PartParameterList.as_view(), name='api-part-parameter-list'),
])), ])),
@ -2012,7 +2024,10 @@ bom_api_urls = [
re_path(r'^substitute/', include([ re_path(r'^substitute/', include([
# Detail view # Detail view
path(r'<int:pk>/', BomItemSubstituteDetail.as_view(), name='api-bom-substitute-detail'), path(r'<int:pk>/', 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 # Catch all
re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'), re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),

View File

@ -50,3 +50,9 @@
part: 101 part: 101
sub_part: 100 sub_part: 100
quantity: 10 quantity: 10
- model: part.bomitemsubstitute
pk: 1
fields:
part: 5
bom_item: 6

View File

@ -188,3 +188,9 @@
level: 0 level: 0
lft: 0 lft: 0
rght: 0 rght: 0
- model: part.partrelated
pk: 1
fields:
part_1: 10003
part_2: 10004

View File

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

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('part', '0111_auto_20230521_1350'), ('part', '0112_auto_20230525_1606'),
] ]
operations = [ operations = [

View File

@ -3194,7 +3194,7 @@ class PartCategoryStar(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=_('User'), related_name='starred_categories') 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). """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 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 <key:value> pair to a part. """A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter <key:value> pair to a part.
Attributes: Attributes:
@ -3559,7 +3559,7 @@ class PartParameter(models.Model):
return part_parameter return part_parameter
class PartCategoryParameterTemplate(models.Model): class PartCategoryParameterTemplate(MetadataMixin, models.Model):
"""A PartCategoryParameterTemplate creates a unique relationship between a PartCategory and a PartParameterTemplate. """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. 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) 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. """A BomItemSubstitute provides a specification for alternative parts, which can be used in a bill of materials.
Attributes: 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.).""" """Store and handle related parts (eg. mating connector, crimps, etc.)."""
class Meta: class Meta:

View File

@ -22,8 +22,9 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
StockStatus) StockStatus)
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import (BomItem, BomItemSubstitute, Part, PartCategory, from part.models import (BomItem, BomItemSubstitute, Part, PartCategory,
PartCategoryParameterTemplate, PartParameterTemplate, PartCategoryParameterTemplate, PartParameter,
PartRelated, PartStocktake) PartParameterTemplate, PartRelated, PartStocktake,
PartTestTemplate)
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
@ -159,26 +160,6 @@ class PartCategoryAPITest(InvenTreeAPITestCase):
# Annotation should include parts from all sub-categories # Annotation should include parts from all sub-categories
self.assertEqual(response.data['part_count'], 100) 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): def test_category_parameters(self):
"""Test that the PartCategoryParameterTemplate API function work""" """Test that the PartCategoryParameterTemplate API function work"""
@ -1665,56 +1646,6 @@ class PartDetailTests(PartAPITestBase):
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 PartListTests(PartAPITestBase): class PartListTests(PartAPITestBase):
"""Unit tests for the Part List API endpoint""" """Unit tests for the Part List API endpoint"""
@ -2536,9 +2467,13 @@ class BomItemTest(InvenTreeAPITestCase):
url = reverse('api-bom-substitute-list') url = reverse('api-bom-substitute-list')
stock_url = reverse('api-stock-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) 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 we are interested in
bom_item = BomItem.objects.get(pk=1) bom_item = BomItem.objects.get(pk=1)
@ -2594,9 +2529,9 @@ class BomItemTest(InvenTreeAPITestCase):
self.assertEqual(len(response.data), n_items + ii + 1) 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) 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 # The BomItem detail endpoint should now also reflect the substitute data
data = self.get( data = self.get(
@ -3050,3 +2985,71 @@ class PartStocktakeTest(InvenTreeAPITestCase):
InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', True, None) InvenTreeSetting.set_setting('STOCKTAKE_ENABLE', True, None)
response = self.post(url, data={}, expected_code=400) response = self.post(url, data={}, expected_code=400)
self.assertIn('Background worker check failed', str(response.data)) 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)

View File

@ -294,8 +294,10 @@ class PartTest(TestCase):
"""Unit tests for the PartRelated model""" """Unit tests for the PartRelated model"""
# Create a part relationship # Create a part relationship
# Count before creation
countbefore = PartRelated.objects.count()
PartRelated.objects.create(part_1=self.r1, part_2=self.r2) 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 # Creating a duplicate part relationship should fail
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
@ -321,7 +323,7 @@ class PartTest(TestCase):
# Delete a part, ensure the relationship also gets deleted # Delete a part, ensure the relationship also gets deleted
self.r1.delete() self.r1.delete()
self.assertEqual(PartRelated.objects.count(), 0) self.assertEqual(PartRelated.objects.count(), countbefore)
self.assertEqual(len(self.r2.get_related_parts()), 0) self.assertEqual(len(self.r2.get_related_parts()), 0)
# Add multiple part relationships to self.r2 # Add multiple part relationships to self.r2
@ -330,12 +332,12 @@ class PartTest(TestCase):
n = Part.objects.count() - 1 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) 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.r2.delete()
self.assertEqual(PartRelated.objects.count(), 0) self.assertEqual(PartRelated.objects.count(), countbefore)
def test_stocktake(self): def test_stocktake(self):
"""Test for adding stocktake data""" """Test for adding stocktake data"""

View File

@ -1430,7 +1430,10 @@ stock_api_urls = [
# StockItemTestResult API endpoints # StockItemTestResult API endpoints
re_path(r'^test/', include([ re_path(r'^test/', include([
path(r'<int:pk>/', StockItemTestResultDetail.as_view(), name='api-stock-test-result-detail'), path(r'<int:pk>/', 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'), re_path(r'^.*$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'),
])), ])),

View File

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

View File

@ -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)) 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. """A StockItemTestResult records results of custom tests against individual StockItem objects.
This is useful for tracking unit acceptance tests, and particularly useful when integrated This is useful for tracking unit acceptance tests, and particularly useful when integrated

View File

@ -1693,3 +1693,62 @@ class StockMergeTest(StockAPITestCase):
# Total number of stock items has been reduced! # Total number of stock items has been reduced!
self.assertEqual(StockItem.objects.filter(part=self.part).count(), n - 2) 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)