[WIP] Test result table (#6430)

* Add basic table for stock item test results

* Improve custom data formatter callback

* Custom data formatter for returned results

* Update YesNoButton functionality

- Add PassFailButton with custom text

* Enhancements for stock item test result table

- Render all data

* Add placeholder row actions

* Fix table link

* Add option to filter parttesttemplate table by "inherited"

* Navigate through to parent part

* Update PartTestTemplate model

- Save 'key' value to database
- Update whenever model is saved
- Custom data migration

* Custom migration step in tasks.py

- Add custom management command
- Wraps migration step in maintenance mode

* Improve uniqueness validation for PartTestTemplate

* Add 'template' field to StockItemTestResult

- Links to a PartTestTemplate instance
- Add migrations to link existing PartTestTemplates

* Add "results" count to PartTestTemplate API

- Include in rendered tables

* Add 'results' column to test result table

- Allow filtering too

* Update serializer for StockItemTestResult

- Include template information
- Update CUI and PUI tables

* Control template_detail field with query params

* Update ref in api_version.py

* Update data migration

- Ensure new template is created for top level assembly

* Fix admin integration

* Update StockItemTestResult table

- Remove 'test' field
- Make 'template' field non-nullable
- Previous data migrations should have accounted for this

* Implement "legacy" API support

- Create test result by providing test name
- Lookup existing template

* PUI: Cleanup table

* Update tasks.py

- Exclude temporary settings when exporting data

* Fix unique validation check

* Remove duplicate code

* CUI: Fix data rendering

* More refactoring of PUI table

* More fixes for PUI table

* Get row expansion working (kinda)

* Improve rendering of subtable

* More PUI updates:

- Edit existing results
- Add new results

* allow delete of test result

* Fix typo

* Updates for admin integration

* Unit tests for stock migrations

* Added migration test for PartTestTemplate

* Fix for AttachmentTable

- Rebuild actions when permissions are recalculated

* Update test fixtures

* Add ModelType information

* Fix TableState

* Fix dataFormatter type def

* Improve table rendering

* Correctly filter "edit" and "delete" buttons

* Loosen requirements for dataFormatter

* Fixtures for report tests

* Better API filtering for StocokItemTestResult list

- Add Filter class
- Add option for filtering against legacy "name" data

* Cleanup API filter

* Fix unit tests

* Further unit test fixes

* Include test results for installed stock items

* Improve rendering of test result table

* Fix filtering for getTestResults

* More unit test fixes

* Fix more unit tests

* FIx part unit test

* More fixes

* More unit test fixes

* Rebuild stock item trees when merging

* Helper function for adding a test result to a stock item

* Set init fix

* Code cleanup

* Cleanup unused variables

* Add docs and more unit tests

* Update build unit test
This commit is contained in:
Oliver 2024-02-18 23:26:01 +11:00 committed by GitHub
parent ad1c1ae604
commit 0f51127adf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1505 additions and 243 deletions

View File

@ -1,12 +1,17 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 168 INVENTREE_API_VERSION = 169
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v168 -> 2024-02-07 : https://github.com/inventree/InvenTree/pull/4824 v169 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/6430
- Adds 'key' field to PartTestTemplate API endpoint
- Adds annotated 'results' field to PartTestTemplate API endpoint
- Adds 'template' field to StockItemTestResult API endpoint
v168 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/4824
- Adds machine CRUD API endpoints - Adds machine CRUD API endpoints
- Adds machine settings API endpoints - Adds machine settings API endpoints
- Adds machine restart API endpoint - Adds machine restart API endpoint

View File

@ -76,16 +76,23 @@ def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
return ref_int return ref_int
def generateTestKey(test_name): def generateTestKey(test_name: str) -> str:
"""Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template. """Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template.
Tests must be named such that they will have unique keys. Tests must be named such that they will have unique keys.
""" """
if test_name is None:
test_name = ''
key = test_name.strip().lower() key = test_name.strip().lower()
key = key.replace(' ', '') key = key.replace(' ', '')
# Remove any characters that cannot be used to represent a variable # Remove any characters that cannot be used to represent a variable
key = re.sub(r'[^a-zA-Z0-9]', '', key) key = re.sub(r'[^a-zA-Z0-9_]', '', key)
# If the key starts with a digit, prefix with an underscore
if key[0].isdigit():
key = '_' + key
return key return key

View File

@ -0,0 +1,19 @@
"""Check if there are any pending database migrations, and run them."""
import logging
from django.core.management.base import BaseCommand
from InvenTree.tasks import check_for_migrations
logger = logging.getLogger('inventree')
class Command(BaseCommand):
"""Check if there are any pending database migrations, and run them."""
def handle(self, *args, **kwargs):
"""Check for any pending database migrations."""
logger.info('Checking for pending database migrations')
check_for_migrations(force=True, reload_registry=False)
logger.info('Database migrations complete')

View File

@ -509,6 +509,20 @@ class TestHelpers(TestCase):
self.assertNotIn(PartCategory, models) self.assertNotIn(PartCategory, models)
self.assertNotIn(InvenTreeSetting, models) self.assertNotIn(InvenTreeSetting, models)
def test_test_key(self):
"""Test for the generateTestKey function."""
tests = {
' Hello World ': 'helloworld',
' MY NEW TEST KEY ': 'mynewtestkey',
' 1234 5678': '_12345678',
' 100 percenT': '_100percent',
' MY_NEW_TEST': 'my_new_test',
' 100_new_tests': '_100_new_tests',
}
for name, key in tests.items():
self.assertEqual(helpers.generateTestKey(name), key)
class TestQuoteWrap(TestCase): class TestQuoteWrap(TestCase):
"""Tests for string wrapping.""" """Tests for string wrapping."""

View File

@ -655,9 +655,10 @@ class BuildTest(BuildTestBase):
# let's complete the required test and see if it could be saved # let's complete the required test and see if it could be saved
StockItemTestResult.objects.create( StockItemTestResult.objects.create(
stock_item=self.stockitem_with_required_test, stock_item=self.stockitem_with_required_test,
test=self.test_template_required.test_name, template=self.test_template_required,
result=True result=True
) )
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None) self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
# let's see if a non required test could be saved # let's see if a non required test could be saved

View File

@ -363,6 +363,7 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
"""Admin class for the PartTestTemplate model.""" """Admin class for the PartTestTemplate model."""
list_display = ('part', 'test_name', 'required') list_display = ('part', 'test_name', 'required')
readonly_fields = ['key']
autocomplete_fields = ('part',) autocomplete_fields = ('part',)

View File

@ -389,29 +389,53 @@ class PartTestTemplateFilter(rest_filters.FilterSet):
Note that for the 'part' field, we also include any parts "above" the specified part. Note that for the 'part' field, we also include any parts "above" the specified part.
""" """
variants = part.get_ancestors(include_self=True) include_inherited = str2bool(
return queryset.filter(part__in=variants) self.request.query_params.get('include_inherited', True)
)
if include_inherited:
return queryset.filter(part__in=part.get_ancestors(include_self=True))
else:
return queryset.filter(part=part)
class PartTestTemplateDetail(RetrieveUpdateDestroyAPI): class PartTestTemplateMixin:
"""Mixin class for the PartTestTemplate API endpoints."""
queryset = PartTestTemplate.objects.all()
serializer_class = part_serializers.PartTestTemplateSerializer
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the PartTestTemplateDetail endpoints."""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartTestTemplateSerializer.annotate_queryset(
queryset
)
return queryset
class PartTestTemplateDetail(PartTestTemplateMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for PartTestTemplate model.""" """Detail endpoint for PartTestTemplate model."""
queryset = PartTestTemplate.objects.all() pass
serializer_class = part_serializers.PartTestTemplateSerializer
class PartTestTemplateList(ListCreateAPI): class PartTestTemplateList(PartTestTemplateMixin, ListCreateAPI):
"""API endpoint for listing (and creating) a PartTestTemplate.""" """API endpoint for listing (and creating) a PartTestTemplate."""
queryset = PartTestTemplate.objects.all()
serializer_class = part_serializers.PartTestTemplateSerializer
filterset_class = PartTestTemplateFilter filterset_class = PartTestTemplateFilter
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
search_fields = ['test_name', 'description'] search_fields = ['test_name', 'description']
ordering_fields = ['test_name', 'required', 'requires_value', 'requires_attachment'] ordering_fields = [
'test_name',
'required',
'requires_value',
'requires_attachment',
'results',
]
ordering = 'test_name' ordering = 'test_name'

View File

