2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 04:55:44 +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
21 changed files with 462 additions and 136 deletions

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