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:
@ -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'),
|
||||
|
@ -50,3 +50,9 @@
|
||||
part: 101
|
||||
sub_part: 100
|
||||
quantity: 10
|
||||
|
||||
- model: part.bomitemsubstitute
|
||||
pk: 1
|
||||
fields:
|
||||
part: 5
|
||||
bom_item: 6
|
||||
|
@ -188,3 +188,9 @@
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: part.partrelated
|
||||
pk: 1
|
||||
fields:
|
||||
part_1: 10003
|
||||
part_2: 10004
|
||||
|
38
InvenTree/part/migrations/0112_auto_20230525_1606.py
Normal file
38
InvenTree/part/migrations/0112_auto_20230525_1606.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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 = [
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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"""
|
||||
|
Reference in New Issue
Block a user