@ -4,30 +4,35 @@
fields: fields:
part: 10000 part: 10000
test_name: Test strength of chair test_name: Test strength of chair
key: 'teststrengthofchair'
- model: part.parttesttemplate - model: part.parttesttemplate
pk: 2 pk: 2
fields: fields:
part: 10000 part: 10000
test_name: Apply paint test_name: Apply paint
key: 'applypaint'
- model: part.parttesttemplate - model: part.parttesttemplate
pk: 3 pk: 3
fields: fields:
part: 10000 part: 10000
test_name: Sew cushion test_name: Sew cushion
key: 'sewcushion'
- model: part.parttesttemplate - model: part.parttesttemplate
pk: 4 pk: 4
fields: fields:
part: 10000 part: 10000
test_name: Attach legs test_name: Attach legs
key: 'attachlegs'
- model: part.parttesttemplate - model: part.parttesttemplate
pk: 5 pk: 5
fields: fields:
part: 10000 part: 10000
test_name: Record weight test_name: Record weight
key: 'recordweight'
required: false required: false
# Add some tests for one of the variants # Add some tests for one of the variants
@ -35,12 +40,30 @@
pk: 6 pk: 6
fields: fields:
part: 10003 part: 10003
test_name: Check that chair is green test_name: Check chair is green
key: 'checkchairisgreen'
required: true required: true
- model: part.parttesttemplate - model: part.parttesttemplate
pk: 7 pk: 8
fields: fields:
part: 10004 part: 25
test_name: Check that chair is especially green test_name: 'Temperature Test'
key: 'temperaturetest'
required: False
- model: part.parttesttemplate
pk: 9
fields:
part: 25
test_name: 'Settings Checksum'
key: 'settingschecksum'
required: False
- model: part.parttesttemplate
pk: 10
fields:
part: 25
test_name: 'Firmware Version'
key: 'firmwareversion'
required: False required: False

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.9 on 2024-02-07 03:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0119_auto_20231120_0457'),
]
operations = [
migrations.AddField(
model_name='parttesttemplate',
name='key',
field=models.CharField(blank=True, help_text='Simplified key for the test', max_length=100, verbose_name='Test Key'),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 4.2.9 on 2024-02-07 03:44
from django.db import migrations
def set_key(apps, schema_editor):
"""Create a 'key' value for existing PartTestTemplate objects."""
import InvenTree.helpers
PartTestTemplate = apps.get_model('part', 'PartTestTemplate')
for template in PartTestTemplate.objects.all():
template.key = InvenTree.helpers.generateTestKey(str(template.test_name).strip())
template.save()
if PartTestTemplate.objects.count() > 0:
print(f"\nUpdated 'key' value for {PartTestTemplate.objects.count()} PartTestTemplate objects")
class Migration(migrations.Migration):
dependencies = [
('part', '0120_parttesttemplate_key'),
]
operations = [
migrations.RunPython(set_key, reverse_code=migrations.RunPython.noop)
]

View File

@ -3393,6 +3393,10 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
run on the model (refer to the validate_unique function). run on the model (refer to the validate_unique function).
""" """
def __str__(self):
"""Format a string representation of this PartTestTemplate."""
return ' | '.join([self.part.name, self.test_name])
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
"""Return the list API endpoint URL associated with the PartTestTemplate model.""" """Return the list API endpoint URL associated with the PartTestTemplate model."""
@ -3408,6 +3412,8 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
"""Clean fields for the PartTestTemplate model.""" """Clean fields for the PartTestTemplate model."""
self.test_name = self.test_name.strip() self.test_name = self.test_name.strip()
self.key = helpers.generateTestKey(self.test_name)
self.validate_unique() self.validate_unique()
super().clean() super().clean()
@ -3418,30 +3424,18 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
'part': _('Test templates can only be created for trackable parts') 'part': _('Test templates can only be created for trackable parts')
}) })
# Get a list of all tests "above" this one # Check that this test is unique within the part tree
tests = PartTestTemplate.objects.filter( tests = PartTestTemplate.objects.filter(
part__in=self.part.get_ancestors(include_self=True) key=self.key, part__tree_id=self.part.tree_id
) ).exclude(pk=self.pk)
# If this item is already in the database, exclude it from comparison! if tests.exists():
if self.pk is not None: raise ValidationError({
tests = tests.exclude(pk=self.pk) 'test_name': _('Test with this name already exists for this part')
})
key = self.key
for test in tests:
if test.key == key:
raise ValidationError({
'test_name': _('Test with this name already exists for this part')
})
super().validate_unique(exclude) super().validate_unique(exclude)
@property
def key(self):
"""Generate a key for this test."""
return helpers.generateTestKey(self.test_name)
part = models.ForeignKey( part = models.ForeignKey(
Part, Part,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -3457,6 +3451,13 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
help_text=_('Enter a name for the test'), help_text=_('Enter a name for the test'),
) )
key = models.CharField(
blank=True,
max_length=100,
verbose_name=_('Test Key'),
help_text=_('Simplified key for the test'),
)
description = models.CharField( description = models.CharField(
blank=False, blank=False,
null=True, null=True,

View File

@ -156,9 +156,20 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer)
'required', 'required',
'requires_value', 'requires_value',
'requires_attachment', 'requires_attachment',
'results',
] ]
key = serializers.CharField(read_only=True) key = serializers.CharField(read_only=True)
results = serializers.IntegerField(
label=_('Results'),
help_text=_('Number of results recorded against this template'),
read_only=True,
)
@staticmethod
def annotate_queryset(queryset):
"""Custom query annotations for the PartTestTemplate serializer."""
return queryset.annotate(results=SubqueryCount('test_results'))
class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer): class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer):

View File

@ -806,14 +806,14 @@ class PartAPITest(PartAPITestBase):
response = self.get(url) response = self.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 7) self.assertEqual(len(response.data), 9)
# Request for a particular part # Request for a particular part
response = self.get(url, data={'part': 10000}) response = self.get(url, data={'part': 10000})
self.assertEqual(len(response.data), 5) self.assertEqual(len(response.data), 5)
response = self.get(url, data={'part': 10004}) response = self.get(url, data={'part': 10004})
self.assertEqual(len(response.data), 7) self.assertEqual(len(response.data), 6)
# Try to post a new object (missing description) # Try to post a new object (missing description)
response = self.post( response = self.post(

View File

@ -229,3 +229,47 @@ class TestPartParameterTemplateMigration(MigratorTestCase):
self.assertEqual(template.choices, '') self.assertEqual(template.choices, '')
self.assertEqual(template.checkbox, False) self.assertEqual(template.checkbox, False)
class TestPartTestParameterMigration(MigratorTestCase):
"""Unit tests for the PartTestTemplate model migrations."""
migrate_from = ('part', '0119_auto_20231120_0457')
migrate_to = ('part', '0121_auto_20240207_0344')
test_keys = {
'atest': 'A test',
'someresult': 'Some result',
'anotherresult': 'Another result',
}
def prepare(self):
"""Setup initial database state."""
Part = self.old_state.apps.get_model('part', 'part')
PartTestTemplate = self.old_state.apps.get_model('part', 'parttesttemplate')
# Create a part
p = Part.objects.create(
name='Test Part',
description='A test part',
level=0,
lft=0,
rght=0,
tree_id=0,
)
# Create some test templates
for v in self.test_keys.values():
PartTestTemplate.objects.create(
test_name=v, part=p, description='A test template'
)
self.assertEqual(PartTestTemplate.objects.count(), 3)
def test_key_field(self):
"""Self that the key field is created and correctly filled."""
PartTestTemplate = self.new_state.apps.get_model('part', 'parttesttemplate')
for key, value in self.test_keys.items():
template = PartTestTemplate.objects.get(test_name=value)
self.assertEqual(template.key, key)

View File

@ -378,8 +378,8 @@ class TestTemplateTest(TestCase):
# Test the lowest-level part which has more associated tests # Test the lowest-level part which has more associated tests
variant = Part.objects.get(pk=10004) variant = Part.objects.get(pk=10004)
self.assertEqual(variant.getTestTemplates().count(), 7) self.assertEqual(variant.getTestTemplates().count(), 6)
self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 1) self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 0)
self.assertEqual(variant.getTestTemplates(required=True).count(), 5) self.assertEqual(variant.getTestTemplates(required=True).count(), 5)
def test_uniqueness(self): def test_uniqueness(self):
@ -389,21 +389,29 @@ class TestTemplateTest(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
PartTestTemplate.objects.create(part=variant, test_name='Record weight') PartTestTemplate.objects.create(part=variant, test_name='Record weight')
# Test that error is raised if we try to create a duplicate test name
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
PartTestTemplate.objects.create( PartTestTemplate.objects.create(
part=variant, test_name='Check that chair is especially green' part=variant, test_name='Check chair is green'
) )
# Also should fail if we attempt to create a test that would generate the same key # Also should fail if we attempt to create a test that would generate the same key
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
PartTestTemplate.objects.create( template = PartTestTemplate.objects.create(
part=variant, test_name='ReCoRD weiGHT ' part=variant, test_name='ReCoRD weiGHT '
) )
template.clean()
# But we should be able to create a new one! # But we should be able to create a new one!
n = variant.getTestTemplates().count() n = variant.getTestTemplates().count()
PartTestTemplate.objects.create(part=variant, test_name='A Sample Test') template = PartTestTemplate.objects.create(
part=variant, test_name='A Sample Test'
)
# Test key should have been saved
self.assertEqual(template.key, 'asampletest')
self.assertEqual(variant.getTestTemplates().count(), n + 1) self.assertEqual(variant.getTestTemplates().count(), n + 1)

View File

@ -194,6 +194,7 @@ class ReportTest(InvenTreeAPITestCase):
'part', 'part',
'company', 'company',
'location', 'location',
'test_templates',
'supplier_part', 'supplier_part',
'stock', 'stock',
'stock_tests', 'stock_tests',

View File

@ -317,6 +317,6 @@ class StockTrackingAdmin(ImportExportModelAdmin):
class StockItemTestResultAdmin(admin.ModelAdmin): class StockItemTestResultAdmin(admin.ModelAdmin):
"""Admin class for StockItemTestResult.""" """Admin class for StockItemTestResult."""
list_display = ('stock_item', 'test', 'result', 'value') list_display = ('stock_item', 'test_name', 'result', 'value')
autocomplete_fields = ['stock_item'] autocomplete_fields = ['stock_item']

View File

@ -38,6 +38,7 @@ from InvenTree.filters import (
from InvenTree.helpers import ( from InvenTree.helpers import (
DownloadFile, DownloadFile,
extract_serial_numbers, extract_serial_numbers,
generateTestKey,
is_ajax, is_ajax,
isNull, isNull,
str2bool, str2bool,
@ -1188,22 +1189,87 @@ class StockAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
serializer_class = StockSerializers.StockItemAttachmentSerializer serializer_class = StockSerializers.StockItemAttachmentSerializer
class StockItemTestResultDetail(RetrieveUpdateDestroyAPI): class StockItemTestResultMixin:
"""Mixin class for the StockItemTestResult API endpoints."""
queryset = StockItemTestResult.objects.all()
serializer_class = StockSerializers.StockItemTestResultSerializer
def get_serializer_context(self):
"""Extend serializer context."""
ctx = super().get_serializer_context()
ctx['request'] = self.request
return ctx
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
try:
kwargs['user_detail'] = str2bool(
self.request.query_params.get('user_detail', False)
)
kwargs['template_detail'] = str2bool(
self.request.query_params.get('template_detail', False)
)
except Exception:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for StockItemTestResult.""" """Detail endpoint for StockItemTestResult."""
queryset = StockItemTestResult.objects.all() pass
serializer_class = StockSerializers.StockItemTestResultSerializer
class StockItemTestResultList(ListCreateDestroyAPIView): class StockItemTestResultFilter(rest_filters.FilterSet):
"""API filter for the StockItemTestResult list."""
class Meta:
"""Metaclass options."""
model = StockItemTestResult
# Simple filter fields
fields = ['user', 'template', 'result', 'value']
build = rest_filters.ModelChoiceFilter(
label='Build', queryset=Build.objects.all(), field_name='stock_item__build'
)
part = rest_filters.ModelChoiceFilter(
label='Part', queryset=Part.objects.all(), field_name='stock_item__part'
)
required = rest_filters.BooleanFilter(
label='Required', field_name='template__required'
)
test = rest_filters.CharFilter(
label='Test name (case insensitive)', method='filter_test_name'
)
def filter_test_name(self, queryset, name, value):
"""Filter by test name.
This method is provided for legacy support,
where the StockItemTestResult model had a "test" field.
Now the "test" name is stored against the PartTestTemplate model
"""
key = generateTestKey(value)
return queryset.filter(template__key=key)
class StockItemTestResultList(StockItemTestResultMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a StockItemTestResult object.""" """API endpoint for listing (and creating) a StockItemTestResult object."""
queryset = StockItemTestResult.objects.all() filterset_class = StockItemTestResultFilter
serializer_class = StockSerializers.StockItemTestResultSerializer
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['test', 'user', 'result', 'value'] filterset_fields = ['user', 'template', 'result', 'value']
ordering_fields = ['date', 'result']
ordering = 'date' ordering = 'date'
@ -1213,18 +1279,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView):
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
# Filter by 'build'
build = params.get('build', None)
if build is not None:
try:
build = Build.objects.get(pk=build)
queryset = queryset.filter(stock_item__build=build)
except (ValueError, Build.DoesNotExist):
pass
# Filter by stock item # Filter by stock item
item = params.get('stock_item', None) item = params.get('stock_item', None)
@ -1251,19 +1305,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView):
return queryset return queryset
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
try:
kwargs['user_detail'] = str2bool(
self.request.query_params.get('user_detail', False)
)
except Exception:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def perform_create(self, serializer): def perform_create(self, serializer):
"""Create a new test result object. """Create a new test result object.

View File

@ -2,7 +2,7 @@
pk: 1 pk: 1
fields: fields:
stock_item: 105 stock_item: 105
test: "Firmware Version" template: 10
value: "0xA1B2C3D4" value: "0xA1B2C3D4"
result: True result: True
date: 2020-02-02 date: 2020-02-02
@ -11,7 +11,7 @@
pk: 2 pk: 2
fields: fields:
stock_item: 105 stock_item: 105
test: "Settings Checksum" template: 9
value: "0xAABBCCDD" value: "0xAABBCCDD"
result: True result: True
date: 2020-02-02 date: 2020-02-02
@ -20,7 +20,7 @@
pk: 3 pk: 3
fields: fields:
stock_item: 105 stock_item: 105
test: "Temperature Test" template: 8
result: False result: False
date: 2020-05-16 date: 2020-05-16
notes: 'Got too hot or something' notes: 'Got too hot or something'
@ -29,7 +29,7 @@
pk: 4 pk: 4
fields: fields:
stock_item: 105 stock_item: 105
test: "Temperature Test" template: 8
result: True result: True
date: 2020-05-17 date: 2020-05-17
notes: 'Passed temperature test by making it cooler' notes: 'Passed temperature test by making it cooler'
@ -38,7 +38,7 @@
pk: 5 pk: 5
fields: fields:
stock_item: 522 stock_item: 522
test: 'applypaint' template: 2
result: True result: True
date: 2020-05-17 date: 2020-05-17
@ -46,7 +46,7 @@
pk: 6 pk: 6
fields: fields:
stock_item: 522 stock_item: 522
test: 'applypaint' template: 2
result: False result: False
date: 2020-05-18 date: 2020-05-18
@ -54,7 +54,7 @@
pk: 7 pk: 7
fields: fields:
stock_item: 522 stock_item: 522
test: 'Attach Legs' template: 4
result: True result: True
date: 2020-05-17 date: 2020-05-17
@ -62,15 +62,6 @@
pk: 8 pk: 8
fields: fields:
stock_item: 522 stock_item: 522
test: 'Check that chair is GreEn' template: 3
result: True result: True
date: 2020-05-17 date: 2024-02-15
- model: stock.stockitemtestresult
pk: 12345
fields:
stock_item: 522
test: 'test strength of chair'
result: False
value: 100kg
date: 2020-05-17

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.9 on 2024-02-07 03:52
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0121_auto_20240207_0344'),
('stock', '0104_alter_stockitem_purchase_price_currency'),
]
operations = [
migrations.AddField(
model_name='stockitemtestresult',
name='template',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='test_results', to='part.parttesttemplate'),
),
]

