mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-24 15:50:54 +00:00
Improvements for part creation API endpoint (#4281)
* Refactor javascript for creating a new part * Simplify method of removing create fields from serializer * Fix bug which resulted in multiple model instances being created * remove custom code required on Part model * Reorganize existing Part API test code * Add child serializer for part duplication options * Part duplication is now handled by the DRF serializer - Improved validation options - API is self-documenting (no more secret fields) - More DRY * Initial stock is now handled by the DRF serializer * Adds child serializer for adding initial supplier data for a Part instance * Create initial supplier and manufacturer parts as specified * Adding unit tests * Add unit tests for part duplication via API * Bump API version * Add javascript for automatically extracting info for nested fields * Improvements for part creation form rendering - Move to nested fields (using API metadata) - Visual improvements - Improve some field name / description values * Properly format nested fields for sending to the server * Handle error case for scrollIntoView * Display errors for nested fields * Fix bug for filling part category * JS linting fixes * Unit test fixes * Fixes for unit tests * Further fixes to unit tests
This commit is contained in:
InvenTree
InvenTree
common
company
part
templates
@ -12,6 +12,7 @@ from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
import build.models
|
||||
import company.models
|
||||
import order.models
|
||||
from common.models import InvenTreeSetting
|
||||
from company.models import Company, SupplierPart
|
||||
@ -544,20 +545,21 @@ class PartOptionsAPITest(InvenTreeAPITestCase):
|
||||
self.assertTrue(sub_part['filters']['component'])
|
||||
|
||||
|
||||
class PartAPITest(InvenTreeAPITestCase):
|
||||
"""Series of tests for the Part DRF API.
|
||||
|
||||
- Tests for Part API
|
||||
- Tests for PartCategory API
|
||||
"""
|
||||
class PartAPITestBase(InvenTreeAPITestCase):
|
||||
"""Base class for running tests on the Part API endpoints"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'test_templates',
|
||||
'company',
|
||||
'test_templates',
|
||||
'manufacturer_part',
|
||||
'params',
|
||||
'supplier_part',
|
||||
'order',
|
||||
'stock',
|
||||
]
|
||||
|
||||
roles = [
|
||||
@ -568,6 +570,23 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
'part_category.add',
|
||||
]
|
||||
|
||||
|
||||
class PartAPITest(PartAPITestBase):
|
||||
"""Series of tests for the Part DRF API."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'company',
|
||||
'test_templates',
|
||||
'manufacturer_part',
|
||||
'params',
|
||||
'supplier_part',
|
||||
'order',
|
||||
]
|
||||
|
||||
def test_get_categories(self):
|
||||
"""Test that we can retrieve list of part categories, with various filtering options."""
|
||||
url = reverse('api-part-category-list')
|
||||
@ -873,203 +892,6 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
|
||||
self.assertEqual(len(data['results']), n)
|
||||
|
||||
def test_default_values(self):
|
||||
"""Tests for 'default' values:
|
||||
|
||||
Ensure that unspecified fields revert to "default" values
|
||||
(as specified in the model field definition)
|
||||
"""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part',
|
||||
'category': 1,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
data = response.data
|
||||
|
||||
# Check that the un-specified fields have used correct default values
|
||||
self.assertTrue(data['active'])
|
||||
self.assertFalse(data['virtual'])
|
||||
|
||||
# By default, parts are purchaseable
|
||||
self.assertTrue(data['purchaseable'])
|
||||
|
||||
# Set the default 'purchaseable' status to True
|
||||
InvenTreeSetting.set_setting(
|
||||
'PART_PURCHASEABLE',
|
||||
True,
|
||||
self.user
|
||||
)
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'name': 'all defaults 2',
|
||||
'description': 'my test part 2',
|
||||
'category': 1,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# Part should now be purchaseable by default
|
||||
self.assertTrue(response.data['purchaseable'])
|
||||
|
||||
# "default" values should not be used if the value is specified
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'name': 'all defaults 3',
|
||||
'description': 'my test part 3',
|
||||
'category': 1,
|
||||
'active': False,
|
||||
'purchaseable': False,
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
self.assertFalse(response.data['active'])
|
||||
self.assertFalse(response.data['purchaseable'])
|
||||
|
||||
def test_initial_stock(self):
|
||||
"""Tests for initial stock quantity creation."""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
# Track how many parts exist at the start of this test
|
||||
n = Part.objects.count()
|
||||
|
||||
# Set up required part data
|
||||
data = {
|
||||
'category': 1,
|
||||
'name': "My lil' test part",
|
||||
'description': 'A part with which to test',
|
||||
}
|
||||
|
||||
# Signal that we want to add initial stock
|
||||
data['initial_stock'] = True
|
||||
|
||||
# Post without a quantity
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertIn('initial_stock_quantity', response.data)
|
||||
|
||||
# Post with an invalid quantity
|
||||
data['initial_stock_quantity'] = "ax"
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertIn('initial_stock_quantity', response.data)
|
||||
|
||||
# Post with a negative quantity
|
||||
data['initial_stock_quantity'] = -1
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertIn('Must be greater than zero', response.data['initial_stock_quantity'])
|
||||
|
||||
# Post with a valid quantity
|
||||
data['initial_stock_quantity'] = 12345
|
||||
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertIn('initial_stock_location', response.data)
|
||||
|
||||
# Check that the number of parts has not increased (due to form failures)
|
||||
self.assertEqual(Part.objects.count(), n)
|
||||
|
||||
# Now, set a location
|
||||
data['initial_stock_location'] = 1
|
||||
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
# Check that the part has been created
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
pk = response.data['pk']
|
||||
|
||||
new_part = Part.objects.get(pk=pk)
|
||||
|
||||
self.assertEqual(new_part.total_stock, 12345)
|
||||
|
||||
def test_initial_supplier_data(self):
|
||||
"""Tests for initial creation of supplier / manufacturer data."""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
n = Part.objects.count()
|
||||
|
||||
# Set up initial part data
|
||||
data = {
|
||||
'category': 1,
|
||||
'name': 'Buy Buy Buy',
|
||||
'description': 'A purchaseable part',
|
||||
'purchaseable': True,
|
||||
}
|
||||
|
||||
# Signal that we wish to create initial supplier data
|
||||
data['add_supplier_info'] = True
|
||||
|
||||
# Specify MPN but not manufacturer
|
||||
data['MPN'] = 'MPN-123'
|
||||
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertIn('manufacturer', response.data)
|
||||
|
||||
# Specify manufacturer but not MPN
|
||||
del data['MPN']
|
||||
data['manufacturer'] = 1
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertIn('MPN', response.data)
|
||||
|
||||
# Specify SKU but not supplier
|
||||
del data['manufacturer']
|
||||
data['SKU'] = 'SKU-123'
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertIn('supplier', response.data)
|
||||
|
||||
# Specify supplier but not SKU
|
||||
del data['SKU']
|
||||
data['supplier'] = 1
|
||||
response = self.post(url, data, expected_code=400)
|
||||
self.assertIn('SKU', response.data)
|
||||
|
||||
# Check that no new parts have been created
|
||||
self.assertEqual(Part.objects.count(), n)
|
||||
|
||||
# Now, fully specify the details
|
||||
data['SKU'] = 'SKU-123'
|
||||
data['supplier'] = 3
|
||||
data['MPN'] = 'MPN-123'
|
||||
data['manufacturer'] = 6
|
||||
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
pk = response.data['pk']
|
||||
|
||||
new_part = Part.objects.get(pk=pk)
|
||||
|
||||
# Check that there is a new manufacturer part *and* a new supplier part
|
||||
self.assertEqual(new_part.supplier_parts.count(), 1)
|
||||
self.assertEqual(new_part.manufacturer_parts.count(), 1)
|
||||
|
||||
def test_strange_chars(self):
|
||||
"""Test that non-standard ASCII chars are accepted."""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
name = "Kaltgerätestecker"
|
||||
description = "Gerät"
|
||||
|
||||
data = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"category": 2
|
||||
}
|
||||
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
self.assertEqual(response.data['name'], name)
|
||||
self.assertEqual(response.data['description'], description)
|
||||
|
||||
def test_template_filters(self):
|
||||
"""Unit tests for API filters related to template parts:
|
||||
|
||||
@ -1295,30 +1117,256 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertEqual(part.category.name, row['Category Name'])
|
||||
|
||||
|
||||
class PartDetailTests(InvenTreeAPITestCase):
|
||||
class PartCreationTests(PartAPITestBase):
|
||||
"""Tests for creating new Part instances via the API"""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Tests for 'default' values:
|
||||
|
||||
Ensure that unspecified fields revert to "default" values
|
||||
(as specified in the model field definition)
|
||||
"""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'name': 'all defaults',
|
||||
'description': 'my test part',
|
||||
'category': 1,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
data = response.data
|
||||
|
||||
# Check that the un-specified fields have used correct default values
|
||||
self.assertTrue(data['active'])
|
||||
self.assertFalse(data['virtual'])
|
||||
|
||||
# By default, parts are purchaseable
|
||||
self.assertTrue(data['purchaseable'])
|
||||
|
||||
# Set the default 'purchaseable' status to True
|
||||
InvenTreeSetting.set_setting(
|
||||
'PART_PURCHASEABLE',
|
||||
True,
|
||||
self.user
|
||||
)
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'name': 'all defaults 2',
|
||||
'description': 'my test part 2',
|
||||
'category': 1,
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
# Part should now be purchaseable by default
|
||||
self.assertTrue(response.data['purchaseable'])
|
||||
|
||||
# "default" values should not be used if the value is specified
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'name': 'all defaults 3',
|
||||
'description': 'my test part 3',
|
||||
'category': 1,
|
||||
'active': False,
|
||||
'purchaseable': False,
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
self.assertFalse(response.data['active'])
|
||||
self.assertFalse(response.data['purchaseable'])
|
||||
|
||||
def test_initial_stock(self):
|
||||
"""Tests for initial stock quantity creation."""
|
||||
|
||||
def submit(stock_data, expected_code=None):
|
||||
"""Helper function for submitting with initial stock data"""
|
||||
|
||||
data = {
|
||||
'category': 1,
|
||||
'name': "My lil' test part",
|
||||
'description': 'A part with which to test',
|
||||
}
|
||||
|
||||
data['initial_stock'] = stock_data
|
||||
|
||||
response = self.post(
|
||||
reverse('api-part-list'),
|
||||
data,
|
||||
expected_code=expected_code
|
||||
)
|
||||
|
||||
return response.data
|
||||
|
||||
# Track how many parts exist at the start of this test
|
||||
n = Part.objects.count()
|
||||
|
||||
# Submit with empty data
|
||||
response = submit({}, expected_code=400)
|
||||
self.assertIn('This field is required', str(response['initial_stock']['quantity']))
|
||||
|
||||
# Submit with invalid quantity
|
||||
response = submit({
|
||||
'quantity': 'ax',
|
||||
}, expected_code=400)
|
||||
self.assertIn('A valid number is required', str(response['initial_stock']['quantity']))
|
||||
|
||||
# Submit with valid data
|
||||
response = submit({
|
||||
'quantity': 50,
|
||||
'location': 1,
|
||||
}, expected_code=201)
|
||||
|
||||
part = Part.objects.get(pk=response['pk'])
|
||||
self.assertEqual(part.total_stock, 50)
|
||||
self.assertEqual(n + 1, Part.objects.count())
|
||||
|
||||
def test_initial_supplier_data(self):
|
||||
"""Tests for initial creation of supplier / manufacturer data."""
|
||||
|
||||
def submit(supplier_data, expected_code=400):
|
||||
"""Helper function for submitting with supplier data"""
|
||||
|
||||
data = {
|
||||
'name': 'My test part',
|
||||
'description': 'A test part thingy',
|
||||
'category': 1,
|
||||
}
|
||||
|
||||
data['initial_supplier'] = supplier_data
|
||||
|
||||
response = self.post(
|
||||
reverse('api-part-list'),
|
||||
data,
|
||||
expected_code=expected_code
|
||||
)
|
||||
|
||||
return response.data
|
||||
|
||||
n_part = Part.objects.count()
|
||||
n_mp = company.models.ManufacturerPart.objects.count()
|
||||
n_sp = company.models.SupplierPart.objects.count()
|
||||
|
||||
# Submit with an invalid manufacturer
|
||||
response = submit({
|
||||
'manufacturer': 99999,
|
||||
})
|
||||
|
||||
self.assertIn('object does not exist', str(response['initial_supplier']['manufacturer']))
|
||||
|
||||
response = submit({
|
||||
'manufacturer': 8
|
||||
})
|
||||
|
||||
self.assertIn('Selected company is not a valid manufacturer', str(response['initial_supplier']['manufacturer']))
|
||||
|
||||
# Submit with an invalid supplier
|
||||
response = submit({
|
||||
'supplier': 8,
|
||||
})
|
||||
|
||||
self.assertIn('Selected company is not a valid supplier', str(response['initial_supplier']['supplier']))
|
||||
|
||||
# Test for duplicate MPN
|
||||
response = submit({
|
||||
'manufacturer': 6,
|
||||
'mpn': 'MPN123',
|
||||
})
|
||||
|
||||
self.assertIn('Manufacturer part matching this MPN already exists', str(response))
|
||||
|
||||
# Test for duplicate SKU
|
||||
response = submit({
|
||||
'supplier': 2,
|
||||
'sku': 'MPN456-APPEL',
|
||||
})
|
||||
|
||||
self.assertIn('Supplier part matching this SKU already exists', str(response))
|
||||
|
||||
# Test fields which are too long
|
||||
response = submit({
|
||||
'sku': 'abc' * 100,
|
||||
'mpn': 'xyz' * 100,
|
||||
})
|
||||
|
||||
too_long = 'Ensure this field has no more than 100 characters'
|
||||
|
||||
self.assertIn(too_long, str(response['initial_supplier']['sku']))
|
||||
self.assertIn(too_long, str(response['initial_supplier']['mpn']))
|
||||
|
||||
# Finally, submit a valid set of information
|
||||
response = submit(
|
||||
{
|
||||
'supplier': 2,
|
||||
'sku': 'ABCDEFG',
|
||||
'manufacturer': 6,
|
||||
'mpn': 'QWERTY'
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
self.assertEqual(n_part + 1, Part.objects.count())
|
||||
self.assertEqual(n_sp + 1, company.models.SupplierPart.objects.count())
|
||||
self.assertEqual(n_mp + 1, company.models.ManufacturerPart.objects.count())
|
||||
|
||||
def test_strange_chars(self):
|
||||
"""Test that non-standard ASCII chars are accepted."""
|
||||
url = reverse('api-part-list')
|
||||
|
||||
name = "Kaltgerätestecker"
|
||||
description = "Gerät"
|
||||
|
||||
data = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"category": 2
|
||||
}
|
||||
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
self.assertEqual(response.data['name'], name)
|
||||
self.assertEqual(response.data['description'], description)
|
||||
|
||||
def test_duplication(self):
|
||||
"""Test part duplication options"""
|
||||
|
||||
# Run a matrix of tests
|
||||
for bom in [True, False]:
|
||||
for img in [True, False]:
|
||||
for params in [True, False]:
|
||||
response = self.post(
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
'name': f'thing_{bom}{img}{params}',
|
||||
'description': 'Some description',
|
||||
'category': 1,
|
||||
'duplicate': {
|
||||
'part': 100,
|
||||
'copy_bom': bom,
|
||||
'copy_image': img,
|
||||
'copy_parameters': params,
|
||||
}
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
part = Part.objects.get(pk=response.data['pk'])
|
||||
|
||||
# Check new part
|
||||
self.assertEqual(part.bom_items.count(), 4 if bom else 0)
|
||||
self.assertEqual(part.parameters.count(), 2 if params else 0)
|
||||
|
||||
|
||||
class PartDetailTests(PartAPITestBase):
|
||||
"""Test that we can create / edit / delete Part objects via the API."""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'company',
|
||||
'test_templates',
|
||||
'manufacturer_part',
|
||||
'supplier_part',
|
||||
'order',
|
||||
'stock',
|
||||
]
|
||||
|
||||
roles = [
|
||||
'part.change',
|
||||
'part.add',
|
||||
'part.delete',
|
||||
'part_category.change',
|
||||
'part_category.add',
|
||||
]
|
||||
|
||||
def test_part_operations(self):
|
||||
"""Test that Part instances can be adjusted via the API"""
|
||||
n = Part.objects.count()
|
||||
@ -2556,7 +2604,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(len(response.data), 5)
|
||||
self.assertEqual(len(response.data), 7)
|
||||
|
||||
# Filter by part
|
||||
response = self.get(
|
||||
@ -2576,7 +2624,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
def test_create_param(self):
|
||||
"""Test that we can create a param via the API."""
|
||||
@ -2595,7 +2643,7 @@ class PartParameterTest(InvenTreeAPITestCase):
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(len(response.data), 6)
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
def test_param_detail(self):
|
||||
"""Tests for the PartParameter detail endpoint."""
|
||||
|
Reference in New Issue
Block a user