2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 13:05:42 +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:
Oliver
2023-02-02 09:24:16 +11:00
committed by GitHub
parent c6df0dbb2d
commit 4f029d4d81
15 changed files with 770 additions and 585 deletions

View File

@ -1,7 +1,6 @@
"""Provides a JSON API for the Part app."""
import functools
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.db.models import Count, F, Q
@ -18,7 +17,6 @@ from rest_framework.response import Response
import order.models
from build.models import Build, BuildItem
from company.models import Company, ManufacturerPart, SupplierPart
from InvenTree.api import (APIDownloadMixin, AttachmentMixin,
ListCreateDestroyAPIView)
from InvenTree.filters import InvenTreeOrderingFilter
@ -33,7 +31,6 @@ from InvenTree.status_codes import (BuildStatus, PurchaseOrderStatus,
SalesOrderStatus)
from part.admin import PartCategoryResource, PartResource
from plugin.serializers import MetadataSerializer
from stock.models import StockItem, StockLocation
from . import serializers as part_serializers
from . import views
@ -1096,25 +1093,7 @@ class PartFilter(rest_filters.FilterSet):
class PartList(APIDownloadMixin, ListCreateAPI):
"""API endpoint for accessing a list of Part objects.
- GET: Return list of objects
- POST: Create a new Part object
The Part object list can be filtered by:
- category: Filter by PartCategory reference
- cascade: If true, include parts from sub-categories
- starred: Is the part "starred" by the current user?
- is_template: Is the part a template part?
- variant_of: Filter by variant_of Part reference
- assembly: Filter by assembly field
- component: Filter by component field
- trackable: Filter by trackable field
- purchaseable: Filter by purcahseable field
- salable: Filter by salable field
- active: Filter by active field
- ancestor: Filter parts by 'ancestor' (template / variant tree)
"""
"""API endpoint for accessing a list of Part objects, or creating a new Part instance"""
serializer_class = part_serializers.PartSerializer
queryset = Part.objects.all()
@ -1127,6 +1106,9 @@ class PartList(APIDownloadMixin, ListCreateAPI):
# Ensure the request context is passed through
kwargs['context'] = self.get_serializer_context()
# Indicate that we can create a new Part via this endpoint
kwargs['create'] = True
# Pass a list of "starred" parts to the current user to the serializer
# We do this to reduce the number of database queries required!
if self.starred_parts is None and self.request is not None:
@ -1144,6 +1126,13 @@ class PartList(APIDownloadMixin, ListCreateAPI):
return self.serializer_class(*args, **kwargs)
def get_serializer_context(self):
"""Extend serializer context data"""
context = super().get_serializer_context()
context['request'] = self.request
return context
def download_queryset(self, queryset, export_format):
"""Download the filtered queryset as a data file"""
dataset = PartResource().export(queryset=queryset)
@ -1241,127 +1230,6 @@ class PartList(APIDownloadMixin, ListCreateAPI):
part.save(**{'add_category_templates': copy_templates})
# Optionally copy data from another part (e.g. when duplicating)
copy_from = data.get('copy_from', None)
if copy_from is not None:
try:
original = Part.objects.get(pk=copy_from)
copy_bom = str2bool(data.get('copy_bom', False))
copy_parameters = str2bool(data.get('copy_parameters', False))
copy_image = str2bool(data.get('copy_image', True))
# Copy image?
if copy_image:
part.image = original.image
part.save()
# Copy BOM?
if copy_bom:
part.copy_bom_from(original)
# Copy parameter data?
if copy_parameters:
part.copy_parameters_from(original)
except (ValueError, Part.DoesNotExist):
pass
# Optionally create initial stock item
initial_stock = str2bool(data.get('initial_stock', False))
if initial_stock:
try:
initial_stock_quantity = Decimal(data.get('initial_stock_quantity', ''))
if initial_stock_quantity <= 0:
raise ValidationError({
'initial_stock_quantity': [_('Must be greater than zero')],
})
except (ValueError, InvalidOperation): # Invalid quantity provided
raise ValidationError({
'initial_stock_quantity': [_('Must be a valid quantity')],
})
initial_stock_location = data.get('initial_stock_location', None)
try:
initial_stock_location = StockLocation.objects.get(pk=initial_stock_location)
except (ValueError, StockLocation.DoesNotExist):
initial_stock_location = None
if initial_stock_location is None:
if part.default_location is not None:
initial_stock_location = part.default_location
else:
raise ValidationError({
'initial_stock_location': [_('Specify location for initial part stock')],
})
stock_item = StockItem(
part=part,
quantity=initial_stock_quantity,
location=initial_stock_location,
)
stock_item.save(user=request.user)
# Optionally add manufacturer / supplier data to the part
if part.purchaseable and str2bool(data.get('add_supplier_info', False)):
try:
manufacturer = Company.objects.get(pk=data.get('manufacturer', None))
except Exception:
manufacturer = None
try:
supplier = Company.objects.get(pk=data.get('supplier', None))
except Exception:
supplier = None
mpn = str(data.get('MPN', '')).strip()
sku = str(data.get('SKU', '')).strip()
# Construct a manufacturer part
if manufacturer or mpn:
if not manufacturer:
raise ValidationError({
'manufacturer': [_("This field is required")]
})
if not mpn:
raise ValidationError({
'MPN': [_("This field is required")]
})
manufacturer_part = ManufacturerPart.objects.create(
part=part,
manufacturer=manufacturer,
MPN=mpn
)
else:
# No manufacturer part data specified
manufacturer_part = None
if supplier or sku:
if not supplier:
raise ValidationError({
'supplier': [_("This field is required")]
})
if not sku:
raise ValidationError({
'SKU': [_("This field is required")]
})
SupplierPart.objects.create(
part=part,
supplier=supplier,
SKU=sku,
manufacturer_part=manufacturer_part,
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

View File

@ -54,6 +54,20 @@
template: 3
data: 12
- model: part.PartParameter
pk: 6
fields:
part: 100
template: 3
data: 12
- model: part.PartParameter
pk: 7
fields:
part: 100
template: 1
data: 12
# Add some template parameters to categories (requires category.yaml)
- model: part.PartCategoryParameterTemplate
pk: 1

View File

@ -391,17 +391,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
parent_attr = 'variant_of'
def __init__(self, *args, **kwargs):
"""Custom initialization routine for the Part model.
Ensures that custom serializer fields (without matching model fields) are removed
"""
# Remote image specified during creation via API
kwargs.pop('remote_image', None)
super().__init__(*args, **kwargs)
@staticmethod
def get_api_url():
"""Return the list API endpoint URL associated with the Part model"""
@ -2034,41 +2023,6 @@ class Part(InvenTreeBarcodeMixin, MetadataMixin, MPTTModel):
parameter.save()
@transaction.atomic
def deep_copy(self, other, **kwargs):
"""Duplicates non-field data from another part.
Does not alter the normal fields of this part, but can be used to copy other data linked by ForeignKey refernce.
Keyword Args:
image: If True, copies Part image (default = True)
bom: If True, copies BOM data (default = False)
parameters: If True, copies Parameters data (default = True)
"""
# Copy the part image
if kwargs.get('image', True):
if other.image:
# Reference the other image from this Part
self.image = other.image
# Copy the BOM data
if kwargs.get('bom', False):
self.copy_bom_from(other)
# Copy the parameters data
if kwargs.get('parameters', True):
self.copy_parameters_from(other)
# Copy the fields that aren't available in the duplicate form
self.salable = other.salable
self.assembly = other.assembly
self.component = other.component
self.purchaseable = other.purchaseable
self.trackable = other.trackable
self.virtual = other.virtual
self.save()
def getTestTemplates(self, required=None, include_parent=True):
"""Return a list of all test templates associated with this Part.

View File

@ -5,6 +5,7 @@ import io
from decimal import Decimal
from django.core.files.base import ContentFile
from django.core.validators import MinValueValidator
from django.db import models, transaction
from django.db.models import ExpressionWrapper, F, FloatField, Q
from django.db.models.functions import Coalesce
@ -14,8 +15,10 @@ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
import company.models
import InvenTree.helpers
import part.filters
import stock.models
from common.settings import currency_code_default, currency_code_mappings
from InvenTree.serializers import (DataFileExtractSerializer,
DataFileUploadSerializer,
@ -304,6 +307,113 @@ class PartBriefSerializer(InvenTreeModelSerializer):
]
class DuplicatePartSerializer(serializers.Serializer):
"""Serializer for specifying options when duplicating a Part.
The fields in this serializer control how the Part is duplicated.
"""
part = serializers.PrimaryKeyRelatedField(
queryset=Part.objects.all(),
label=_('Original Part'), help_text=_('Select original part to duplicate'),
required=True,
)
copy_image = serializers.BooleanField(
label=_('Copy Image'), help_text=_('Copy image from original part'),
required=False, default=False,
)
copy_bom = serializers.BooleanField(
label=_('Copy BOM'), help_text=_('Copy bill of materials from original part'),
required=False, default=False,
)
copy_parameters = serializers.BooleanField(
label=_('Copy Parameters'), help_text=_('Copy parameter data from original part'),
required=False, default=False,
)
class InitialStockSerializer(serializers.Serializer):
"""Serializer for creating initial stock quantity."""
quantity = serializers.DecimalField(
max_digits=15, decimal_places=5, validators=[MinValueValidator(0)],
label=_('Initial Stock Quantity'), help_text=_('Specify initial stock quantity for this Part. If quantity is zero, no stock is added.'),
required=True,
)
location = serializers.PrimaryKeyRelatedField(
queryset=stock.models.StockLocation.objects.all(),
label=_('Initial Stock Location'), help_text=_('Specify initial stock location for this Part'),
allow_null=True, required=False,
)
class InitialSupplierSerializer(serializers.Serializer):
"""Serializer for adding initial supplier / manufacturer information"""
supplier = serializers.PrimaryKeyRelatedField(
queryset=company.models.Company.objects.all(),
label=_('Supplier'), help_text=_('Select supplier (or leave blank to skip)'),
allow_null=True, required=False,
)
sku = serializers.CharField(
max_length=100, required=False, allow_blank=True,
label=_('SKU'), help_text=_('Supplier stock keeping unit'),
)
manufacturer = serializers.PrimaryKeyRelatedField(
queryset=company.models.Company.objects.all(),
label=_('Manufacturer'), help_text=_('Select manufacturer (or leave blank to skip)'),
allow_null=True, required=False,
)
mpn = serializers.CharField(
max_length=100, required=False, allow_blank=True,
label=_('MPN'), help_text=_('Manufacturer part number'),
)
def validate_supplier(self, company):
"""Validation for the provided Supplier"""
if company and not company.is_supplier:
raise serializers.ValidationError(_('Selected company is not a valid supplier'))
return company
def validate_manufacturer(self, company):
"""Validation for the provided Manufacturer"""
if company and not company.is_manufacturer:
raise serializers.ValidationError(_('Selected company is not a valid manufacturer'))
return company
def validate(self, data):
"""Extra validation for this serializer"""
if company.models.ManufacturerPart.objects.filter(
manufacturer=data.get('manufacturer', None),
MPN=data.get('mpn', '')
).exists():
raise serializers.ValidationError({
'mpn': _('Manufacturer part matching this MPN already exists')
})
if company.models.SupplierPart.objects.filter(
supplier=data.get('supplier', None),
SKU=data.get('sku', '')
).exists():
raise serializers.ValidationError({
'sku': _('Supplier part matching this SKU already exists')
})
return data
class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
"""Serializer for complete detail information of a part.
@ -314,6 +424,19 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
"""Return the API url associated with this serializer"""
return reverse_lazy('api-part-list')
def skip_create_fields(self):
"""Skip these fields when instantiating a new Part instance"""
fields = super().skip_create_fields()
fields += [
'duplicate',
'initial_stock',
'initial_supplier',
]
return fields
def __init__(self, *args, **kwargs):
"""Custom initialization method for PartSerializer:
@ -325,6 +448,8 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
parameters = kwargs.pop('parameters', False)
create = kwargs.pop('create', False)
super().__init__(*args, **kwargs)
if category_detail is not True:
@ -333,6 +458,11 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
if parameters is not True:
self.fields.pop('parameters')
if create is not True:
# These fields are only used for the LIST API endpoint
for f in self.skip_create_fields()[1:]:
self.fields.pop(f)
@staticmethod
def annotate_queryset(queryset):
"""Add some extra annotations to the queryset.
@ -427,6 +557,22 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
read_only=True,
)
# Extra fields used only for creation of a new Part instance
duplicate = DuplicatePartSerializer(
label=_('Duplicate Part'), help_text=_('Copy initial data from another Part'),
write_only=True, required=False
)
initial_stock = InitialStockSerializer(
label=_('Initial Stock'), help_text=_('Create Part with initial stock quantity'),
write_only=True, required=False,
)
initial_supplier = InitialSupplierSerializer(
label=_('Supplier Information'), help_text=_('Add initial supplier information for this part'),
write_only=True, required=False,
)
class Meta:
"""Metaclass defining serializer fields"""
model = Part
@ -475,12 +621,83 @@ class PartSerializer(RemoteImageMixin, InvenTreeModelSerializer):
'virtual',
'pricing_min',
'pricing_max',
# Fields only used for Part creation
'duplicate',
'initial_stock',
'initial_supplier',
]
read_only_fields = [
'barcode_hash',
]
@transaction.atomic
def create(self, validated_data):
"""Custom method for creating a new Part instance using this serializer"""
duplicate = validated_data.pop('duplicate', None)
initial_stock = validated_data.pop('initial_stock', None)
initial_supplier = validated_data.pop('initial_supplier', None)
instance = super().create(validated_data)
# Copy data from original Part
if duplicate:
original = duplicate['part']
if duplicate['copy_bom']:
instance.copy_bom_from(original)
if duplicate['copy_image']:
instance.image = original.image
instance.save()
if duplicate['copy_parameters']:
instance.copy_parameters_from(original)
# Create initial stock entry
if initial_stock:
quantity = initial_stock['quantity']
location = initial_stock['location'] or instance.default_location
if quantity > 0:
stockitem = stock.models.StockItem(
part=instance,
quantity=quantity,
location=location,
)
stockitem.save(user=self.context['request'].user)
# Create initial supplier information
if initial_supplier:
manufacturer = initial_supplier.get('manufacturer', None)
mpn = initial_supplier.get('mpn', '')
if manufacturer and mpn:
manu_part = company.models.ManufacturerPart.objects.create(
part=instance,
manufacturer=manufacturer,
MPN=mpn
)
else:
manu_part = None
supplier = initial_supplier.get('supplier', None)
sku = initial_supplier.get('sku', '')
if supplier and sku:
company.models.SupplierPart.objects.create(
part=instance,
supplier=supplier,
SKU=sku,
manufacturer_part=manu_part,
)
return instance
def save(self):
"""Save the Part instance"""

View File

@ -336,30 +336,9 @@
{% if roles.part.add %}
$("#part-create").click(function() {
var fields = partFields({
create: true,
createPart({
{% if category %}category: {{ category.pk }},{% endif %}
});
{% if category %}
fields.category.value = {{ category.pk }};
{% endif %}
constructForm('{% url "api-part-list" %}', {
method: 'POST',
fields: fields,
groups: partGroups(),
title: '{% trans "Create Part" %}',
reloadFormAfterSuccess: true,
persist: true,
persistMessage: '{% trans "Create another part after this one" %}',
successMessage: '{% trans "Part created successfully" %}',
onSuccess: function(data) {
// Follow the new part
location.href = `/part/${data.pk}/`;
},
});
});
{% endif %}

View File

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

View File

@ -255,10 +255,6 @@ class PartTest(TestCase):
self.assertIn('InvenTree', barcode)
self.assertIn('"part": {"id": 3}', barcode)
def test_copy(self):
"""Test that we can 'deep copy' a Part instance"""
self.r2.deep_copy(self.r1, image=True, bom=True)
def test_sell_pricing(self):
"""Check that the sell pricebreaks were loaded"""
self.assertTrue(self.r1.has_price_breaks)