View File

@ -0,0 +1,129 @@
# Generated by Django 4.2.9 on 2024-02-07 03:53
from django.db import migrations
def set_template(apps, schema_editor):
"""Matching existing StockItemTestResult objects to their associated template.
- Use the 'key' value from the associated test object.
- Look at the referenced part first
- If no matches, look at parent part template(s)
- If still no matches, create a new PartTestTemplate object
"""
import time
import InvenTree.helpers
StockItemTestResult = apps.get_model('stock', 'stockitemtestresult')
PartTestTemplate = apps.get_model('part', 'parttesttemplate')
Part = apps.get_model('part', 'part')
# Look at any test results which do not match a template
results = StockItemTestResult.objects.filter(template=None)
parts = results.values_list('stock_item__part', flat=True).distinct()
n_results = results.count()
if n_results == 0:
return
print(f"\n{n_results} StockItemTestResult objects do not have matching templates!")
print(f"Updating test results for {len(parts)} unique parts...")
# Keep a map of test templates
part_tree_map = {}
t1 = time.time()
new_templates = 0
# For each part with missing templates, work out what templates are missing
for pk in parts:
part = Part.objects.get(pk=pk)
tree_id = part.tree_id
# Find all results matching this part
part_results = results.filter(stock_item__part=part)
test_names = part_results.values_list('test', flat=True).distinct()
key_map = part_tree_map.get(tree_id, None) or {}
for name in test_names:
template = None
key = InvenTree.helpers.generateTestKey(name)
if template := key_map.get(key, None):
# We have a template for this key
pass
elif template := PartTestTemplate.objects.filter(part__tree_id=part.tree_id, key=key).first():
# We have found an existing template for this test
pass
elif template := PartTestTemplate.objects.filter(part__tree_id=part.tree_id, test_name__iexact=name).first():
# We have found an existing template for this test
pass
# Create a new template, based on the available test information
else:
# Find the parent part template
top_level_part = part
while top_level_part.variant_of:
top_level_part = top_level_part.variant_of
template = PartTestTemplate.objects.create(
part=top_level_part,
test_name=name,
key=key,
)
new_templates += 1
# Finally, update all matching results
part_results.filter(test=name).update(template=template)
# Update the key map for this part tree
key_map[key] = template
# Update the part tree map
part_tree_map[tree_id] = key_map
t2 = time.time()
dt = t2 - t1
print(f"Updated {n_results} StockItemTestResult objects in {dt:.3f} seconds.")
if new_templates > 0:
print(f"Created {new_templates} new templates!")
# Check that there are now zero reamining results without templates
results = StockItemTestResult.objects.filter(template=None)
assert(results.count() == 0)
def remove_template(apps, schema_editor):
"""Remove template links from existing StockItemTestResult objects."""
StockItemTestResult = apps.get_model('stock', 'stockitemtestresult')
results = StockItemTestResult.objects.all()
results.update(template=None)
if results.count() > 0:
print(f"\nRemoved template links from {results.count()} StockItemTestResult objects")
class Migration(migrations.Migration):
atomic = False
dependencies = [
('stock', '0105_stockitemtestresult_template'),
('part', '0121_auto_20240207_0344')
]
operations = [
migrations.RunPython(set_template, reverse_code=remove_template),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.9 on 2024-02-07 09:01
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0121_auto_20240207_0344'),
('stock', '0106_auto_20240207_0353'),
]
operations = [
migrations.RemoveField(
model_name='stockitemtestresult',
name='test',
),
migrations.AlterField(
model_name='stockitemtestresult',
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_results', to='part.parttesttemplate'),
),
]

View File

@ -1550,6 +1550,52 @@ class StockItem(
result.stock_item = self result.stock_item = self
result.save() result.save()
def add_test_result(self, create_template=True, **kwargs):
"""Helper function to add a new StockItemTestResult.
The main purpose of this function is to allow lookup of the template,
based on the provided test name.
If no template is found, a new one is created (if create_template=True).
Args:
create_template: If True, create a new template if it does not exist
kwargs:
template: The ID of the associated PartTestTemplate
test_name: The name of the test (if the template is not provided)
result: The result of the test
value: The value of the test
user: The user who performed the test
notes: Any notes associated with the test
"""
template = kwargs.get('template', None)
test_name = kwargs.pop('test_name', None)
test_key = InvenTree.helpers.generateTestKey(test_name)
if template is None and test_name is not None:
# Attempt to find a matching template
template = PartModels.PartTestTemplate.objects.filter(
part__tree_id=self.part.tree_id, key=test_key
).first()
if template is None:
if create_template:
template = PartModels.PartTestTemplate.objects.create(
part=self.part, test_name=test_name
)
else:
raise ValidationError({
'template': _('Test template does not exist')
})
kwargs['template'] = template
kwargs['stock_item'] = self
return StockItemTestResult.objects.create(**kwargs)
def can_merge(self, other=None, raise_error=False, **kwargs): def can_merge(self, other=None, raise_error=False, **kwargs):
"""Check if this stock item can be merged into another stock item.""" """Check if this stock item can be merged into another stock item."""
allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False) allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False)
@ -1623,6 +1669,9 @@ class StockItem(
if len(other_items) == 0: if len(other_items) == 0:
return return
# Keep track of the tree IDs that are being merged
tree_ids = {self.tree_id}
user = kwargs.get('user', None) user = kwargs.get('user', None)
location = kwargs.get('location', None) location = kwargs.get('location', None)
notes = kwargs.get('notes', None) notes = kwargs.get('notes', None)
@ -1634,6 +1683,8 @@ class StockItem(
if not self.can_merge(other, raise_error=raise_error, **kwargs): if not self.can_merge(other, raise_error=raise_error, **kwargs):
return return
tree_ids.add(other.tree_id)
for other in other_items: for other in other_items:
self.quantity += other.quantity self.quantity += other.quantity
@ -1665,6 +1716,14 @@ class StockItem(
self.location = location self.location = location
self.save() self.save()
# Rebuild stock trees as required
try:
for tree_id in tree_ids:
StockItem.objects.partial_rebuild(tree_id=tree_id)
except Exception:
logger.warning('Rebuilding entire StockItem tree')
StockItem.objects.rebuild()
@transaction.atomic @transaction.atomic
def splitStock(self, quantity, location=None, user=None, **kwargs): def splitStock(self, quantity, location=None, user=None, **kwargs):
"""Split this stock item into two items, in the same location. """Split this stock item into two items, in the same location.
@ -1994,19 +2053,24 @@ class StockItem(
results.delete() results.delete()
def getTestResults(self, test=None, result=None, user=None): def getTestResults(self, template=None, test=None, result=None, user=None):
"""Return all test results associated with this StockItem. """Return all test results associated with this StockItem.
Optionally can filter results by: Optionally can filter results by:
- Test template ID
- Test name - Test name
- Test result - Test result
- User - User
""" """
results = self.test_results results = self.test_results
if template:
results = results.filter(template=template)
if test: if test:
# Filter by test name # Filter by test name
results = results.filter(test=test) test_key = InvenTree.helpers.generateTestKey(test)
results = results.filter(template__key=test_key)
if result is not None: if result is not None:
# Filter by test status # Filter by test status
@ -2037,8 +2101,7 @@ class StockItem(
result_map = {} result_map = {}
for result in results: for result in results:
key = InvenTree.helpers.generateTestKey(result.test) result_map[result.key] = result
result_map[key] = result
# Do we wish to "cascade" and include test results from installed stock items? # Do we wish to "cascade" and include test results from installed stock items?
cascade = kwargs.get('cascade', False) cascade = kwargs.get('cascade', False)
@ -2098,7 +2161,7 @@ class StockItem(
def hasRequiredTests(self): def hasRequiredTests(self):
"""Return True if there are any 'required tests' associated with this StockItem.""" """Return True if there are any 'required tests' associated with this StockItem."""
return self.part.getRequiredTests().count() > 0 return self.required_test_count > 0
def passedAllRequiredTests(self): def passedAllRequiredTests(self):
"""Returns True if this StockItem has passed all required tests.""" """Returns True if this StockItem has passed all required tests."""
@ -2286,7 +2349,7 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
Attributes: Attributes:
stock_item: Link to StockItem stock_item: Link to StockItem
test: Test name (simple string matching) template: Link to TestTemplate
result: Test result value (pass / fail / etc) result: Test result value (pass / fail / etc)
value: Recorded test output value (optional) value: Recorded test output value (optional)
attachment: Link to StockItem attachment (optional) attachment: Link to StockItem attachment (optional)
@ -2295,6 +2358,10 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
date: Date the test result was recorded date: Date the test result was recorded
""" """
def __str__(self):
"""Return string representation."""
return f'{self.test_name} - {self.result}'
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
"""Return API url.""" """Return API url."""
@ -2334,14 +2401,22 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
@property @property
def key(self): def key(self):
"""Return key for test.""" """Return key for test."""
return InvenTree.helpers.generateTestKey(self.test) return InvenTree.helpers.generateTestKey(self.test_name)
stock_item = models.ForeignKey( stock_item = models.ForeignKey(
StockItem, on_delete=models.CASCADE, related_name='test_results' StockItem, on_delete=models.CASCADE, related_name='test_results'
) )
test = models.CharField( @property
blank=False, max_length=100, verbose_name=_('Test'), help_text=_('Test name') def test_name(self):
"""Return the test name of the associated test template."""
return self.template.test_name
template = models.ForeignKey(
'part.parttesttemplate',
on_delete=models.CASCADE,
blank=False,
related_name='test_results',
) )
result = models.BooleanField( result = models.BooleanField(

View File

@ -1,5 +1,6 @@
"""JSON serializers for Stock app.""" """JSON serializers for Stock app."""
import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal from decimal import Decimal
@ -23,7 +24,7 @@ import part.models as part_models
import stock.filters import stock.filters
from company.serializers import SupplierPartSerializer from company.serializers import SupplierPartSerializer
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer, PartTestTemplateSerializer
from .models import ( from .models import (
StockItem, StockItem,
@ -34,6 +35,8 @@ from .models import (
StockLocationType, StockLocationType,
) )
logger = logging.getLogger('inventree')
class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer): class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Provides a brief serializer for a StockLocation object.""" """Provides a brief serializer for a StockLocation object."""
@ -56,8 +59,6 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
fields = [ fields = [
'pk', 'pk',
'stock_item', 'stock_item',
'key',
'test',
'result', 'result',
'value', 'value',
'attachment', 'attachment',
@ -65,6 +66,8 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
'user', 'user',
'user_detail', 'user_detail',
'date', 'date',
'template',
'template_detail',
] ]
read_only_fields = ['pk', 'user', 'date'] read_only_fields = ['pk', 'user', 'date']
@ -72,20 +75,67 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Add detail fields.""" """Add detail fields."""
user_detail = kwargs.pop('user_detail', False) user_detail = kwargs.pop('user_detail', False)
template_detail = kwargs.pop('template_detail', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if user_detail is not True: if user_detail is not True:
self.fields.pop('user_detail') self.fields.pop('user_detail')
if template_detail is not True:
self.fields.pop('template_detail')
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True) user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True)
key = serializers.CharField(read_only=True) template = serializers.PrimaryKeyRelatedField(
queryset=part_models.PartTestTemplate.objects.all(),
many=False,
required=False,
allow_null=True,
help_text=_('Template'),
label=_('Test template for this result'),
)
template_detail = PartTestTemplateSerializer(source='template', read_only=True)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField( attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(
required=False required=False
) )
def validate(self, data):
"""Validate the test result data."""
stock_item = data['stock_item']
template = data.get('template', None)
# To support legacy API, we can accept a test name instead of a template
# In such a case, we use the test name to lookup the appropriate template
test_name = self.context['request'].data.get('test', None)
if not template and not test_name:
raise ValidationError(_('Template ID or test name must be provided'))
if not template:
test_key = InvenTree.helpers.generateTestKey(test_name)
# Find a template based on name
if template := part_models.PartTestTemplate.objects.filter(
part__tree_id=stock_item.part.tree_id, key=test_key
).first():
data['template'] = template
else:
logger.info(
"No matching test template found for '%s' - creating a new template",
test_name,
)
# Create a new test template based on the provided dasta
data['template'] = part_models.PartTestTemplate.objects.create(
part=stock_item.part, test_name=test_name
)
return super().validate(data)
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer): class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
"""Brief serializers for a StockItem.""" """Brief serializers for a StockItem."""

View File

@ -292,6 +292,7 @@
constructForm('{% url "api-stock-test-result-list" %}', { constructForm('{% url "api-stock-test-result-list" %}', {
method: 'POST', method: 'POST',
fields: stockItemTestResultFields({ fields: stockItemTestResultFields({
part: {{ item.part.pk }},
stock_item: {{ item.pk }}, stock_item: {{ item.pk }},
}), }),
title: '{% trans "Add Test Result" escape %}', title: '{% trans "Add Test Result" escape %}',

View File

@ -19,7 +19,7 @@ import part.models
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.status_codes import StockHistoryCode, StockStatus from InvenTree.status_codes import StockHistoryCode, StockStatus
from InvenTree.unit_test import InvenTreeAPITestCase from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part from part.models import Part, PartTestTemplate
from stock.models import ( from stock.models import (
StockItem, StockItem,
StockItemTestResult, StockItemTestResult,
@ -34,6 +34,7 @@ class StockAPITestCase(InvenTreeAPITestCase):
fixtures = [ fixtures = [
'category', 'category',
'part', 'part',
'test_templates',
'bom', 'bom',
'company', 'company',
'location', 'location',
@ -1559,6 +1560,8 @@ class StockTestResultTest(StockAPITestCase):
response = self.client.get(url) response = self.client.get(url)
n = len(response.data) n = len(response.data)
# Test upload using test name (legacy method)
# Note that a new test template will be created
data = { data = {
'stock_item': 105, 'stock_item': 105,
'test': 'Checked Steam Valve', 'test': 'Checked Steam Valve',
@ -1569,6 +1572,9 @@ class StockTestResultTest(StockAPITestCase):
response = self.post(url, data, expected_code=201) response = self.post(url, data, expected_code=201)
# Check that a new test template has been created
test_template = PartTestTemplate.objects.get(key='checkedsteamvalve')
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(len(response.data), n + 1) self.assertEqual(len(response.data), n + 1)
@ -1581,6 +1587,27 @@ class StockTestResultTest(StockAPITestCase):
self.assertEqual(test['value'], '150kPa') self.assertEqual(test['value'], '150kPa')
self.assertEqual(test['user'], self.user.pk) self.assertEqual(test['user'], self.user.pk)
# Test upload using template reference (new method)
data = {
'stock_item': 105,
'template': test_template.pk,
'result': True,
'value': '75kPa',
}
response = self.post(url, data, expected_code=201)
# Check that a new test template has been created
self.assertEqual(test_template.test_results.all().count(), 2)
# List test results against the template
response = self.client.get(url, data={'template': test_template.pk})
self.assertEqual(len(response.data), 2)
for item in response.data:
self.assertEqual(item['template'], test_template.pk)
def test_post_bitmap(self): def test_post_bitmap(self):
"""2021-08-25. """2021-08-25.
@ -1598,14 +1625,15 @@ class StockTestResultTest(StockAPITestCase):
with open(image_file, 'rb') as bitmap: with open(image_file, 'rb') as bitmap:
data = { data = {
'stock_item': 105, 'stock_item': 105,
'test': 'Checked Steam Valve', 'test': 'Temperature Test',
'result': False, 'result': False,
'value': '150kPa', 'value': '550C',
'notes': 'I guess there was just too much pressure?', 'notes': 'I guess there was just too much heat?',
'attachment': bitmap, 'attachment': bitmap,
} }
response = self.client.post(self.get_url(), data) response = self.client.post(self.get_url(), data)
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
# Check that an attachment has been uploaded # Check that an attachment has been uploaded
@ -1619,23 +1647,34 @@ class StockTestResultTest(StockAPITestCase):
url = reverse('api-stock-test-result-list') url = reverse('api-stock-test-result-list')
stock_item = StockItem.objects.get(pk=1)
# Ensure the part is marked as "trackable"
p = stock_item.part
p.trackable = True
p.save()
# Create some objects (via the API) # Create some objects (via the API)
for _ii in range(50): for _ii in range(50):
response = self.post( response = self.post(
url, url,
{ {
'stock_item': 1, 'stock_item': stock_item.pk,
'test': f'Some test {_ii}', 'test': f'Some test {_ii}',
'result': True, 'result': True,
'value': 'Test result value', 'value': 'Test result value',
}, },
expected_code=201, # expected_code=201,
) )
tests.append(response.data['pk']) tests.append(response.data['pk'])
self.assertEqual(StockItemTestResult.objects.count(), n + 50) self.assertEqual(StockItemTestResult.objects.count(), n + 50)
# Filter test results by part
response = self.get(url, {'part': p.pk}, expected_code=200)
self.assertEqual(len(response.data), 50)
# Attempt a delete without providing items # Attempt a delete without providing items
self.delete(url, {}, expected_code=400) self.delete(url, {}, expected_code=400)
@ -1838,6 +1877,7 @@ class StockMetadataAPITest(InvenTreeAPITestCase):
fixtures = [ fixtures = [
'category', 'category',
'part', 'part',
'test_templates',
'bom', 'bom',
'company', 'company',
'location', 'location',

View File

@ -133,3 +133,100 @@ class TestScheduledForDeletionMigration(MigratorTestCase):
# All the "scheduled for deletion" items have been removed # All the "scheduled for deletion" items have been removed
self.assertEqual(StockItem.objects.count(), 3) self.assertEqual(StockItem.objects.count(), 3)
class TestTestResultMigration(MigratorTestCase):
"""Unit tests for StockItemTestResult data migrations."""
migrate_from = ('stock', '0103_stock_location_types')
migrate_to = ('stock', '0107_remove_stockitemtestresult_test_and_more')
test_keys = {
'appliedpaint': 'Applied Paint',
'programmed': 'Programmed',
'checkedresultcode': 'Checked Result CODE',
}
def prepare(self):
"""Create initial data."""
Part = self.old_state.apps.get_model('part', 'part')
PartTestTemplate = self.old_state.apps.get_model('part', 'parttesttemplate')
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
StockItemTestResult = self.old_state.apps.get_model(
'stock', 'stockitemtestresult'
)
# Create a test part
parent_part = Part.objects.create(
name='Parent Part',
description='A parent part',
is_template=True,
active=True,
trackable=True,
level=0,
tree_id=1,
lft=0,
rght=0,
)
# Create some child parts
children = [
Part.objects.create(
name=f'Child part {idx}',
description='A child part',
variant_of=parent_part,
active=True,
trackable=True,
level=0,
tree_id=1,
lft=0,
rght=0,
)
for idx in range(3)
]
# Create some stock items
for ii, child in enumerate(children):
for jj in range(4):
si = StockItem.objects.create(
part=child,
serial=str(1 + ii * jj),
quantity=1,
tree_id=0,
level=0,
lft=0,
rght=0,
)
# Create some test results
for _k, v in self.test_keys.items():
StockItemTestResult.objects.create(
stock_item=si, test=v, result=True, value=f'Result: {ii} : {jj}'
)
# Check initial record counts
self.assertEqual(PartTestTemplate.objects.count(), 0)
self.assertEqual(StockItemTestResult.objects.count(), 36)
def test_migration(self):
"""Test that the migrations were applied as expected."""
Part = self.new_state.apps.get_model('part', 'part')
PartTestTemplate = self.new_state.apps.get_model('part', 'parttesttemplate')
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
StockItemTestResult = self.new_state.apps.get_model(
'stock', 'stockitemtestresult'
)
# Test that original record counts are correct
self.assertEqual(Part.objects.count(), 4)
self.assertEqual(StockItem.objects.count(), 12)
self.assertEqual(StockItemTestResult.objects.count(), 36)
# Two more test templates should have been created
self.assertEqual(PartTestTemplate.objects.count(), 3)
for k in self.test_keys.keys():
self.assertTrue(PartTestTemplate.objects.filter(key=k).exists())
for result in StockItemTestResult.objects.all():
self.assertIsNotNone(result.template)

View File

@ -12,7 +12,7 @@ from company.models import Company
from InvenTree.status_codes import StockHistoryCode from InvenTree.status_codes import StockHistoryCode
from InvenTree.unit_test import InvenTreeTestCase from InvenTree.unit_test import InvenTreeTestCase
from order.models import SalesOrder from order.models import SalesOrder
from part.models import Part from part.models import Part, PartTestTemplate
from .models import StockItem, StockItemTestResult, StockItemTracking, StockLocation from .models import StockItem, StockItemTestResult, StockItemTracking, StockLocation
@ -1086,31 +1086,51 @@ class TestResultTest(StockTestBase):
self.assertEqual(status['total'], 5) self.assertEqual(status['total'], 5)
self.assertEqual(status['passed'], 2) self.assertEqual(status['passed'], 2)
self.assertEqual(status['failed'], 2) self.assertEqual(status['failed'], 1)
self.assertFalse(item.passedAllRequiredTests()) self.assertFalse(item.passedAllRequiredTests())
# Add some new test results to make it pass! # Add some new test results to make it pass!
test = StockItemTestResult.objects.get(pk=12345) test = StockItemTestResult.objects.get(pk=8)
test.result = True test.result = False
test.save() test.save()
status = item.requiredTestStatus()
self.assertEqual(status['total'], 5)
self.assertEqual(status['passed'], 1)
self.assertEqual(status['failed'], 2)
template = PartTestTemplate.objects.get(pk=3)
StockItemTestResult.objects.create( StockItemTestResult.objects.create(
stock_item=item, test='sew cushion', result=True stock_item=item, template=template, result=True
) )
# Still should be failing at this point, # Still should be failing at this point,
# as the most recent "apply paint" test was False # as the most recent "apply paint" test was False
self.assertFalse(item.passedAllRequiredTests()) self.assertFalse(item.passedAllRequiredTests())
template = PartTestTemplate.objects.get(pk=2)
# Add a new test result against this required test # Add a new test result against this required test
StockItemTestResult.objects.create( StockItemTestResult.objects.create(
stock_item=item, stock_item=item,
test='apply paint', template=template,
date=datetime.datetime(2022, 12, 12), date=datetime.datetime(2022, 12, 12),
result=True, result=True,
) )
self.assertFalse(item.passedAllRequiredTests())
# Generate a passing result for all required tests
for template in item.part.getRequiredTests():
StockItemTestResult.objects.create(
stock_item=item,
template=template,
result=True,
date=datetime.datetime(2025, 12, 12),
)
self.assertTrue(item.passedAllRequiredTests()) self.assertTrue(item.passedAllRequiredTests())
def test_duplicate_item_tests(self): def test_duplicate_item_tests(self):
@ -1140,17 +1160,9 @@ class TestResultTest(StockTestBase):
item.save() item.save()
# Do some tests! # Do some tests!
StockItemTestResult.objects.create( item.add_test_result(test_name='Firmware', result=True)
stock_item=item, test='Firmware', result=True item.add_test_result(test_name='Paint Color', result=True, value='Red')
) item.add_test_result(test_name='Applied Sticker', result=False)
StockItemTestResult.objects.create(
stock_item=item, test='Paint Color', result=True, value='Red'
)
StockItemTestResult.objects.create(
stock_item=item, test='Applied Sticker', result=False
)
self.assertEqual(item.test_results.count(), 3) self.assertEqual(item.test_results.count(), 3)
self.assertEqual(item.quantity, 50) self.assertEqual(item.quantity, 50)
@ -1163,7 +1175,7 @@ class TestResultTest(StockTestBase):
self.assertEqual(item.test_results.count(), 3) self.assertEqual(item.test_results.count(), 3)
self.assertEqual(item2.test_results.count(), 3) self.assertEqual(item2.test_results.count(), 3)
StockItemTestResult.objects.create(stock_item=item2, test='A new test') item2.add_test_result(test_name='A new test')
self.assertEqual(item.test_results.count(), 3) self.assertEqual(item.test_results.count(), 3)
self.assertEqual(item2.test_results.count(), 4) self.assertEqual(item2.test_results.count(), 4)
@ -1172,7 +1184,7 @@ class TestResultTest(StockTestBase):
item2.serializeStock(1, [100], self.user) item2.serializeStock(1, [100], self.user)
# Add a test result to the parent *after* serialization # Add a test result to the parent *after* serialization
StockItemTestResult.objects.create(stock_item=item2, test='abcde') item2.add_test_result(test_name='abcde')
self.assertEqual(item2.test_results.count(), 5) self.assertEqual(item2.test_results.count(), 5)
@ -1201,11 +1213,20 @@ class TestResultTest(StockTestBase):
) )
# Now, create some test results against the sub item # Now, create some test results against the sub item
# Ensure there is a matching PartTestTemplate
if template := PartTestTemplate.objects.filter(
part=item.part, key='firmwareversion'
).first():
pass
else:
template = PartTestTemplate.objects.create(
part=item.part, test_name='Firmware Version', required=True
)
# First test is overshadowed by the same test for the parent part # First test is overshadowed by the same test for the parent part
StockItemTestResult.objects.create( StockItemTestResult.objects.create(
stock_item=sub_item, stock_item=sub_item,
test='firmware version', template=template,
date=datetime.datetime.now().date(), date=datetime.datetime.now().date(),
result=True, result=True,
) )
@ -1214,10 +1235,19 @@ class TestResultTest(StockTestBase):
tests = item.testResultMap(include_installed=True) tests = item.testResultMap(include_installed=True)
self.assertEqual(len(tests), 3) self.assertEqual(len(tests), 3)
if template := PartTestTemplate.objects.filter(
part=item.part, key='somenewtest'
).first():
pass
else:
template = PartTestTemplate.objects.create(
part=item.part, test_name='Some New Test', required=True
)
# Now, add a *unique* test result for the sub item # Now, add a *unique* test result for the sub item
StockItemTestResult.objects.create( StockItemTestResult.objects.create(
stock_item=sub_item, stock_item=sub_item,
test='some new test', template=template,
date=datetime.datetime.now().date(), date=datetime.datetime.now().date(),
result=False, result=False,
value='abcde', value='abcde',

View File

@ -68,6 +68,8 @@ function getModelRenderer(model) {
return renderPartCategory; return renderPartCategory;
case 'partparametertemplate': case 'partparametertemplate':
return renderPartParameterTemplate; return renderPartParameterTemplate;
case 'parttesttemplate':
return renderPartTestTemplate;
case 'purchaseorder': case 'purchaseorder':
return renderPurchaseOrder; return renderPurchaseOrder;
case 'salesorder': case 'salesorder':
@ -483,6 +485,18 @@ function renderPartParameterTemplate(data, parameters={}) {
} }
function renderPartTestTemplate(data, parameters={}) {
return renderModel(
{
text: data.test_name,
textSecondary: data.description,
},
parameters
);
}
// Renderer for "ManufacturerPart" model // Renderer for "ManufacturerPart" model
function renderManufacturerPart(data, parameters={}) { function renderManufacturerPart(data, parameters={}) {

View File

@ -2867,6 +2867,18 @@ function loadPartTestTemplateTable(table, options) {
field: 'test_name', field: 'test_name',
title: '{% trans "Test Name" %}', title: '{% trans "Test Name" %}',
sortable: true, sortable: true,
formatter: function(value, row) {
let html = value;
if (row.results && row.results > 0) {
html += `
<span class='badge bg-dark rounded-pill float-right' title='${row.results} {% trans "results" %}'>
${row.results}
</span>`;
}
return html;
}
}, },
{ {
field: 'description', field: 'description',
@ -2909,7 +2921,7 @@ function loadPartTestTemplateTable(table, options) {
} else { } else {
var text = '{% trans "This test is defined for a parent part" %}'; var text = '{% trans "This test is defined for a parent part" %}';
return renderLink(text, `/part/${row.part}/tests/`); return renderLink(text, `/part/${row.part}/?display=test-templates`);
} }
} }
} }

View File

@ -1381,7 +1381,11 @@ function formatDate(row) {
/* Construct set of default fields for a StockItemTestResult */ /* Construct set of default fields for a StockItemTestResult */
function stockItemTestResultFields(options={}) { function stockItemTestResultFields(options={}) {
let fields = { let fields = {
test: {}, template: {
filters: {
include_inherited: true,
}
},
result: {}, result: {},
value: {}, value: {},
attachment: {}, attachment: {},
@ -1393,6 +1397,10 @@ function stockItemTestResultFields(options={}) {
}, },
}; };
if (options.part) {
fields.template.filters.part = options.part;
}
if (options.stock_item) { if (options.stock_item) {
fields.stock_item.value = options.stock_item; fields.stock_item.value = options.stock_item;
} }
@ -1412,6 +1420,7 @@ function loadStockTestResultsTable(table, options) {
let params = { let params = {
part: options.part, part: options.part,
include_inherited: true,
}; };
var filters = loadTableFilters(filterKey, params); var filters = loadTableFilters(filterKey, params);
@ -1424,17 +1433,16 @@ function loadStockTestResultsTable(table, options) {
let html = ''; let html = '';
if (row.requires_attachment == false && row.requires_value == false && !row.result) { if (row.parent != parent_node && row.requires_attachment == false && row.requires_value == false && !row.result) {
// Enable a "quick tick" option for this test result // Enable a "quick tick" option for this test result
html += makeIconButton('fa-check-circle icon-green', 'button-test-tick', row.test_name, '{% trans "Pass test" %}'); html += makeIconButton('fa-check-circle icon-green', 'button-test-tick', row.test_name, '{% trans "Pass test" %}');
} }
html += makeIconButton('fa-plus icon-green', 'button-test-add', row.test_name, '{% trans "Add test result" %}'); html += makeIconButton('fa-plus icon-green', 'button-test-add', row.templateId, '{% trans "Add test result" %}');
if (!grouped && row.result != null) { if (!grouped && row.result != null) {
var pk = row.pk; html += makeEditButton('button-test-edit', row.testId, '{% trans "Edit test result" %}');
html += makeEditButton('button-test-edit', pk, '{% trans "Edit test result" %}'); html += makeDeleteButton('button-test-delete', row.testId, '{% trans "Delete test result" %}');
html += makeDeleteButton('button-test-delete', pk, '{% trans "Delete test result" %}');
} }
return wrapButtons(html); return wrapButtons(html);
@ -1532,9 +1540,14 @@ function loadStockTestResultsTable(table, options) {
], ],
onLoadSuccess: function(tableData) { onLoadSuccess: function(tableData) {
// Set "parent" for each existing row // Construct an initial dataset based on the returned templates
tableData.forEach(function(item, idx) { let results = tableData.map((template) => {
tableData[idx].parent = parent_node; return {
...template,
templateId: template.pk,
parent: parent_node,
results: []
};
}); });
// Once the test template data are loaded, query for test results // Once the test template data are loaded, query for test results
@ -1545,6 +1558,7 @@ function loadStockTestResultsTable(table, options) {
stock_item: options.stock_item, stock_item: options.stock_item,
user_detail: true, user_detail: true,
attachment_detail: true, attachment_detail: true,
template_detail: false,
ordering: '-date', ordering: '-date',
}; };
@ -1561,54 +1575,40 @@ function loadStockTestResultsTable(table, options) {
query_params, query_params,
{ {
success: function(data) { success: function(data) {
// Iterate through the returned test data
data.forEach(function(item) {
var match = false; data.sort((a, b) => {
var override = false; return a.pk < b.pk;
}).forEach((row) => {
let idx = results.findIndex((template) => {
return template.templateId == row.template;
});
// Extract the simplified test key if (idx > -1) {
var key = item.key;
// Attempt to associate this result with an existing test results[idx].results.push(row);
for (var idx = 0; idx < tableData.length; idx++) {
var row = tableData[idx]; // Check if a test result is already recorded
if (results[idx].testId) {
if (key == row.key) { // Push this result into the results array
results.push({
item.test_name = row.test_name; ...results[idx],
item.test_description = row.description; ...row,
item.required = row.required; parent: results[idx].templateId,
testId: row.pk,
if (row.result == null) { });
item.parent = parent_node; } else {
tableData[idx] = item; // First result - update the parent row
override = true; results[idx] = {
} else { ...row,
item.parent = row.pk; ...results[idx],
} testId: row.pk,
};
match = true;
break;
} }
} }
// No match could be found
if (!match) {
item.test_name = item.test;
item.parent = parent_node;
}
if (!override) {
tableData.push(item);
}
}); });
// Push data back into the table // Push data back into the table
table.bootstrapTable('load', tableData); table.bootstrapTable('load', results);
} }
} }
); );
@ -1645,25 +1645,17 @@ function loadStockTestResultsTable(table, options) {
$(table).on('click', '.button-test-add', function() { $(table).on('click', '.button-test-add', function() {
var button = $(this); var button = $(this);
var test_name = button.attr('pk'); var templateId = button.attr('pk');
let fields = stockItemTestResultFields();
fields['stock_item']['value'] = options.stock_item;
fields['template']['value'] = templateId;
fields['template']['filters']['part'] = options.part;
constructForm('{% url "api-stock-test-result-list" %}', { constructForm('{% url "api-stock-test-result-list" %}', {
method: 'POST', method: 'POST',
fields: { fields: fields,
test: {
value: test_name,
},
result: {},
value: {},
attachment: {},
notes: {
icon: 'fa-sticky-note',
},
stock_item: {
value: options.stock_item,
hidden: true,
}
},
title: '{% trans "Add Test Result" %}', title: '{% trans "Add Test Result" %}',
onSuccess: reloadTestTable, onSuccess: reloadTestTable,
}); });
@ -1692,11 +1684,9 @@ function loadStockTestResultsTable(table, options) {
var url = `/api/stock/test/${pk}/`; var url = `/api/stock/test/${pk}/`;
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
var html = ` var html = `
<div class='alert alert-block alert-danger'> <div class='alert alert-block alert-danger'>
<strong>{% trans "Delete test result" %}:</strong> ${row.test_name || row.test || row.key} <strong>{% trans "Delete test result" %}</strong>
</div>`; </div>`;
constructForm(url, { constructForm(url, {

View File

@ -20,7 +20,22 @@ Test templates "cascade" down to variant parts: this means that if a master part
The name of the test is a simple string value which defines the name of the test. This test must be unique for a given part (or across a set of part variants). The name of the test is a simple string value which defines the name of the test. This test must be unique for a given part (or across a set of part variants).
The test name is used to generate a test "key" which is then used to match against test results associated with individual stock items. #### Test Key
The test name is used to generate a test "key" which is then used to match against test results associated with individual stock items. The *key* is a simplified string representation of the test name, which consists only of lowercase alphanumeric characters. This key value is automatically generated (based on the test name) whenever the test template is saved.
The generated test key is intended to be a valid python variable name, and can be used to reference the test in the report generation system.
##### Examples
Some examples of generated test key values are provided below:
| Test Name | Test Key |
| --- | --- |
| "Firmware Version" | "firmwareversion" |
| " My NEW T E sT " | "mynewtest" |
| "100 Percent Test"| "_100percenttest" *(note that the leading underscore is added to ensure the key is a valid python variable name)* |
| "Test 123" | "test123" |
#### Test Description #### Test Description

View File

@ -14,9 +14,9 @@ The master "Part" record for the stock item can define multiple [test templates]
### Test Result Fields ### Test Result Fields
#### Test Name #### Test Template
The name of the test data is used to associate the test with a test template object. The *template* field links to a [Part Test Template](../part/test.md#part-test-templates) object. Each test result instance must link to a test template.
#### Result #### Result

View File

@ -21,7 +21,7 @@ export function setApiDefaults() {
const token = useSessionState.getState().token; const token = useSessionState.getState().token;
api.defaults.baseURL = host; api.defaults.baseURL = host;
api.defaults.timeout = 1000; api.defaults.timeout = 2500;
if (!!token) { if (!!token) {
api.defaults.headers.common['Authorization'] = `Token ${token}`; api.defaults.headers.common['Authorization'] = `Token ${token}`;

View File

@ -3,8 +3,18 @@ import { Badge } from '@mantine/core';
import { isTrue } from '../../functions/conversion'; import { isTrue } from '../../functions/conversion';
export function YesNoButton({ value }: { value: any }) { export function PassFailButton({
value,
passText,
failText
}: {
value: any;
passText?: string;
failText?: string;
}) {
const v = isTrue(value); const v = isTrue(value);
const pass = passText || t`Pass`;
const fail = failText || t`Fail`;
return ( return (
<Badge <Badge
@ -13,7 +23,11 @@ export function YesNoButton({ value }: { value: any }) {
radius="lg" radius="lg"
size="sm" size="sm"
> >
{v ? t`Yes` : t`No`} {v ? pass : fail}
</Badge> </Badge>
); );
} }
export function YesNoButton({ value }: { value: any }) {
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
}

View File

@ -23,7 +23,8 @@ import {
import { import {
RenderPart, RenderPart,
RenderPartCategory, RenderPartCategory,
RenderPartParameterTemplate RenderPartParameterTemplate,
RenderPartTestTemplate
} from './Part'; } from './Part';
import { RenderStockItem, RenderStockLocation } from './Stock'; import { RenderStockItem, RenderStockLocation } from './Stock';
import { RenderOwner, RenderUser } from './User'; import { RenderOwner, RenderUser } from './User';
@ -48,6 +49,7 @@ const RendererLookup: EnumDictionary<
[ModelType.part]: RenderPart, [ModelType.part]: RenderPart,
[ModelType.partcategory]: RenderPartCategory, [ModelType.partcategory]: RenderPartCategory,
[ModelType.partparametertemplate]: RenderPartParameterTemplate, [ModelType.partparametertemplate]: RenderPartParameterTemplate,
[ModelType.parttesttemplate]: RenderPartTestTemplate,
[ModelType.projectcode]: RenderProjectCode, [ModelType.projectcode]: RenderProjectCode,
[ModelType.purchaseorder]: RenderPurchaseOrder, [ModelType.purchaseorder]: RenderPurchaseOrder,
[ModelType.purchaseorderline]: RenderPurchaseOrder, [ModelType.purchaseorderline]: RenderPurchaseOrder,

View File

@ -32,6 +32,13 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/partparametertemplate/:pk/', url_detail: '/partparametertemplate/:pk/',
api_endpoint: ApiEndpoints.part_parameter_template_list api_endpoint: ApiEndpoints.part_parameter_template_list
}, },
parttesttemplate: {
label: t`Part Test Template`,
label_multiple: t`Part Test Templates`,
url_overview: '/parttesttemplate',
url_detail: '/parttesttemplate/:pk/',
api_endpoint: ApiEndpoints.part_test_template_list
},
supplierpart: { supplierpart: {
label: t`Supplier Part`, label: t`Supplier Part`,
label_multiple: t`Supplier Parts`, label_multiple: t`Supplier Parts`,

View File

@ -51,3 +51,16 @@ export function RenderPartParameterTemplate({
/> />
); );
} }
export function RenderPartTestTemplate({
instance
}: {
instance: any;
}): ReactNode {
return (
<RenderInlineModel
primary={instance.test_name}
secondary={instance.description}
/>
);
}

View File

@ -80,6 +80,7 @@ export enum ApiEndpoints {
stock_location_list = 'stock/location/', stock_location_list = 'stock/location/',
stock_location_tree = 'stock/location/tree/', stock_location_tree = 'stock/location/tree/',
stock_attachment_list = 'stock/attachment/', stock_attachment_list = 'stock/attachment/',
stock_test_result_list = 'stock/test/',
// Order API endpoints // Order API endpoints
purchase_order_list = 'order/po/', purchase_order_list = 'order/po/',

View File

@ -7,6 +7,7 @@ export enum ModelType {
manufacturerpart = 'manufacturerpart', manufacturerpart = 'manufacturerpart',
partcategory = 'partcategory', partcategory = 'partcategory',
partparametertemplate = 'partparametertemplate', partparametertemplate = 'partparametertemplate',
parttesttemplate = 'parttesttemplate',
projectcode = 'projectcode', projectcode = 'projectcode',
stockitem = 'stockitem', stockitem = 'stockitem',
stocklocation = 'stocklocation', stocklocation = 'stocklocation',

View File

@ -51,6 +51,7 @@ export function useInstance<T = any>({
return api return api
.get(url, { .get(url, {
timeout: 10000,
params: params params: params
}) })
.then((response) => { .then((response) => {

View File

@ -19,6 +19,8 @@ export type TableState = {
activeFilters: TableFilter[]; activeFilters: TableFilter[];
setActiveFilters: (filters: TableFilter[]) => void; setActiveFilters: (filters: TableFilter[]) => void;
clearActiveFilters: () => void; clearActiveFilters: () => void;
expandedRecords: any[];
setExpandedRecords: (records: any[]) => void;
selectedRecords: any[]; selectedRecords: any[];
setSelectedRecords: (records: any[]) => void; setSelectedRecords: (records: any[]) => void;
clearSelectedRecords: () => void; clearSelectedRecords: () => void;
@ -59,6 +61,9 @@ export function useTable(tableName: string): TableState {
setActiveFilters([]); setActiveFilters([]);
}, []); }, []);
// Array of expanded records
const [expandedRecords, setExpandedRecords] = useState<any[]>([]);
// Array of selected records // Array of selected records
const [selectedRecords, setSelectedRecords] = useState<any[]>([]); const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
@ -81,6 +86,8 @@ export function useTable(tableName: string): TableState {
activeFilters, activeFilters,
setActiveFilters, setActiveFilters,
clearActiveFilters, clearActiveFilters,
expandedRecords,
setExpandedRecords,
selectedRecords, selectedRecords,
setSelectedRecords, setSelectedRecords,
clearSelectedRecords, clearSelectedRecords,

View File

@ -41,6 +41,7 @@ import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable'; import { AttachmentTable } from '../../tables/general/AttachmentTable';
import { StockItemTable } from '../../tables/stock/StockItemTable'; import { StockItemTable } from '../../tables/stock/StockItemTable';
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
export default function StockDetail() { export default function StockDetail() {
const { id } = useParams(); const { id } = useParams();
@ -89,7 +90,15 @@ export default function StockDetail() {
name: 'testdata', name: 'testdata',
label: t`Test Data`, label: t`Test Data`,
icon: <IconChecklist />, icon: <IconChecklist />,
hidden: !stockitem?.part_detail?.trackable hidden: !stockitem?.part_detail?.trackable,
content: stockitem?.pk ? (
<StockItemTestResultTable
itemId={stockitem.pk}
partId={stockitem.part}
/>
) : (
<Skeleton />
)
}, },
{ {
name: 'installed_items', name: 'installed_items',

View File

@ -74,7 +74,9 @@ export function ReferenceColumn(): TableColumn {
export function NoteColumn(): TableColumn { export function NoteColumn(): TableColumn {
return { return {
accessor: 'note', accessor: 'note',
sortable: false sortable: false,
title: t`Note`,
render: (record: any) => record.note ?? record.notes
}; };
} }
@ -119,6 +121,15 @@ export function ResponsibleColumn(): TableColumn {
}; };
} }
export function DateColumn(): TableColumn {
return {
accessor: 'date',
sortable: true,
title: t`Date`,
render: (record: any) => renderDate(record.date)
};
}
export function TargetDateColumn(): TableColumn { export function TargetDateColumn(): TableColumn {
return { return {
accessor: 'target_date', accessor: 'target_date',

View File

@ -74,8 +74,9 @@ export type InvenTreeTableProps<T = any> = {
tableFilters?: TableFilter[]; tableFilters?: TableFilter[];
tableActions?: React.ReactNode[]; tableActions?: React.ReactNode[];
printingActions?: any[]; printingActions?: any[];
rowExpansion?: any;
idAccessor?: string; idAccessor?: string;
dataFormatter?: (data: T) => any; dataFormatter?: (data: any) => any;
rowActions?: (record: T) => RowAction[]; rowActions?: (record: T) => RowAction[];
onRowClick?: (record: T, index: number, event: any) => void; onRowClick?: (record: T, index: number, event: any) => void;
}; };
@ -223,14 +224,12 @@ export function InvenTreeTable<T = any>({
hidden: false, hidden: false,
switchable: false, switchable: false,
width: 50, width: 50,
render: function (record: any) { render: (record: any) => (
return ( <RowActions
<RowActions actions={tableProps.rowActions?.(record) ?? []}
actions={tableProps.rowActions?.(record) ?? []} disabled={tableState.selectedRecords.length > 0}
disabled={tableState.selectedRecords.length > 0} />
/> )
);
}
}); });
} }
@ -373,14 +372,11 @@ export function InvenTreeTable<T = any>({
tableProps.noRecordsText ?? t`No records found` tableProps.noRecordsText ?? t`No records found`
); );
let results = []; let results = response.data?.results ?? response.data ?? [];
if (props.dataFormatter) { if (props.dataFormatter) {
// Custom data formatter provided // Custom data formatter provided
results = props.dataFormatter(response.data); results = props.dataFormatter(results);
} else {
// Extract returned data (accounting for pagination) and ensure it is a list
results = response.data?.results ?? response.data ?? [];
} }
if (!Array.isArray(results)) { if (!Array.isArray(results)) {
@ -611,6 +607,7 @@ export function InvenTreeTable<T = any>({
onSelectedRecordsChange={ onSelectedRecordsChange={
tableProps.enableSelection ? onSelectedRecordsChange : undefined tableProps.enableSelection ? onSelectedRecordsChange : undefined
} }
rowExpansion={tableProps.rowExpansion}
fetching={isFetching} fetching={isFetching}
noRecordsText={missingRecordsText} noRecordsText={missingRecordsText}
records={data} records={data}

View File

@ -1,10 +1,14 @@
import { t } from '@lingui/macro'; import { Trans, t } from '@lingui/macro';
import { Alert, Badge, Text } from '@mantine/core';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton'; import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles'; import { UserRoles } from '../../enums/Roles';
import { getDetailUrl } from '../../functions/urls';
import { import {
useCreateApiFormModal, useCreateApiFormModal,
useDeleteApiFormModal, useDeleteApiFormModal,
@ -22,6 +26,7 @@ import { RowDeleteAction, RowEditAction } from '../RowActions';
export default function PartTestTemplateTable({ partId }: { partId: number }) { export default function PartTestTemplateTable({ partId }: { partId: number }) {
const table = useTable('part-test-template'); const table = useTable('part-test-template');
const user = useUserState(); const user = useUserState();
const navigate = useNavigate();
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
return [ return [
@ -30,6 +35,15 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
switchable: false, switchable: false,
sortable: true sortable: true
}, },
{
accessor: 'results',
switchable: true,
sortable: true,
title: t`Results`,
render: (record: any) => {
return record.results || <Badge color="blue">{t`No Results`}</Badge>;
}
},
DescriptionColumn({ DescriptionColumn({
switchable: false switchable: false
}), }),
@ -43,7 +57,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
accessor: 'requires_attachment' accessor: 'requires_attachment'
}) })
]; ];
}, []); }, [partId]);
const tableFilters: TableFilter[] = useMemo(() => { const tableFilters: TableFilter[] = useMemo(() => {
return [ return [
@ -58,6 +72,11 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
{ {
name: 'requires_attachment', name: 'requires_attachment',
description: t`Show tests that require an attachment` description: t`Show tests that require an attachment`
},
{
name: 'include_inherited',
label: t`Include Inherited`,
description: t`Show tests from inherited templates`
} }
]; ];
}, []); }, []);
@ -99,13 +118,27 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
url: ApiEndpoints.part_test_template_list, url: ApiEndpoints.part_test_template_list,
pk: selectedTest, pk: selectedTest,
title: t`Delete Test Template`, title: t`Delete Test Template`,
preFormContent: (
<Alert color="red" title={t`This action cannot be reversed`}>
<Text>
<Trans>
Any tests results associated with this template will be deleted
</Trans>
</Text>
</Alert>
),
onFormSuccess: table.refreshTable onFormSuccess: table.refreshTable
}); });
const rowActions = useCallback( const rowActions = useCallback(
(record: any) => { (record: any) => {
let can_edit = user.hasChangeRole(UserRoles.part); const can_edit = user.hasChangeRole(UserRoles.part);
let can_delete = user.hasDeleteRole(UserRoles.part); const can_delete = user.hasDeleteRole(UserRoles.part);
if (record.part != partId) {
// No actions, as this test is defined for a parent part
return [];
}
return [ return [
RowEditAction({ RowEditAction({
@ -124,7 +157,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
}) })
]; ];
}, },
[user] [user, partId]
); );
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
@ -150,11 +183,18 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
columns={tableColumns} columns={tableColumns}
props={{ props={{
params: { params: {
part: partId part: partId,
part_detail: true
}, },
tableFilters: tableFilters, tableFilters: tableFilters,
tableActions: tableActions, tableActions: tableActions,
rowActions: rowActions rowActions: rowActions,
onRowClick: (row) => {
if (row.part && row.part != partId) {
// This test is defined for a different part
navigate(getDetailUrl(ModelType.part, row.part));
}
}
}} }}
/> />
</> </>

View File

@ -67,8 +67,8 @@ export default function CurrencyTable() {
columns={columns} columns={columns}
props={{ props={{
tableActions: tableActions, tableActions: tableActions,
dataFormatter: (data) => { dataFormatter: (data: any) => {
let rates = data?.exchange_rates ?? {}; let rates = data.exchange_rates ?? {};
return Object.entries(rates).map(([currency, rate]) => { return Object.entries(rates).map(([currency, rate]) => {
return { return {

View File

@ -338,7 +338,7 @@ export function StockItemTable({ params = {} }: { params?: any }) {
columns={tableColumns} columns={tableColumns}
props={{ props={{
enableDownload: true, enableDownload: true,
enableSelection: true, enableSelection: false,
tableFilters: tableFilters, tableFilters: tableFilters,
onRowClick: (record) => onRowClick: (record) =>
navigate(getDetailUrl(ModelType.stockitem, record.pk)), navigate(getDetailUrl(ModelType.stockitem, record.pk)),

View File

@ -0,0 +1,428 @@
import { t } from '@lingui/macro';
import { Badge, Group, Text, Tooltip } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import {
IconCircleCheck,
IconCirclePlus,
IconInfoCircle
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable } from 'mantine-datatable';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../App';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { AttachmentLink } from '../../components/items/AttachmentLink';
import { PassFailButton } from '../../components/items/YesNoButton';
import { RenderUser } from '../../components/render/User';
import { renderDate } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { DescriptionColumn, NoteColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowActions, RowDeleteAction, RowEditAction } from '../RowActions';
export default function StockItemTestResultTable({
partId,
itemId
}: {
partId: number;
itemId: number;
}) {
const user = useUserState();
const table = useTable('stocktests');
// Fetch the test templates required for this stock item
const { data: testTemplates } = useQuery({
queryKey: ['stocktesttemplates', partId, itemId],
queryFn: async () => {
if (!partId) {
return [];
}
return api
.get(apiUrl(ApiEndpoints.part_test_template_list), {
params: {
part: partId,
include_inherited: true
}
})
.then((response) => response.data)
.catch((_error) => []);
}
});
useEffect(() => {
table.refreshTable();
}, [testTemplates]);
// Format the test results based on the returned data
const formatRecords = useCallback(
(records: any[]): any[] => {
// Construct a list of test templates
let results = testTemplates.map((template: any) => {
return {
...template,
templateId: template.pk,
results: []
};
});
// If any of the tests results point to templates which we do not have, add them in
records.forEach((record) => {
if (!results.find((r: any) => r.templateId == record.template)) {
results.push({
...record.template_detail,
templateId: record.template,
results: []
});
}
});
// Iterate through the returned records
// Note that the results are sorted by oldest first,
// to ensure that the most recent result is displayed "on top"
records
.sort((a: any, b: any) => {
return a.pk > b.pk ? 1 : -1;
})
.forEach((record) => {
// Find matching template
let idx = results.findIndex(
(r: any) => r.templateId == record.template
);
if (idx >= 0) {
results[idx] = {
...results[idx],
...record
};
results[idx].results.push(record);
}
});
return results;
},
[partId, itemId, testTemplates]
);
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'test',
title: t`Test`,
switchable: false,
sortable: true,
render: (record: any) => {
let required = record.required ?? record.template_detail?.required;
let installed =
record.stock_item != undefined && record.stock_item != itemId;
return (
<Group position="apart">
<Text italic={installed} fw={required && 700}>
{!record.templateId && '- '}
{record.test_name ?? record.template_detail?.test_name}
</Text>
<Group position="right">
{record.results && record.results.length > 1 && (
<Tooltip label={t`Test Results`}>
<Badge color="lightblue" variant="filled">
{record.results.length}
</Badge>
</Tooltip>
)}
{installed && (
<Tooltip label={t`Test result for installed stock item`}>
<IconInfoCircle size={16} color="blue" />
</Tooltip>
)}
</Group>
</Group>
);
}
},
{
accessor: 'result',
title: t`Result`,
switchable: false,
sortable: true,
render: (record: any) => {
if (record.result === undefined) {
return (
<Badge color="lightblue" variant="filled">{t`No Result`}</Badge>
);
} else {
return <PassFailButton value={record.result} />;
}
}
},
DescriptionColumn({
accessor: 'description'
}),
{
accessor: 'value',
title: t`Value`
},
{
accessor: 'attachment',
title: t`Attachment`,
render: (record: any) =>
record.attachment && <AttachmentLink attachment={record.attachment} />
},
NoteColumn(),
{
accessor: 'date',
sortable: true,
title: t`Date`,
render: (record: any) => {
return (
<Group position="apart">
{renderDate(record.date)}
{record.user_detail && (
<RenderUser instance={record.user_detail} />
)}
</Group>
);
}
}
];
}, [itemId]);
const resultFields: ApiFormFieldSet = useMemo(() => {
return {
template: {
filters: {
include_inherited: true,
part: partId
}
},
result: {},
value: {},
attachment: {},
notes: {},
stock_item: {
value: itemId,
hidden: true
}
};
}, [partId, itemId]);
const [selectedTemplate, setSelectedTemplate] = useState<number | undefined>(
undefined
);
const newTestModal = useCreateApiFormModal({
url: ApiEndpoints.stock_test_result_list,
fields: resultFields,
initialData: {
template: selectedTemplate,
result: true
},
title: t`Add Test Result`,
onFormSuccess: () => table.refreshTable(),
successMessage: t`Test result added`
});
const [selectedTest, setSelectedTest] = useState<number | undefined>(
undefined
);
const editTestModal = useEditApiFormModal({
url: ApiEndpoints.stock_test_result_list,
pk: selectedTest,
fields: resultFields,
title: t`Edit Test Result`,
onFormSuccess: () => table.refreshTable(),
successMessage: t`Test result updated`
});
const deleteTestModal = useDeleteApiFormModal({
url: ApiEndpoints.stock_test_result_list,
pk: selectedTest,
title: t`Delete Test Result`,
onFormSuccess: () => table.refreshTable(),
successMessage: t`Test result deleted`
});
const passTest = useCallback(
(templateId: number) => {
api
.post(apiUrl(ApiEndpoints.stock_test_result_list), {
template: templateId,
stock_item: itemId,
result: true
})
.then(() => {
table.refreshTable();
showNotification({
title: t`Test Passed`,
message: t`Test result has been recorded`,
color: 'green'
});
});
},
[itemId]
);
const rowActions = useCallback(
(record: any) => {
if (record.stock_item != undefined && record.stock_item != itemId) {
// Test results for other stock items cannot be edited
return [];
}
return [
{
title: t`Pass Test`,
color: 'green',
icon: <IconCircleCheck />,
hidden:
!record.templateId ||
record?.requires_attachment ||
record?.requires_value ||
record.result,
onClick: () => passTest(record.templateId)
},
{
title: t`Add`,
tooltip: t`Add Test Result`,
color: 'green',
icon: <IconCirclePlus />,
hidden: !user.hasAddRole(UserRoles.stock) || !record.templateId,
onClick: () => {
setSelectedTemplate(record.templateId);
newTestModal.open();
}
},
RowEditAction({
tooltip: t`Edit Test Result`,
hidden:
!user.hasChangeRole(UserRoles.stock) || !record.template_detail,
onClick: () => {
setSelectedTest(record.pk);
editTestModal.open();
}
}),
RowDeleteAction({
tooltip: t`Delete Test Result`,
hidden:
!user.hasDeleteRole(UserRoles.stock) || !record.template_detail,
onClick: () => {
setSelectedTest(record.pk);
deleteTestModal.open();
}
})
];
},
[user, itemId]
);
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'required',
label: t`Required`,
description: t`Show results for required tests`
},
{
name: 'include_installed',
label: t`Include Installed`,
description: t`Show results for installed stock items`
},
{
name: 'result',
label: t`Passed`,
description: t`Show only passed tests`
}
];
}, []);
const tableActions = useMemo(() => {
return [
<AddItemButton
tooltip={t`Add Test Result`}
onClick={() => {
setSelectedTemplate(undefined);
newTestModal.open();
}}
hidden={!user.hasAddRole(UserRoles.stock)}
/>
];
}, [user]);
// Row expansion controller
const rowExpansion: any = useMemo(() => {
const cols: any = [
...tableColumns,
{
accessor: 'actions',
title: ' ',
hidden: false,
switchable: false,
width: 50,
render: (record: any) => (
<RowActions actions={rowActions(record) ?? []} />
)
}
];
return {
allowMultiple: true,
content: ({ record }: { record: any }) => {
if (!record || !record.results || record.results.length < 2) {
return null;
}
const results = record?.results ?? [];
return (
<DataTable
key={record.pk}
noHeader
columns={cols}
records={results.slice(0, -1)}
/>
);
}
};
}, []);
return (
<>
{newTestModal.modal}
{editTestModal.modal}
{deleteTestModal.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.stock_test_result_list)}
tableState={table}
columns={tableColumns}
props={{
dataFormatter: formatRecords,
enablePagination: false,
tableActions: tableActions,
tableFilters: tableFilters,
rowActions: rowActions,
rowExpansion: rowExpansion,
params: {
stock_item: itemId,
user_detail: true,
attachment_detail: true,
template_detail: true
}
}}
/>
</>
);
}

View File

@ -105,12 +105,7 @@ def content_excludes(
excludes.append('socialaccount.socialapp') excludes.append('socialaccount.socialapp')
excludes.append('socialaccount.socialtoken') excludes.append('socialaccount.socialtoken')
output = '' return ' '.join([f'--exclude {e}' for e in excludes])
for e in excludes:
output += f'--exclude {e} '
return output
def localDir() -> Path: def localDir() -> Path:
@ -370,6 +365,7 @@ def migrate(c):
print('========================================') print('========================================')
# Run custom management command which wraps migrations in "maintenance mode" # Run custom management command which wraps migrations in "maintenance mode"
manage(c, 'makemigrations')
manage(c, 'runmigrations', pty=True) manage(c, 'runmigrations', pty=True)
manage(c, 'migrate --run-syncdb') manage(c, 'migrate --run-syncdb')