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

View File

@ -561,7 +561,10 @@ company_api_urls = [
])),
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'),
])),

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.
Attributes:

View File

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

View File

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

View File

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

View File

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

View File

@ -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<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'),
])),
@ -1910,7 +1913,10 @@ part_api_urls = [
# Base URL for PartTestTemplate API endpoints
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'),
])),
@ -1934,7 +1940,10 @@ part_api_urls = [
# Base URL for PartRelated API endpoints
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'),
])),
@ -1948,7 +1957,10 @@ part_api_urls = [
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'),
])),
@ -2012,7 +2024,10 @@ bom_api_urls = [
re_path(r'^substitute/', include([
# 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
re_path(r'^.*$', BomItemSubstituteList.as_view(), name='api-bom-substitute-list'),

View File

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

View File

@ -188,3 +188,9 @@
level: 0
lft: 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):
dependencies = [
('part', '0111_auto_20230521_1350'),
('part', '0112_auto_20230525_1606'),
]
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')
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 <key:value> 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:

View File

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

View File

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

View File

@ -1430,7 +1430,10 @@ stock_api_urls = [
# StockItemTestResult API endpoints
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'),
])),

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

View File

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