Files
InvenTree/src/backend/InvenTree/common/test_api.py
T
2026-08-23 23:02:08 +02:00

1628 lines
58 KiB
Python

"""API unit tests for InvenTree common functionality."""
import io
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test.utils import override_settings
from django.urls import reverse
from PIL import Image
from taggit.models import Tag
import common.models
from common.models import SelectionList, SelectionListEntry
from common.settings import set_global_setting
from InvenTree.unit_test import InvenTreeAPITestCase, findOffloadedEvent
class DataOutputAPITests(InvenTreeAPITestCase):
"""API tests for the DataOutput endpoint."""
roles = 'all'
def setUp(self):
"""Set up some test data for DataOutput API testing."""
from report.models import DataOutput
super().setUp()
for ii in range(5):
DataOutput.objects.create(
output_type='test_output',
user=self.user if ii % 2 == 0 else None,
complete=ii % 2 == 1,
)
def test_data_output_list(self):
"""Test the DataOutput API list endpoint."""
url = reverse('api-data-output-list')
# Non-staff user should only see outputs which are either enabled for all users, or created by themselves
self.user.is_staff = False
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 3)
for output in response.data:
self.assertEqual(output['user'], self.user.pk)
# Set staff access = True, so we should see all outputs
self.user.is_staff = True
self.user.save()
response = self.get(url)
self.assertEqual(len(response.data), 5)
class ParameterAPITests(InvenTreeAPITestCase):
"""Tests for the Parameter API."""
roles = 'all'
def test_template_options(self):
"""Test OPTIONS information for the ParameterTemplate API endpoint."""
url = reverse('api-parameter-template-list')
options = self.options(url)
actions = options.data['actions']['GET']
for field in [
'pk',
'name',
'units',
'description',
'model_type',
'selectionlist',
'enabled',
'unique',
]:
self.assertIn(
field,
actions.keys(),
f'Field "{field}" missing from ParameterTemplate API!',
)
model_types = [act['value'] for act in actions['model_type']['choices']]
for mdl in [
'part.part',
'build.build',
'company.company',
'order.purchaseorder',
]:
self.assertIn(
mdl,
model_types,
f'Model type "{mdl}" missing from ParameterTemplate API!',
)
def test_parameter_options(self):
"""Test OPTIONS information for the Parameter API endpoint."""
url = reverse('api-parameter-list')
options = self.options(url)
actions = options.data['actions']['GET']
for field in [
'pk',
'template',
'model_type',
'model_id',
'data',
'data_numeric',
]:
self.assertIn(
field, actions.keys(), f'Field "{field}" missing from Parameter API!'
)
self.assertFalse(actions['data']['read_only'])
self.assertFalse(actions['model_type']['read_only'])
def test_template_api(self):
"""Test ParameterTemplate API functionality."""
url = reverse('api-parameter-template-list')
N = common.models.ParameterTemplate.objects.count()
# Create a new ParameterTemplate - initially with invalid model_type field
data = {
'name': 'Test Parameter',
'units': 'mm',
'description': 'A test parameter template',
'model_type': 'order.salesorderx',
'enabled': True,
}
response = self.post(url, data, expected_code=400)
self.assertIn('Content type not found', str(response.data['model_type']))
data['model_type'] = 'order.salesorder'
response = self.post(url, data, expected_code=201)
pk = response.data['pk']
# Verify that the ParameterTemplate was created
self.assertEqual(common.models.ParameterTemplate.objects.count(), N + 1)
template = common.models.ParameterTemplate.objects.get(pk=pk)
self.assertEqual(template.name, 'Test Parameter')
self.assertEqual(template.description, 'A test parameter template')
self.assertEqual(template.units, 'mm')
# Let's update the Template via the API
data = {'description': 'An UPDATED test parameter template'}
response = self.patch(
reverse('api-parameter-template-detail', kwargs={'pk': pk}),
data,
expected_code=200,
)
template.refresh_from_db()
self.assertEqual(template.description, 'An UPDATED test parameter template')
# Finally, let's delete the Template
response = self.delete(
reverse('api-parameter-template-detail', kwargs={'pk': pk}),
expected_code=204,
)
self.assertEqual(common.models.ParameterTemplate.objects.count(), N)
self.assertFalse(common.models.ParameterTemplate.objects.filter(pk=pk).exists())
# Let's create a template which does not specify a model_type
data = {
'name': 'Universal Parameter',
'units': '',
'description': 'A parameter template for all models',
'enabled': False,
}
response = self.post(url, data, expected_code=201)
self.assertIsNone(response.data['model_type'])
self.assertFalse(response.data['enabled'])
def test_template_filters(self):
"""Tests for API filters against ParameterTemplate endpoint."""
from company.models import Company
# Create some ParameterTemplate objects
t1 = common.models.ParameterTemplate.objects.create(
name='Template A',
description='Template with choices',
choices='apple,banana,cherry',
enabled=True,
)
t2 = common.models.ParameterTemplate.objects.create(
name='Template B',
description='Template without choices',
enabled=True,
units='mm',
model_type=Company.get_content_type(),
)
t3 = common.models.ParameterTemplate.objects.create(
name='Template C', description='Another template', enabled=False
)
url = reverse('api-parameter-template-list')
# Filter by 'enabled' status
response = self.get(url, data={'enabled': True})
self.assertEqual(len(response.data), 2)
response = self.get(url, data={'enabled': False})
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], t3.pk)
# Filter by 'has_choices'
response = self.get(url, data={'has_choices': True})
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], t1.pk)
response = self.get(url, data={'has_choices': False})
self.assertEqual(len(response.data), 2)
# Filter by 'model_type'
response = self.get(url, data={'model_type': 'company.company'})
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], t2.pk)
# Filter by 'has_units'
response = self.get(url, data={'has_units': True})
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['pk'], t2.pk)
response = self.get(url, data={'has_units': False})
self.assertEqual(len(response.data), 2)
# Filter by 'for_model'
# Note that a 'blank' model_type is considered to match all models
response = self.get(url, data={'for_model': 'part.part'})
self.assertEqual(len(response.data), 2)
response = self.get(url, data={'for_model': 'company'})
self.assertEqual(len(response.data), 3)
# Create a Parameter against a specific Company instance
company = Company.objects.create(
name='Test Company', description='A company for testing'
)
common.models.Parameter.objects.create(
template=t1,
model_type=company.get_content_type(),
model_id=company.pk,
data='apple',
)
model_types = {'company': 3, 'part.part': 2, 'order.purchaseorder': 2}
for model_name, count in model_types.items():
response = self.get(url, data={'for_model': model_name})
self.assertEqual(
len(response.data),
count,
f'Incorrect number of templates for model "{model_name}"',
)
# Filter with an invalid 'for_model'
response = self.get(
url, data={'for_model': 'invalid.modelname'}, expected_code=400
)
self.assertIn('Invalid content type: invalid.modelname', str(response.data))
# Filter the "exists for model" filter
model_types = {'company': 1, 'part.part': 0, 'order.purchaseorder': 0}
for model_name, count in model_types.items():
response = self.get(url, data={'exists_for_model': model_name})
self.assertEqual(
len(response.data),
count,
f'Incorrect number of templates for model "{model_name}"',
)
def test_template_extended_filters(self):
"""Unit testing for more complex filters on the ParameterTemplate endpoint.
Ref: https://github.com/inventree/InvenTree/pull/11383
In these tests we will filter by complex model relations.
"""
from part.models import Part, PartCategory
# Create some part categories
cat_mech = PartCategory.objects.create(
name='Mechanical', description='Mechanical components'
)
cat_elec = PartCategory.objects.create(
name='Electronics', description='Electronic components'
)
cat_pass = PartCategory.objects.create(
name='Passive', description='Passive electronic components', parent=cat_elec
)
cat_res = PartCategory.objects.create(
name='Resistors', description='Resistor components', parent=cat_pass
)
cat_cap = PartCategory.objects.create(
name='Capacitors', description='Capacitor components', parent=cat_pass
)
# Create some parts
capacitors = [
Part.objects.create(
name=f'Capacitor {ii}', description='A capacitor', category=cat_cap
)
for ii in range(5)
]
resistors = [
Part.objects.create(
name=f'Resistor {ii}', description='A resistor', category=cat_res
)
for ii in range(5)
]
# Create some ParameterTemplates which relate to the category of the part
resistance = common.models.ParameterTemplate.objects.create(
name='Resistance', description='The resistance of a part', units='Ohms'
)
capacitance = common.models.ParameterTemplate.objects.create(
name='Capacitance', description='The capacitance of a part', units='Farads'
)
tolerance = common.models.ParameterTemplate.objects.create(
name='Tolerance', description='The tolerance of a part', units='%'
)
for idx, resistor in enumerate(resistors):
common.models.Parameter.objects.create(
template=resistance,
model_type=resistor.get_content_type(),
model_id=resistor.pk,
data=f'{10 * (idx + 1)}k',
)
common.models.Parameter.objects.create(
template=tolerance,
model_type=resistor.get_content_type(),
model_id=resistor.pk,
data=f'{idx + 1}%',
)
for idx, capacitor in enumerate(capacitors):
common.models.Parameter.objects.create(
template=capacitance,
model_type=capacitor.get_content_type(),
model_id=capacitor.pk,
data=f'{10 * (idx + 1)}uF',
)
common.models.Parameter.objects.create(
template=tolerance,
model_type=capacitor.get_content_type(),
model_id=capacitor.pk,
data=f'{5 * (idx + 1)}%',
)
# Ensure that we have the expected number of templates and parameters created for testing
self.assertEqual(common.models.ParameterTemplate.objects.count(), 3)
self.assertEqual(common.models.Parameter.objects.count(), 20)
# Now, we have some data - let's apply some filtering
url = reverse('api-parameter-template-list')
# Return *all* results, without filters
response = self.get(url)
self.assertEqual(len(response.data), 3)
# Filter by 'exists_for_model'
for model_name, count in {
'part.part': 3,
'part': 3,
'company': 0,
'build': 0,
}.items():
response = self.get(url, data={'exists_for_model': model_name})
n = len(response.data)
self.assertEqual(
n,
count,
f'Incorrect number of templates ({n}) for model "{model_name}"',
)
# Filter by 'exists_for_model' and 'exists_for_model_id'
res = resistors[0]
response = self.get(
url, data={'exists_for_model': 'part.part', 'exists_for_model_id': res.pk}
)
self.assertEqual(len(response.data), 2)
pk_list = [t['pk'] for t in response.data]
self.assertIn(resistance.pk, pk_list)
self.assertIn(tolerance.pk, pk_list)
cap = capacitors[0]
response = self.get(
url, data={'exists_for_model': 'part.part', 'exists_for_model_id': cap.pk}
)
self.assertEqual(len(response.data), 2)
pk_list = [t['pk'] for t in response.data]
self.assertIn(capacitance.pk, pk_list)
self.assertIn(tolerance.pk, pk_list)
# Filter by 'exists_for_related_model' (test the "capacitor" relationship)
# Check the 'capacitor' category
response = self.get(
url,
data={
'exists_for_model': 'part.part',
'exists_for_related_model': 'category',
'exists_for_related_model_id': cat_cap.pk,
},
)
self.assertEqual(len(response.data), 2)
pk_list = [t['pk'] for t in response.data]
self.assertIn(capacitance.pk, pk_list)
self.assertIn(tolerance.pk, pk_list)
# Check the 'electronics' category - this should return all parameters
response = self.get(
url,
data={
'exists_for_model': 'part.part',
'exists_for_related_model': 'category',
'exists_for_related_model_id': cat_elec.pk,
},
)
self.assertEqual(len(response.data), 3)
pk_list = [t['pk'] for t in response.data]
self.assertIn(resistance.pk, pk_list)
self.assertIn(capacitance.pk, pk_list)
self.assertIn(tolerance.pk, pk_list)
# Check the 'mechanical' category - this should return no parameters
response = self.get(
url,
data={
'exists_for_model': 'part.part',
'exists_for_related_model': 'category',
'exists_for_related_model_id': cat_mech.pk,
},
)
self.assertEqual(len(response.data), 0)
def test_invalid_filters(self):
"""Test error messages for invalid filter combinations."""
url = reverse('api-parameter-template-list')
# Invalid 'exists_for_model' value
response = self.get(
url, {'exists_for_model': 'asdf---invalid---model'}, expected_code=400
)
self.assertIn(
'Invalid model type provided', str(response.data['exists_for_model'])
)
# Invalid 'exists_for_model_id' value
for model_id in ['not_an_integer', -1, 9999]:
response = self.get(
url,
{'exists_for_model': 'part.part', 'exists_for_model_id': model_id},
expected_code=400,
)
# Invalid 'exists_for_related_model' value
response = self.get(
url,
{
'exists_for_model': 'part',
'exists_for_related_model': 'invalid_field',
'exists_for_related_model_id': 1,
},
expected_code=400,
)
self.assertIn(
'no such field on the base model',
str(response.data['exists_for_related_model']),
)
def test_parameter_api(self):
"""Test Parameter API functionality."""
# Create a simple part to test with
from part.models import Part
part = Part.objects.create(name='Test Part', description='A part for testing')
N = common.models.Parameter.objects.count()
# Create a ParameterTemplate for the Part model
template = common.models.ParameterTemplate.objects.create(
name='Length',
units='mm',
model_type=part.get_content_type(),
description='Length of part',
enabled=True,
)
# Create a Parameter via the API
url = reverse('api-parameter-list')
data = {
'template': template.pk,
'model_type': 'part.part',
'model_id': part.pk,
'data': '25.4',
}
# Initially, user does not have correct permissions
response = self.post(url, data=data, expected_code=403)
self.assertIn(
'User does not have permission to create or edit parameters for this model',
str(response.data['detail']),
)
# Grant user the correct permissions
self.assignRole('part.add')
response = self.post(url, data=data, expected_code=201)
parameter = common.models.Parameter.objects.get(pk=response.data['pk'])
# Check that the Parameter was created
self.assertEqual(common.models.Parameter.objects.count(), N + 1)
# Try to create a duplicate Parameter (should fail)
response = self.post(url, data=data, expected_code=400)
self.assertIn(
'The fields model_type, model_id, template must make a unique set.',
str(response.data['non_field_errors']),
)
# Let's edit the Parameter via the API
url = reverse('api-parameter-detail', kwargs={'pk': parameter.pk})
response = self.patch(url, data={'data': '-2 inches'}, expected_code=200)
# Ensure parameter conversion has correctly updated data_numeric field
data = response.data
self.assertEqual(data['data'], '-2 inches')
self.assertAlmostEqual(data['data_numeric'], -50.8, places=2)
# Finally, delete the Parameter via the API
response = self.delete(url, expected_code=204)
self.assertEqual(common.models.Parameter.objects.count(), N)
self.assertFalse(
common.models.Parameter.objects.filter(pk=parameter.pk).exists()
)
@override_settings(
TESTING_TABLE_EVENTS=True,
PLUGIN_TESTING_EVENTS=True,
PLUGIN_TESTING_EVENTS_ASYNC=True,
)
def test_bulk_create_parameters(self):
"""Test bulk creation of parameters via the API.
Test that:
- The correct number of items are created
- Instance creation events are offloaded to the background worker
"""
from django_q.models import OrmQ
from part.models import Part
self.assignRole('part.add')
OrmQ.objects.all().delete()
set_global_setting('ENABLE_PLUGINS_EVENTS', True)
template = common.models.ParameterTemplate.objects.create(
name='Test Parameter',
description='A parameter template for testing bulk creation',
model_type=None,
)
# Generate a set of parts
parts = [
Part.objects.create(
name=f'Test Part {ii}', description='A part for testing'
)
for ii in range(50)
]
N = common.models.Parameter.objects.count()
# Bulk-create parameters
response = self.post(
reverse('api-parameter-list'),
data=[
{
'template': template.pk,
'model_type': 'part.part',
'model_id': part.pk,
'data': f'Test data {part.pk}',
}
for part in parts
],
benchmark=True,
max_query_count=500,
max_query_time=2.0,
)
self.assertEqual(len(response.data), 50)
# Check that the parameters have been created
self.assertEqual(common.models.Parameter.objects.count(), N + len(parts))
# We expect that 50 events have been offloaded to the background worker
self.assertGreaterEqual(OrmQ.objects.count(), len(parts))
# There should be a parameter for each part
for part in parts:
self.assertEqual(part.parameters.count(), 1)
parameter = part.parameters.first()
self.assertIsNotNone(parameter)
self.assertIsNotNone(parameter.updated)
self.assertIsNotNone(parameter.updated_by)
self.assertEqual(parameter.updated_by, self.user)
# Check that an associated event has been offloaded
self.assertIsNotNone(
findOffloadedEvent(
'part_partparameter.created', matching_kwargs={'id': parameter.pk}
),
f'No created event found for parameter {parameter.pk}',
)
# Check that an extra 'saved' event is *NOT* generated
self.assertIsNone(
findOffloadedEvent(
'part_partparameter.saved', matching_kwargs={'id': parameter.pk}
),
f'Unexpected saved event found for parameter {parameter.pk}',
)
set_global_setting('ENABLE_PLUGINS_EVENTS', False)
def test_parameter_uniqueness(self):
"""Test the uniqueness options which can be applied to a ParameterTemplate."""
from company.models import Company
from part.models import Part
part_a = Part.objects.create(name='Part A', description='A part for testing')
part_b = Part.objects.create(name='Part B', description='A part for testing')
part_c = Part.objects.create(name='Part C', description='A part for testing')
company = Company.objects.create(
name='Test Company', description='A company for testing'
)
template = common.models.ParameterTemplate.objects.create(
name='Serial Number', description='A serial number parameter'
)
self.assertEqual(
template.unique, common.models.ParameterTemplate.UniqueOptions.NONE
)
param_a = common.models.Parameter(
template=template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='ABC123',
)
param_a.full_clean()
param_a.save()
# No uniqueness requirement - a duplicate value against a different part is fine
param_b = common.models.Parameter(
template=template,
model_type=part_b.get_content_type(),
model_id=part_b.pk,
data='ABC123',
)
param_b.full_clean()
param_b.save()
# Re-saving the existing instance (unchanged) should not raise any errors
param_a.full_clean()
param_a.save()
# Now, require uniqueness *per model type*
template.unique = common.models.ParameterTemplate.UniqueOptions.MODEL_TYPE
template.save()
# A new Part with the same value should be rejected
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_c.get_content_type(),
model_id=part_c.pk,
data='ABC123',
).full_clean()
# A case-insensitive match should also be rejected
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_c.get_content_type(),
model_id=part_c.pk,
data='abc123',
).full_clean()
# A different model type entirely is not affected by the 'model type' restriction
param_company = common.models.Parameter(
template=template,
model_type=company.get_content_type(),
model_id=company.pk,
data='ABC123',
)
param_company.full_clean()
param_company.save()
# Finally, require the value to be *globally* unique
template.unique = common.models.ParameterTemplate.UniqueOptions.GLOBAL
template.save()
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_c.get_content_type(),
model_id=part_c.pk,
data='ABC123',
).full_clean()
def test_parameter_uniqueness_units(self):
"""Test that uniqueness checks are unit-aware for templates which define units.
Values expressed in different (but compatible) units which represent the
same physical quantity must be detected as duplicates.
"""
from part.models import Part
part_a = Part.objects.create(name='Part A', description='A part for testing')
part_b = Part.objects.create(name='Part B', description='A part for testing')
template = common.models.ParameterTemplate.objects.create(
name='Resistance',
units='ohm',
description='A globally unique resistance parameter',
unique=common.models.ParameterTemplate.UniqueOptions.GLOBAL,
)
param_a = common.models.Parameter(
template=template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='1000',
)
param_a.full_clean()
param_a.save()
# A value expressed as '1k' ohms is numerically identical to '1000' ohms
with self.assertRaises(ValidationError):
common.models.Parameter(
template=template,
model_type=part_b.get_content_type(),
model_id=part_b.pk,
data='1k',
).full_clean()
# A distinct value (in different units) is not a duplicate
param_b = common.models.Parameter(
template=template,
model_type=part_b.get_content_type(),
model_id=part_b.pk,
data='2k',
)
param_b.full_clean()
param_b.save()
def test_copy_unique_parameters(self):
"""Test that 'unique' parameters are skipped when copying parameters between model instances."""
from part.models import Part
part_a = Part.objects.create(name='Part A', description='A part for testing')
part_b = Part.objects.create(name='Part B', description='A part for testing')
normal_template = common.models.ParameterTemplate.objects.create(
name='Color', description='A normal (non-unique) parameter'
)
unique_template = common.models.ParameterTemplate.objects.create(
name='Serial Number',
description='A globally unique parameter',
unique=common.models.ParameterTemplate.UniqueOptions.GLOBAL,
)
common.models.Parameter.objects.create(
template=normal_template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='Red',
)
common.models.Parameter.objects.create(
template=unique_template,
model_type=part_a.get_content_type(),
model_id=part_a.pk,
data='ABC123',
)
# Copy parameters from part_a to part_b
part_b.copy_parameters_from(part_a)
# The non-unique parameter should have been copied
self.assertEqual(part_b.get_parameter('Color').data, 'Red')
# The unique parameter should *not* have been copied, to avoid a conflicting value
self.assertIsNone(part_b.get_parameter('Serial Number'))
def test_parameter_annotation(self):
"""Test that we can annotate parameters against a queryset."""
from company.models import Company
templates = []
parameters = []
companies = []
for ii in range(100):
company = Company(
name=f'Test Company {ii}',
description='A company for testing parameter annotations',
)
companies.append(company)
Company.objects.bulk_create(companies)
# Let's create a large number of parameters
for ii in range(25):
templates.append(
common.models.ParameterTemplate(
name=f'Test Parameter {ii}',
units='',
description='A parameter for testing annotations',
model_type=Company.get_content_type(),
enabled=True,
)
)
common.models.ParameterTemplate.objects.bulk_create(templates)
# Create a parameter for every company against every template
for company in Company.objects.all():
for template in common.models.ParameterTemplate.objects.all():
parameters.append(
common.models.Parameter(
template=template,
model_type=company.get_content_type(),
model_id=company.pk,
data=f'Test data for {company.name} - {template.name}',
)
)
common.models.Parameter.objects.bulk_create(parameters)
self.assertEqual(
common.models.Parameter.objects.count(), len(companies) * len(templates)
)
# We will fetch the companies, annotated with all parameters
url = reverse('api-company-list')
# By default, we do not expect any parameter annotations
response = self.get(url, data={'limit': 5})
self.assertEqual(response.data['count'], len(companies))
for company in response.data['results']:
self.assertNotIn('parameters', company)
# Fetch all companies, explicitly without parameters
with self.assertNumQueriesLessThan(20):
response = self.get(url, data={'parameters': False})
# Now, annotate with parameters
# This must be done efficiently, without an 1 + N query pattern
with self.assertNumQueriesLessThan(45):
response = self.get(url, data={'parameters': True})
self.assertEqual(len(response.data), len(companies))
for company in response.data:
self.assertIn('parameters', company)
self.assertEqual(
len(company['parameters']),
len(templates),
'Incorrect number of parameter annotations found',
)
def test_parameter_delete(self):
"""Test that associated parameters are correctly deleted when removing the linked model."""
from part.models import Part
part = Part.objects.create(
name='Test Part', description='A part for testing', active=False
)
# Create a ParameterTemplate for the Part model
template = common.models.ParameterTemplate.objects.create(
name='Test Parameter',
description='A parameter template for testing parameter deletion',
model_type=None,
)
# Create a Parameter for the Build
parameter = common.models.Parameter.objects.create(
template=template,
model_type=part.get_content_type(),
model_id=part.pk,
data='Test data',
)
self.assertTrue(
common.models.Parameter.objects.filter(pk=parameter.pk).exists()
)
N = common.models.Parameter.objects.count()
# Now delete the part instance
self.assignRole('part.delete')
self.delete(
reverse('api-part-detail', kwargs={'pk': part.pk}), expected_code=204
)
self.assertEqual(common.models.Parameter.objects.count(), N - 1)
self.assertFalse(
common.models.Parameter.objects.filter(template=template.pk).exists()
)
class AttachmentAPITests(InvenTreeAPITestCase):
"""Tests for the Attachment API."""
def test_attachments(self):
"""Test API functionality for attachments."""
from common.models import Attachment
from part.models import Part
self.assignRole('part.add')
part = Part.objects.create(name='Test Part', description='A part for testing')
N = Attachment.objects.count()
# Upload multiple attachments against the part instance
for ii in range(5):
file_object = io.StringIO('Hello world')
file_object.seek(0)
fn = f'test_file_{ii}.txt'
content_file = ContentFile(file_object.read(), name=fn)
url = reverse('api-attachment-list')
response = self.post(
url,
data={
'model_type': 'part',
'model_id': part.pk,
'attachment': content_file,
'comment': f'This is test file {ii}',
},
format='multipart',
expected_code=201,
)
data = response.data
# Check that the file has actually been created
self.assertEqual(data['filename'], fn)
self.assertTrue(
default_storage.exists(data['attachment'].replace('/media/', ''))
)
# Check that we have the expected number of attachments
self.assertEqual(Attachment.objects.count(), N + 5)
self.assertEqual(part.attachments.count(), 5)
# Let's rename one of the attachments
att = part.attachments.first()
self.assertEqual(att.basename, 'test_file_0.txt')
url = reverse('api-attachment-detail', kwargs={'pk': att.pk})
# A few failed attempts
for new_name in [
'different_ext.docx',
'test_file_1.txt',
'../../test_file.txt',
]:
print('- ATTEMPTING:', new_name)
response = self.patch(url, data={'filename': new_name}, expected_code=400)
att.refresh_from_db()
self.assertEqual(att.basename, 'test_file_0.txt')
# Let's try seriously this time
new_name = 'a_new_file.txt'
response = self.patch(url, data={'filename': new_name}, expected_code=200)
att.refresh_from_db()
self.assertEqual(att.basename, new_name)
# Check that the file has been renamed on disk
self.assertTrue(
default_storage.exists(f'attachments/part/{part.pk}/{new_name}')
)
self.assertFalse(
default_storage.exists(f'attachments/part/{part.pk}/test_file_0.txt')
)
# Next, let's delete the attachment manually - via the API
response = self.delete(url, expected_code=403)
self.assignRole('part.delete')
response = self.delete(url, expected_code=204)
# Check that the file has been deleted from disk
self.assertFalse(
default_storage.exists(f'attachments/part/{part.pk}/{new_name}')
)
self.assertEqual(Attachment.objects.count(), N + 4)
self.assertEqual(part.attachments.count(), 4)
# Fetch the remaining attachments
attachments = list(part.attachments.all())
# Now, delete the part instance
part.active = False
part.save()
part.delete()
self.assertEqual(Attachment.objects.count(), N)
for att in attachments:
# Ensure that the file associated with each attachment has been removed
self.assertFalse(default_storage.exists(att.attachment.path))
def test_attachment_read_permissions(self):
"""Test that reading attachments is gated on the linked model's own view permission.
A user should not be able to list or retrieve attachments linked to a model
type they have no view permission for, even though attachments themselves
have no RuleSet of their own (see users.ruleset.get_ruleset_ignore).
"""
from common.models import Attachment
from part.models import Part
from stock.models import StockItem
part = Part.objects.create(name='Attachable Part', description='A part')
item = StockItem.objects.create(part=part, quantity=10)
part_attachment = Attachment.objects.create(
model_type='part',
model_id=part.pk,
comment='part attachment',
link='https://example.com/part',
)
stock_attachment = Attachment.objects.create(
model_type='stockitem',
model_id=item.pk,
comment='stock attachment',
link='https://example.com/stock',
)
# User has no roles at all - should see nothing, and be denied on direct retrieve
list_url = reverse('api-attachment-list')
response = self.get(list_url, expected_code=200)
result_ids = {result['pk'] for result in response.data}
self.assertNotIn(part_attachment.pk, result_ids)
self.assertNotIn(stock_attachment.pk, result_ids)
self.get(
reverse('api-attachment-detail', kwargs={'pk': part_attachment.pk}),
expected_code=403,
)
self.get(
reverse('api-attachment-detail', kwargs={'pk': stock_attachment.pk}),
expected_code=403,
)
# Grant 'view' permission on 'part' only
self.assignRole('part.view')
response = self.get(list_url, expected_code=200)
result_ids = {result['pk'] for result in response.data}
self.assertIn(part_attachment.pk, result_ids)
self.assertNotIn(stock_attachment.pk, result_ids)
self.get(
reverse('api-attachment-detail', kwargs={'pk': part_attachment.pk}),
expected_code=200,
)
self.get(
reverse('api-attachment-detail', kwargs={'pk': stock_attachment.pk}),
expected_code=403,
)
# Granting 'stock' view permission too now exposes both
self.assignRole('stock.view')
response = self.get(list_url, expected_code=200)
result_ids = {result['pk'] for result in response.data}
self.assertIn(part_attachment.pk, result_ids)
self.assertIn(stock_attachment.pk, result_ids)
self.get(
reverse('api-attachment-detail', kwargs={'pk': stock_attachment.pk}),
expected_code=200,
)
class AttachmentThumbnailAPITests(InvenTreeAPITestCase):
"""Tests for thumbnail generation when uploading attachments via the API."""
def setUp(self):
"""Set up a Part instance and required roles."""
from part.models import Part
super().setUp()
self.assignRole('part.add')
self.assignRole('part.delete')
self.part = Part.objects.create(
name='Thumbnail Test Part', description='Part for thumbnail testing'
)
def _make_image_file(self, name='test.png', size=(100, 100), color='red'):
"""Return a SimpleUploadedFile containing a valid PNG image."""
buf = io.BytesIO()
Image.new('RGB', size, color=color).save(buf, format='PNG')
return SimpleUploadedFile(name, buf.getvalue(), content_type='image/png')
def _upload_attachment(self, file_obj, expected_code=201):
"""Upload a file attachment against the test part and return the response."""
return self.post(
reverse('api-attachment-list'),
data={
'model_type': 'part',
'model_id': self.part.pk,
'attachment': file_obj,
},
format='multipart',
expected_code=expected_code,
)
def test_thumbnail_valid_image(self):
"""Uploading a valid image file should set is_image=True and generate a thumbnail."""
from common.models import Attachment
response = self._upload_attachment(self._make_image_file())
att = Attachment.objects.get(pk=response.data['pk'])
self.assertTrue(att.is_image)
self.assertTrue(att.thumbnail)
self.assertTrue(default_storage.exists(att.thumbnail.name))
def test_thumbnail_invalid_image(self):
"""Uploading a file with an image extension but invalid image data should not create a thumbnail."""
from common.models import Attachment
bad_file = SimpleUploadedFile(
'corrupt.png', b'this is not image data', content_type='image/png'
)
response = self._upload_attachment(bad_file)
att = Attachment.objects.get(pk=response.data['pk'])
self.assertFalse(att.is_image)
self.assertFalse(att.thumbnail)
def test_thumbnail_non_image_file(self):
"""Uploading a non-image file should leave is_image=False with no thumbnail."""
from common.models import Attachment
txt_file = SimpleUploadedFile(
'document.txt', b'Hello, InvenTree!', content_type='text/plain'
)
response = self._upload_attachment(txt_file)
att = Attachment.objects.get(pk=response.data['pk'])
self.assertFalse(att.is_image)
self.assertFalse(att.thumbnail)
def test_thumbnail_large_image(self):
"""A large image attachment should produce a thumbnail no larger than THUMBNAIL_SIZE on each side."""
from common.models import Attachment
response = self._upload_attachment(self._make_image_file(size=(1000, 1000)))
att = Attachment.objects.get(pk=response.data['pk'])
self.assertTrue(att.is_image)
self.assertTrue(att.thumbnail)
thumb_data = default_storage.open(att.thumbnail.name).read()
thumb_img = Image.open(io.BytesIO(thumb_data))
self.assertLessEqual(thumb_img.width, Attachment.THUMBNAIL_SIZE)
self.assertLessEqual(thumb_img.height, Attachment.THUMBNAIL_SIZE)
def test_thumbnail_deleted_with_attachment(self):
"""Deleting an attachment via the API should also remove its thumbnail from storage."""
from common.models import Attachment
response = self._upload_attachment(self._make_image_file())
att = Attachment.objects.get(pk=response.data['pk'])
self.assertTrue(att.thumbnail)
thumb_name = att.thumbnail.name
att_name = att.attachment.name
self.assertTrue(default_storage.exists(att_name))
self.assertTrue(default_storage.exists(thumb_name))
self.delete(
reverse('api-attachment-detail', kwargs={'pk': att.pk}), expected_code=204
)
self.assertFalse(default_storage.exists(att_name))
self.assertFalse(default_storage.exists(thumb_name))
def test_thumbnail_zero_byte_file(self):
"""Uploading a zero-byte file should be rejected by Django's file validation before reaching thumbnail logic."""
empty_file = SimpleUploadedFile('empty.png', b'', content_type='image/png')
# Django's FileField rejects empty uploads at the serializer/validation layer
response = self._upload_attachment(empty_file, expected_code=400)
self.assertIn('attachment', response.data)
def test_thumbnail_link_attachment(self):
"""An attachment created with an external link (no file) should not generate a thumbnail."""
from common.models import Attachment
response = self.post(
reverse('api-attachment-list'),
data={
'model_type': 'part',
'model_id': self.part.pk,
'link': 'https://example.com/some/resource',
},
format='multipart',
expected_code=201,
)
att = Attachment.objects.get(pk=response.data['pk'])
self.assertFalse(att.is_image)
self.assertFalse(att.thumbnail)
def test_is_image_filter(self):
"""The is_image filter on the attachment list endpoint should return only matching attachments."""
url = reverse('api-attachment-list')
base_filters = {'model_type': 'part', 'model_id': self.part.pk}
# Upload one valid image and three non-image attachments
self._upload_attachment(self._make_image_file('img1.png'))
self._upload_attachment(
SimpleUploadedFile(
'corrupt.png', b'not image data', content_type='image/png'
)
)
self._upload_attachment(
SimpleUploadedFile('doc.txt', b'hello', content_type='text/plain')
)
self.post(
url,
data={**base_filters, 'link': 'https://example.com/resource'},
format='multipart',
expected_code=201,
)
all_attachments = self.get(url, base_filters, expected_code=200).data
self.assertEqual(len(all_attachments), 4)
# is_image=true → only the valid image
images = self.get(
url, {**base_filters, 'is_image': 'true'}, expected_code=200
).data
self.assertEqual(len(images), 1)
self.assertTrue(images[0]['is_image'])
# is_image=false → the three non-image attachments
non_images = self.get(
url, {**base_filters, 'is_image': 'false'}, expected_code=200
).data
self.assertEqual(len(non_images), 3)
self.assertTrue(all(not a['is_image'] for a in non_images))
def test_upload_exceeds_size_limit(self):
"""Uploading a file that exceeds INVENTREE_UPLOAD_MAX_SIZE should be rejected with a 400 error."""
from common.settings import get_global_setting, set_global_setting
original_limit = get_global_setting('INVENTREE_UPLOAD_MAX_SIZE')
# Use a 1 MB ceiling so the test file stays small and fast
set_global_setting('INVENTREE_UPLOAD_MAX_SIZE', 1, change_user=None)
limit_bytes = 1 * 1024 * 1024
try:
# File exactly at the limit — validator uses >, so this must be accepted
self._upload_attachment(
SimpleUploadedFile(
'at_limit.txt', b'\x00' * limit_bytes, content_type='text/plain'
),
expected_code=201,
)
# File one byte over the limit — must be rejected
response = self._upload_attachment(
SimpleUploadedFile(
'over_limit.txt',
b'\x00' * (limit_bytes + 1),
content_type='text/plain',
),
expected_code=400,
)
self.assertIn('attachment', response.data)
finally:
set_global_setting(
'INVENTREE_UPLOAD_MAX_SIZE', original_limit, change_user=None
)
class TagAPITests(InvenTreeAPITestCase):
"""Tests for the Tag API endpoints and tag-based filtering."""
roles = 'all'
LIST_URL = 'api-tag-list'
DETAIL_URL = 'api-tag-detail'
def setUp(self):
"""Create a small set of tagged objects for filter testing."""
super().setUp()
from part.models import Part
self.part_a = Part.objects.create(
name='Tagged Part A', description='Part with apple and banana tags'
)
self.part_b = Part.objects.create(
name='Tagged Part B', description='Part with apple tag only'
)
self.part_c = Part.objects.create(
name='Untagged Part C', description='Part with no tags'
)
self.part_a.tags.add('apple', 'banana')
self.part_b.tags.add('apple')
# ------------------------------------------------------------------
# Tag list / CRUD
# ------------------------------------------------------------------
def test_tag_list(self):
"""Tag list endpoint should return all existing tags."""
url = reverse(self.LIST_URL)
response = self.get(url)
names = {t['name'] for t in response.data}
self.assertIn('apple', names)
self.assertIn('banana', names)
def test_tag_create(self):
"""Staff users should be able to create tags via POST."""
url = reverse(self.LIST_URL)
n = Tag.objects.count()
response = self.post(url, {'name': 'cherry'}, expected_code=201)
self.assertEqual(response.data['name'], 'cherry')
self.assertEqual(Tag.objects.count(), n + 1)
def test_tag_create_non_staff(self):
"""Non-staff users must not be able to create tags."""
self.user.is_staff = False
self.user.save()
url = reverse(self.LIST_URL)
self.post(url, {'name': 'forbidden'}, expected_code=403)
def test_tag_edit(self):
"""Staff users should be able to rename a tag via PATCH."""
tag = Tag.objects.get(name='banana')
url = reverse(self.DETAIL_URL, kwargs={'pk': tag.pk})
response = self.patch(url, {'name': 'blueberry'}, expected_code=200)
self.assertEqual(response.data['name'], 'blueberry')
tag.refresh_from_db()
self.assertEqual(tag.name, 'blueberry')
def test_tag_delete(self):
"""Staff users should be able to delete a tag."""
tag = Tag.objects.get(name='banana')
url = reverse(self.DETAIL_URL, kwargs={'pk': tag.pk})
self.delete(url, expected_code=204)
self.assertFalse(Tag.objects.filter(name='banana').exists())
def test_tag_search(self):
"""The list endpoint should support free-text search."""
url = reverse(self.LIST_URL)
response = self.get(url, data={'search': 'app'})
names = [t['name'] for t in response.data]
self.assertIn('apple', names)
self.assertNotIn('banana', names)
# ------------------------------------------------------------------
# Filter by model type
# ------------------------------------------------------------------
def test_tag_filter_model_type(self):
"""Tags applied to a given model type should be returned when filtering by model_type."""
url = reverse(self.LIST_URL)
# Filter for tags applied to Part objects
response = self.get(url, data={'model_type': 'part.part'})
names = {t['name'] for t in response.data}
self.assertIn('apple', names)
self.assertIn('banana', names)
def test_tag_filter_model_type_unrelated(self):
"""Filtering by a model type that has no tagged objects should return an empty list."""
url = reverse(self.LIST_URL)
# StockItem has no tagged objects in this test
response = self.get(url, data={'model_type': 'stock.stockitem'})
self.assertEqual(len(response.data), 0)
def test_tag_filter_model_type_invalid(self):
"""An unrecognised model_type value should return a 400 error."""
url = reverse(self.LIST_URL)
self.get(url, data={'model_type': 'notanapp.notamodel'}, expected_code=400)
# ------------------------------------------------------------------
# Filter Part list by tags
# ------------------------------------------------------------------
def test_part_filter_single_tag(self):
"""Filtering parts by a single tag should return only parts with that tag."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'apple'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertIn(self.part_b.pk, pks)
self.assertNotIn(self.part_c.pk, pks)
def test_part_filter_multiple_tags_and(self):
"""Filtering by comma-separated tags should return only parts that have ALL tags."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'apple,banana'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertNotIn(self.part_b.pk, pks) # only has 'apple'
self.assertNotIn(self.part_c.pk, pks) # no tags at all
def test_part_filter_tag_case_insensitive(self):
"""Tag filtering should be case-insensitive."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'APPLE'})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertIn(self.part_b.pk, pks)
def test_part_filter_nonexistent_tag(self):
"""Filtering by a tag that no part has should return an empty result set."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'doesnotexist'})
self.assertEqual(len(response.data), 0)
def test_part_filter_tag_whitespace(self):
"""Whitespace around comma-separated tag names should be ignored."""
url = reverse('api-part-list')
response = self.get(url, data={'tags': ' apple , banana '})
pks = {p['pk'] for p in response.data}
self.assertIn(self.part_a.pk, pks)
self.assertNotIn(self.part_b.pk, pks)
# ------------------------------------------------------------------
# 'tags' as an OptionalField (data inclusion, not filtering)
# ------------------------------------------------------------------
#
# Every serializer below wires up its 'tags' field via
# `common.filters.enable_tags_filter()`, with `default_include=False` -
# so a plain detail request should never include tag data, and it should
# only appear when the caller explicitly asks for it via `?tags=true`.
def test_part_detail_tags_excluded_by_default(self):
"""A plain part detail request should not include tag data."""
url = reverse('api-part-detail', kwargs={'pk': self.part_a.pk})
response = self.get(url, expected_code=200)
self.assertNotIn('tags', response.data)
def test_part_detail_tags_included_via_query_param(self):
"""Requesting '?tags=true' on part detail should include the part's tag names."""
url = reverse('api-part-detail', kwargs={'pk': self.part_a.pk})
response = self.get(url, data={'tags': 'true'}, expected_code=200)
self.assertIn('tags', response.data)
self.assertEqual(set(response.data['tags']), {'apple', 'banana'})
# An untagged part should report an empty list, not omit the field
url = reverse('api-part-detail', kwargs={'pk': self.part_c.pk})
response = self.get(url, data={'tags': 'true'}, expected_code=200)
self.assertIn('tags', response.data)
self.assertEqual(response.data['tags'], [])
def test_part_list_tags_query_param_collides_with_tag_filter(self):
"""On the list endpoint, '?tags=true' is *not* the OptionalField inclusion flag.
`PartFilter` (the list endpoint's FilterSet) declares its own 'tags' field
(a `TagsFilter`, for filtering by tag name - see the `test_part_filter_*`
tests above), which shadows the serializer's 'tags' OptionalField: both are
wired to the same query parameter name. django-filter processes the
FilterSet before the serializer runs, so '?tags=true' is filtered as "must
have a tag named 'true'" - which nothing does - rather than being treated
as a request to include each part's tag data.
This is presumably not the intended behaviour for a client trying to
request tag data on a list endpoint, but it is the current, real
behaviour - this test locks it in so a change to either `PartFilter` or
`enable_tags_filter()` is a deliberate decision rather than an accident.
"""
url = reverse('api-part-list')
response = self.get(url, data={'tags': 'true'}, expected_code=200)
self.assertEqual(response.data, [])
class SelectionListLockedTest(InvenTreeAPITestCase):
"""Tests that a locked SelectionList rejects all entry mutations."""
def setUp(self):
"""Create a locked SelectionList with one entry."""
super().setUp()
self.sel_list = SelectionList.objects.create(name='Locked List', locked=True)
self.entry = SelectionListEntry.objects.create(
list=self.sel_list, value='v1', label='Entry 1'
)
self.list_url = reverse(
'api-selectionlist-detail', kwargs={'pk': self.sel_list.pk}
)
self.entry_list_url = reverse(
'api-selectionlistentry-list', kwargs={'pk': self.sel_list.pk}
)
self.entry_detail_url = reverse(
'api-selectionlistentry-detail',
kwargs={'pk': self.sel_list.pk, 'entrypk': self.entry.pk},
)
def test_create_entry_locked(self):
"""POST a new entry to a locked list should be rejected."""
response = self.post(
self.entry_list_url,
{'list': self.sel_list.pk, 'value': 'v2', 'label': 'Entry 2'},
expected_code=400,
)
self.assertIn('list', response.data)
self.assertIn('locked', str(response.data['list']).lower())
def test_update_entry_locked(self):
"""PATCH an entry on a locked list should be rejected."""
response = self.patch(
self.entry_detail_url, {'label': 'Changed'}, expected_code=400
)
self.assertIn('list', response.data)
self.assertIn('locked', str(response.data['list']).lower())
def test_delete_entry_locked(self):
"""DELETE an entry from a locked list should be rejected."""
self.delete(self.entry_detail_url, expected_code=403)
self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists())
def test_patch_list_with_choices_locked(self):
"""PATCH the list with a choices payload should be rejected when locked."""
response = self.patch(
self.list_url,
{'choices': [{'value': 'v2', 'label': 'New'}]},
expected_code=400,
)
self.assertIn('locked', response.data)
def test_patch_list_without_choices_preserves_entries(self):
"""PATCH the list without choices should not touch entries (even when unlocked)."""
self.sel_list.locked = False
self.sel_list.save()
self.patch(self.list_url, {'name': 'Renamed List'}, expected_code=200)
# Entry must still exist — omitting choices must not delete entries
self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists())